From fb70396d09e49d3ff2190dc55415b7fba3e0f289 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 10:21:23 +0000 Subject: [PATCH 1/2] Add Kaiser, Crazy 8s and Wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more card games, engine to screen. Kaiser: 32-card prairie trick-taking with bidding, the five of hearts worth five and the three of spades costing three. Bidding is capped at ten because ten is all a hand can be worth, and a losing score gives the game a floor — without one a table that keeps bidding and going down never reaches a result. Crazy 8s: eights are wild and name the suit in force, which is why the table states that suit in words rather than leaving it to be read off the pile. Blocked rounds are scored rather than hanging. Wizard: the 60-card deck, one more card dealt each round until the pack runs out, bid your tricks exactly. The shared card model gains WIZARD and JESTER ranks, so every ordinary deck now builds from Rank.standard rather than walking the whole enum. Also fixes a lobby bug this would have hit: hosting seated the catalogue minimum rather than the table the host had just set up, so a six-handed game would have been rejected by its own rules engine. 164 engine tests pass locally. --- README.md | 12 +- .../org/prolibertate/games/game/Catalog.kt | 6 +- .../prolibertate/games/game/cards/Cards.kt | 50 +- .../games/game/crazyeights/CrazyEightsAi.kt | 52 ++ .../game/crazyeights/CrazyEightsModel.kt | 90 ++++ .../game/crazyeights/CrazyEightsRules.kt | 253 ++++++++++ .../games/game/kaiser/KaiserAi.kt | 117 +++++ .../games/game/kaiser/KaiserModel.kt | 127 +++++ .../games/game/kaiser/KaiserRules.kt | 304 +++++++++++ .../games/game/wizard/WizardAi.kt | 151 ++++++ .../games/game/wizard/WizardModel.kt | 123 +++++ .../games/game/wizard/WizardRules.kt | 263 ++++++++++ .../prolibertate/games/ui/GameSetupScreen.kt | 169 ++++++- .../org/prolibertate/games/ui/LobbyScreen.kt | 8 +- .../org/prolibertate/games/ui/PlayScreen.kt | 119 +++++ .../games/ui/game/CrazyEightsScreen.kt | 397 +++++++++++++++ .../games/ui/game/KaiserScreen.kt | 414 +++++++++++++++ .../games/ui/game/WizardScreen.kt | 473 ++++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 3 + .../game/crazyeights/CrazyEightsRulesTest.kt | 337 +++++++++++++ .../games/game/kaiser/KaiserRulesTest.kt | 369 ++++++++++++++ .../games/game/sequence/SequenceRulesTest.kt | 2 +- .../games/game/wizard/WizardRulesTest.kt | 471 +++++++++++++++++ docs/RULES-crazy8s.md | 59 +++ docs/RULES-kaiser.md | 78 +++ docs/RULES-wizard.md | 81 +++ 26 files changed, 4504 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/crazyeights/CrazyEightsRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/kaiser/KaiserAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/kaiser/KaiserModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/kaiser/KaiserRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/wizard/WizardAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/wizard/WizardModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/wizard/WizardRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/CrazyEightsScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/KaiserScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/WizardScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/crazyeights/CrazyEightsRulesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/kaiser/KaiserRulesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/wizard/WizardRulesTest.kt create mode 100644 docs/RULES-crazy8s.md create mode 100644 docs/RULES-kaiser.md create mode 100644 docs/RULES-wizard.md 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. From d6e237ce4a8c6ceba212573bee75906139aa10d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:34:31 +0000 Subject: [PATCH 2/2] Add chess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full game: castling with every condition, en passant including the case where taking would expose your own king, promotion to any of the four, pins, checkmate, stalemate, insufficient material, the fifty-move rule and threefold repetition. Move generation is checked against the published perft counts rather than against hand-written cases — the opening array to depth four (197,281 positions) plus four standard test positions chosen precisely because they break generators. Those counts are exact, so matching them leaves very little room for a rule to be subtly wrong. The opponent is a negamax search with alpha-beta, iterative deepening and a capture-only quiescence tail, at three strengths. The quiescence tail is the part that stops it hanging pieces: a fixed-depth search that stops mid-exchange scores the position as though the recapture never comes. Two supporting changes: The AI now thinks off the caller's thread. A card AI answers instantly, but a chess search runs for a second or more, and the match controller's scope belongs to the screen — searching there would freeze the board it is drawn on. Offline setup can seat the local player somewhere other than seat 0, which is what lets you play Black. Seat 0 is White by definition, so choosing a colour has to choose a seat. FEN is read and written, so a table can start from a set position. 203 engine tests pass locally. --- README.md | 8 +- .../org/prolibertate/games/game/Catalog.kt | 2 +- .../prolibertate/games/game/chess/ChessAi.kt | 281 +++++++++ .../games/game/chess/ChessModel.kt | 258 ++++++++ .../games/game/chess/ChessRules.kt | 577 ++++++++++++++++++ .../prolibertate/games/net/MatchController.kt | 23 +- .../java/org/prolibertate/games/ui/AppRoot.kt | 2 +- .../prolibertate/games/ui/GameSetupScreen.kt | 72 ++- .../org/prolibertate/games/ui/PlayScreen.kt | 26 + .../prolibertate/games/ui/game/ChessScreen.kt | 378 ++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 1 + .../games/game/chess/ChessAiTest.kt | 131 ++++ .../games/game/chess/ChessRulesTest.kt | 417 +++++++++++++ docs/RULES-chess.md | 84 +++ 14 files changed, 2243 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/chess/ChessModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/chess/ChessRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/chess/ChessRulesTest.kt create mode 100644 docs/RULES-chess.md diff --git a/README.md b/README.md index 5cf4362..db4866a 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 seven games are playable end to end: +The app shell is complete and eight games are playable end to end: | | | | --- | --- | -| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence | -| **Listed, not yet implemented** | Mastermind, Backgammon, Chess, Checkers | +| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess | +| **Listed, not yet implemented** | Mastermind, Backgammon, 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, @@ -153,6 +153,7 @@ game: - [Golf](docs/RULES-golf.md) - [Wizard](docs/RULES-wizard.md) - [Crazy 8s](docs/RULES-crazy8s.md) +- [Chess](docs/RULES-chess.md) ## Layout @@ -167,6 +168,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested golf/ Golf model, scoring, rules, AI wizard/ Wizard model, rules, AI crazyeights/ Crazy 8s model, rules, AI + chess/ Chess model, FEN, move generation, search 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 f942acf..5a09f32 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -138,7 +138,7 @@ object GameCatalog { maxPlayers = 2, teamBased = false, blurb = "The old game. Full legal moves, castling, en passant, promotion.", - available = false, + available = true, ), GameDescriptor( id = CHECKERS, diff --git a/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt b/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt new file mode 100644 index 0000000..3b4cb04 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt @@ -0,0 +1,281 @@ +package org.prolibertate.games.game.chess + +import kotlin.math.abs +import kotlin.random.Random +import org.prolibertate.games.game.engine.GameAi + +/** + * A negamax search with alpha-beta pruning, iterative deepening and a capture- + * only quiescence tail. + * + * The quiescence tail is what stops it hanging pieces: a fixed-depth search that + * stops in the middle of an exchange scores the position as though the recapture + * never happens, and every such search plays like it is being robbed. + * + * Search depth comes from the table's own options unless a level is passed in, + * so the setup screen sets the strength without the caller having to thread it + * through. + */ +class ChessAi(private val level: ChessLevel? = null) : GameAi { + + /** + * Ceiling on how much thinking any one move gets. A phone should not stall + * on a sharp middlegame, so the search stops where it is and plays the best + * move from the last depth it finished. + */ + private var nodeBudget = 0 + private var nodesUsed = 0 + + override fun chooseMove(state: ChessState, seat: Int, legal: List): ChessMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val setting = level ?: state.options.level + if (legal.size == 1) return legal.first() + + nodeBudget = NODE_BUDGET + nodesUsed = 0 + + // Deterministic per position, so a host and a client replaying the same + // game reach the same move, but varied enough that two games from the + // opening array do not run identically. + val random = Random(state.repetitionKeys.size * 31L + state.toFen().hashCode()) + + var best = legal.first() + var scored = legal.map { it to 0 } + + for (depth in 1..setting.depth) { + val results = mutableListOf>() + // Search the previous iteration's best first, so the sub-searches + // below it have a decent bound to prune against. + val ordered = orderMoves(state, legal, preferred = best) + for (move in ordered) { + // Every root move gets a full window rather than being cut off + // against the best so far. It costs some pruning, but it is + // what makes the scores comparable — the weaker levels pick + // from among the near-best moves, and a move that returned only + // an upper bound cannot be compared with one that did not. + val score = -negamax( + state = ChessRules.advanced(state, move), + depth = depth - 1, + alpha = -INFINITY, + beta = INFINITY, + ply = 1, + ) + results += move to score + if (nodesUsed >= nodeBudget) break + } + // A half-finished iteration has scored only some of the moves, so + // it is thrown away rather than compared against a full one. + if (nodesUsed >= nodeBudget && results.size < ordered.size) break + scored = results + best = results.maxBy { it.second }.first + } + + // Weaker levels take any move that is nearly as good, which is what + // makes them beatable without making them play nonsense. + val bestScore = scored.maxOf { it.second } + val acceptable = scored.filter { it.second >= bestScore - setting.slack() } + return acceptable[random.nextInt(acceptable.size)].first + } + + private fun ChessLevel.slack(): Int = when (this) { + ChessLevel.CASUAL -> 70 + ChessLevel.CLUB -> 20 + ChessLevel.STRONG -> 0 + } + + // ----------------------------------------------------------------------- + // Search + // ----------------------------------------------------------------------- + + private fun negamax(state: ChessState, depth: Int, alpha: Int, beta: Int, ply: Int): Int { + nodesUsed++ + val moves = ChessRules.legalMoves(state) + if (moves.isEmpty()) { + // Mate is scored by distance, so the search prefers mating sooner + // and, when it is losing, delays as long as it can. + return if (ChessRules.inCheck(state)) -(MATE - ply) else 0 + } + if (state.halfmoveClock >= 100) return 0 + if (depth <= 0) return quiescence(state, alpha, beta, ply) + + var localAlpha = alpha + for (move in orderMoves(state, moves, preferred = null)) { + val score = -negamax(ChessRules.advanced(state, move), depth - 1, -beta, -localAlpha, ply + 1) + if (score >= beta) return beta + if (score > localAlpha) localAlpha = score + if (nodesUsed >= nodeBudget) break + } + return localAlpha + } + + /** + * Keeps searching while pieces are still being taken. + * + * Standing pat is allowed because the side to move is never obliged to + * capture — the position as it stands is a lower bound on what it can get. + */ + private fun quiescence(state: ChessState, alpha: Int, beta: Int, ply: Int): Int { + nodesUsed++ + val standPat = evaluate(state) + if (standPat >= beta) return beta + var localAlpha = maxOf(alpha, standPat) + if (ply >= MAX_QUIESCENCE_PLY) return localAlpha + + val captures = ChessRules.legalMoves(state).filter { + ChessRules.isCapture(state, it) || it.promotion != null + } + for (move in orderMoves(state, captures, preferred = null)) { + val score = -quiescence(ChessRules.advanced(state, move), -beta, -localAlpha, ply + 1) + if (score >= beta) return beta + if (score > localAlpha) localAlpha = score + if (nodesUsed >= nodeBudget) break + } + return localAlpha + } + + /** Captures first, biggest victim by smallest attacker — cheap and effective. */ + private fun orderMoves( + state: ChessState, + moves: List, + preferred: ChessMove?, + ): List = moves.sortedByDescending { move -> + var score = 0 + if (move == preferred) score += 1_000_000 + val victim = state.board[move.to] + if (victim != null) { + score += 10_000 + victim.kind.value - (state.board[move.from]?.kind?.value ?: 0) / 10 + } + move.promotion?.let { score += 9_000 + it.value } + score + } + + // ----------------------------------------------------------------------- + // Evaluation + // ----------------------------------------------------------------------- + + /** Centipawns, from the point of view of whoever is to move. */ + fun evaluate(state: ChessState): Int { + var white = 0 + var black = 0 + var whitePieces = 0 + var blackPieces = 0 + + for (square in 0 until 64) { + val piece = state.board[square] ?: continue + val table = tableFor(piece.kind) + // Black reads the same table from the other end of the board. + val placement = table[if (piece.white) square else square xor 56] + if (piece.white) { + white += piece.kind.value + placement + if (piece.kind != PieceKind.PAWN && piece.kind != PieceKind.KING) whitePieces++ + } else { + black += piece.kind.value + placement + if (piece.kind != PieceKind.PAWN && piece.kind != PieceKind.KING) blackPieces++ + } + } + + // In a bare endgame the tables stop meaning much and driving the enemy + // king to the edge starts to; without this a won endgame shuffles. + if (whitePieces + blackPieces <= 2) { + state.kingSquare(white = false)?.let { white += edgeDistance(it) * 8 } + state.kingSquare(white = true)?.let { black += edgeDistance(it) * 8 } + } + + val score = white - black + return if (state.whiteToMove) score else -score + } + + /** How far a king has been pushed towards the rim, 0 in the centre. */ + private fun edgeDistance(square: Int): Int { + val file = fileOf(square) + val rank = rankOf(square) + return maxOf(abs(file * 2 - 7), abs(rank * 2 - 7)) + } + + private fun tableFor(kind: PieceKind): IntArray = when (kind) { + PieceKind.PAWN -> PAWN_TABLE + PieceKind.KNIGHT -> KNIGHT_TABLE + PieceKind.BISHOP -> BISHOP_TABLE + PieceKind.ROOK -> ROOK_TABLE + PieceKind.QUEEN -> QUEEN_TABLE + PieceKind.KING -> KING_TABLE + } + + private companion object { + const val INFINITY = 1_000_000 + const val MATE = 100_000 + const val NODE_BUDGET = 120_000 + const val MAX_QUIESCENCE_PLY = 24 + + /** + * Placement bonuses in centipawns, written from White's side with rank + * eight on the top row — the same reading order the board uses, so the + * table can be laid out to look like a board. + */ + val PAWN_TABLE = intArrayOf( + 0, 0, 0, 0, 0, 0, 0, 0, + 50, 50, 50, 50, 50, 50, 50, 50, + 10, 10, 20, 30, 30, 20, 10, 10, + 5, 5, 10, 25, 25, 10, 5, 5, + 0, 0, 0, 20, 20, 0, 0, 0, + 5, -5, -10, 0, 0, -10, -5, 5, + 5, 10, 10, -20, -20, 10, 10, 5, + 0, 0, 0, 0, 0, 0, 0, 0, + ) + + val KNIGHT_TABLE = intArrayOf( + -50, -40, -30, -30, -30, -30, -40, -50, + -40, -20, 0, 0, 0, 0, -20, -40, + -30, 0, 10, 15, 15, 10, 0, -30, + -30, 5, 15, 20, 20, 15, 5, -30, + -30, 0, 15, 20, 20, 15, 0, -30, + -30, 5, 10, 15, 15, 10, 5, -30, + -40, -20, 0, 5, 5, 0, -20, -40, + -50, -40, -30, -30, -30, -30, -40, -50, + ) + + val BISHOP_TABLE = intArrayOf( + -20, -10, -10, -10, -10, -10, -10, -20, + -10, 0, 0, 0, 0, 0, 0, -10, + -10, 0, 5, 10, 10, 5, 0, -10, + -10, 5, 5, 10, 10, 5, 5, -10, + -10, 0, 10, 10, 10, 10, 0, -10, + -10, 10, 10, 10, 10, 10, 10, -10, + -10, 5, 0, 0, 0, 0, 5, -10, + -20, -10, -10, -10, -10, -10, -10, -20, + ) + + val ROOK_TABLE = intArrayOf( + 0, 0, 0, 0, 0, 0, 0, 0, + 5, 10, 10, 10, 10, 10, 10, 5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + -5, 0, 0, 0, 0, 0, 0, -5, + 0, 0, 0, 5, 5, 0, 0, 0, + ) + + val QUEEN_TABLE = intArrayOf( + -20, -10, -10, -5, -5, -10, -10, -20, + -10, 0, 0, 0, 0, 0, 0, -10, + -10, 0, 5, 5, 5, 5, 0, -10, + -5, 0, 5, 5, 5, 5, 0, -5, + 0, 0, 5, 5, 5, 5, 0, -5, + -10, 5, 5, 5, 5, 5, 0, -10, + -10, 0, 5, 0, 0, 0, 0, -10, + -20, -10, -10, -5, -5, -10, -10, -20, + ) + + val KING_TABLE = intArrayOf( + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -30, -40, -40, -50, -50, -40, -40, -30, + -20, -30, -30, -40, -40, -30, -30, -20, + -10, -20, -20, -20, -20, -20, -20, -10, + 20, 20, 0, 0, 0, 20, 20, 20, + 20, 30, 10, 0, 0, 10, 30, 20, + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/chess/ChessModel.kt b/app/src/main/java/org/prolibertate/games/game/chess/ChessModel.kt new file mode 100644 index 0000000..425ce81 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/chess/ChessModel.kt @@ -0,0 +1,258 @@ +package org.prolibertate.games.game.chess + +import kotlinx.serialization.Serializable + +/** + * Squares are numbered the way a FEN string reads them: 0 is a8, 7 is h8, and + * 63 is h1. Keeping the board in reading order means parsing and printing a + * position are both straight loops, and the screen only has to flip the index + * when it draws from Black's side. + */ +fun squareOf(file: Int, rank: Int): Int = (7 - rank) * 8 + file + +/** 0 is the a-file. */ +fun fileOf(square: Int): Int = square % 8 + +/** 0 is rank one. */ +fun rankOf(square: Int): Int = 7 - square / 8 + +fun squareName(square: Int): String = + "${'a' + fileOf(square)}${rankOf(square) + 1}" + +fun squareFromName(name: String): Int = + squareOf(name[0] - 'a', name[1] - '1') + +/** True for the light squares, which is all the screen needs to colour a board. */ +fun isLightSquare(square: Int): Boolean = (fileOf(square) + rankOf(square)) % 2 == 1 + +@Serializable +enum class PieceKind(val letter: Char, val glyphIndex: Int, val value: Int) { + PAWN('P', 5, 100), + KNIGHT('N', 4, 320), + BISHOP('B', 3, 330), + ROOK('R', 2, 500), + QUEEN('Q', 1, 900), + + /** + * The king has no material value: it is never captured, and a search that + * could trade it for anything would happily do so. + */ + KING('K', 0, 0), + ; + + companion object { + fun fromLetter(letter: Char): PieceKind? = + entries.firstOrNull { it.letter == letter.uppercaseChar() } + } +} + +@Serializable +data class Piece(val kind: PieceKind, val white: Boolean) { + /** FEN notation: white in upper case, black in lower. */ + val letter: Char get() = if (white) kind.letter else kind.letter.lowercaseChar() + + /** The Unicode chess figurine, which is how the board is drawn. */ + val glyph: String get() = (if (white) WHITE_GLYPHS else BLACK_GLYPHS)[kind.glyphIndex].toString() +} + +private const val WHITE_GLYPHS = "♔♕♖♗♘♙" +private const val BLACK_GLYPHS = "♚♛♜♝♞♟" + +/** Which castles are still available, regardless of whether they are legal now. */ +@Serializable +data class CastlingRights( + val whiteKingSide: Boolean = true, + val whiteQueenSide: Boolean = true, + val blackKingSide: Boolean = true, + val blackQueenSide: Boolean = true, +) { + fun kingSide(white: Boolean): Boolean = if (white) whiteKingSide else blackKingSide + fun queenSide(white: Boolean): Boolean = if (white) whiteQueenSide else blackQueenSide + + /** The FEN field, or "-" when nobody may castle. */ + fun toFen(): String = buildString { + if (whiteKingSide) append('K') + if (whiteQueenSide) append('Q') + if (blackKingSide) append('k') + if (blackQueenSide) append('q') + if (isEmpty()) append('-') + } + + companion object { + fun fromFen(field: String): CastlingRights = CastlingRights( + whiteKingSide = field.contains('K'), + whiteQueenSide = field.contains('Q'), + blackKingSide = field.contains('k'), + blackQueenSide = field.contains('q'), + ) + } +} + +/** + * A move is only ever from one square to another, plus what a pawn turns into. + * Castling is recorded as the king's two-square step and en passant as the + * pawn's diagonal — the rules work out the rest from the position. + */ +@Serializable +data class ChessMove( + val from: Int, + val to: Int, + val promotion: PieceKind? = null, +) { + /** Long algebraic, as used by every engine protocol: e2e4, e7e8q. */ + override fun toString(): String = + squareName(from) + squareName(to) + (promotion?.letter?.lowercaseChar() ?: "") +} + +@Serializable +enum class ChessPhase { PLAYING, GAME_OVER } + +@Serializable +enum class ChessOutcome(val label: String) { + // Named for who wins rather than who was mated: every caller wants the + // result, and "White mated" reads either way round. + WHITE_WINS("White wins by checkmate"), + BLACK_WINS("Black wins by checkmate"), + STALEMATE("Draw by stalemate"), + FIFTY_MOVE("Draw by the fifty-move rule"), + REPETITION("Draw by threefold repetition"), + INSUFFICIENT_MATERIAL("Draw — neither side can mate"), +} + +/** How hard the computer thinks about it. */ +@Serializable +enum class ChessLevel(val label: String, val depth: Int) { + CASUAL("Casual", depth = 2), + CLUB("Club", depth = 3), + STRONG("Strong", depth = 4), +} + +@Serializable +data class ChessOptions( + val level: ChessLevel = ChessLevel.CLUB, + /** A hundred half-moves with no capture and no pawn move is a draw. */ + val fiftyMoveRule: Boolean = true, + /** The same position three times over is a draw. */ + val threefoldRepetition: Boolean = true, + /** + * Where the game starts, in FEN. Blank means the ordinary opening array; + * anything else lets a table set up a puzzle or resume an adjourned game. + */ + val startingFen: String = "", +) + +@Serializable +data class ChessState( + val options: ChessOptions, + /** Sixty-four squares in FEN reading order; null is an empty square. */ + val board: List, + val whiteToMove: Boolean, + val castling: CastlingRights, + /** The square a pawn just skipped over, which is where it may be taken. */ + val enPassant: Int?, + val halfmoveClock: Int, + val fullmoveNumber: Int, + val phase: ChessPhase, + val outcome: ChessOutcome?, + val lastMove: ChessMove?, + /** + * One entry per position reached, in the four-field form that decides + * repetition — the clocks are deliberately left out, since a position + * repeats whether or not the same number of moves has been played. + */ + val repetitionKeys: List, + /** The game score in algebraic notation, one entry per half-move. */ + val moveLog: List, +) { + /** Seat 0 is White, seat 1 is Black. */ + val turnSeat: Int get() = if (whiteToMove) WHITE_SEAT else BLACK_SEAT + + fun kingSquare(white: Boolean): Int? = + board.indexOfFirst { it != null && it.white == white && it.kind == PieceKind.KING } + .takeIf { it >= 0 } +} + +const val WHITE_SEAT = 0 +const val BLACK_SEAT = 1 + +fun seatIsWhite(seat: Int): Boolean = seat == WHITE_SEAT + +const val START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + +/** A position with everything needed to resume it: board, side, rights, clocks. */ +data class FenPosition( + val board: List, + val whiteToMove: Boolean, + val castling: CastlingRights, + val enPassant: Int?, + val halfmoveClock: Int, + val fullmoveNumber: Int, +) + +/** + * Parses a FEN string. + * + * This is what the tests are built on — the standard perft positions are all + * published as FEN — and it is also what lets a game start from something other + * than the opening array. + */ +fun parseFen(fen: String): FenPosition { + val fields = fen.trim().split(" ") + require(fields.size >= 4) { "FEN needs at least four fields: $fen" } + + val board = arrayOfNulls(64) + var index = 0 + for (character in fields[0]) { + when { + character == '/' -> Unit + character.isDigit() -> index += character - '0' + else -> { + val kind = requireNotNull(PieceKind.fromLetter(character)) { + "Unknown piece '$character' in $fen" + } + require(index < 64) { "Too many squares in $fen" } + board[index] = Piece(kind, white = character.isUpperCase()) + index++ + } + } + } + require(index == 64) { "FEN describes $index squares, not 64: $fen" } + + return FenPosition( + board = board.toList(), + whiteToMove = fields[1] == "w", + castling = CastlingRights.fromFen(fields[2]), + enPassant = fields[3].takeIf { it != "-" }?.let { squareFromName(it) }, + halfmoveClock = fields.getOrNull(4)?.toIntOrNull() ?: 0, + fullmoveNumber = fields.getOrNull(5)?.toIntOrNull() ?: 1, + ) +} + +/** The first four FEN fields — the part that decides whether a position repeats. */ +fun positionKey( + board: List, + whiteToMove: Boolean, + castling: CastlingRights, + enPassant: Int?, +): String = buildString { + for (rank in 0 until 8) { + var empty = 0 + for (file in 0 until 8) { + val piece = board[rank * 8 + file] + if (piece == null) { + empty++ + } else { + if (empty > 0) { append(empty); empty = 0 } + append(piece.letter) + } + } + if (empty > 0) append(empty) + if (rank < 7) append('/') + } + append(' ').append(if (whiteToMove) 'w' else 'b') + append(' ').append(castling.toFen()) + append(' ').append(enPassant?.let { squareName(it) } ?: "-") +} + +fun ChessState.toFen(): String = + positionKey(board, whiteToMove, castling, enPassant) + " $halfmoveClock $fullmoveNumber" diff --git a/app/src/main/java/org/prolibertate/games/game/chess/ChessRules.kt b/app/src/main/java/org/prolibertate/games/game/chess/ChessRules.kt new file mode 100644 index 0000000..35d3b88 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/chess/ChessRules.kt @@ -0,0 +1,577 @@ +package org.prolibertate.games.game.chess + +import kotlin.math.abs +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig + +/** + * Chess as a pure state machine. See RULES-chess.md. + * + * Move generation is pseudo-legal followed by a king-safety filter: every + * candidate is played onto a copy of the board and thrown away if it leaves the + * mover in check. That is slower than tracking pins, and it is the reason the + * perft counts in the tests match published figures exactly — there is no + * special case to get subtly wrong. + */ +object ChessRules : GameRules { + + override val gameId: String = GameCatalog.CHESS + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): ChessState { + require(config.seats.size == 2) { "Chess is a two-handed game" } + val options = json.decodeFromString(config.optionsJson) + return fromPosition(options, options.startingFen.ifBlank { START_FEN }) + } + + /** Builds a state from a FEN string, which is also how a puzzle is loaded. */ + fun fromPosition(options: ChessOptions, fen: String): ChessState { + val position = parseFen(fen) + return ChessState( + options = options, + board = position.board, + whiteToMove = position.whiteToMove, + castling = position.castling, + enPassant = position.enPassant, + halfmoveClock = position.halfmoveClock, + fullmoveNumber = position.fullmoveNumber, + phase = ChessPhase.PLAYING, + outcome = null, + lastMove = null, + repetitionKeys = listOf( + positionKey( + position.board, + position.whiteToMove, + position.castling, + position.enPassant, + ) + ), + moveLog = emptyList(), + ).withTerminalCheck() + } + + override fun currentSeat(state: ChessState): Int? = + if (state.phase == ChessPhase.PLAYING) state.turnSeat else null + + // ----------------------------------------------------------------------- + // Attacks + // ----------------------------------------------------------------------- + + private val KNIGHT_STEPS = + listOf(1 to 2, 2 to 1, 2 to -1, 1 to -2, -1 to -2, -2 to -1, -2 to 1, -1 to 2) + private val KING_STEPS = + listOf(1 to 0, 1 to 1, 0 to 1, -1 to 1, -1 to 0, -1 to -1, 0 to -1, 1 to -1) + private val ROOK_DIRS = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1) + private val BISHOP_DIRS = listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1) + + private fun pieceAt(board: List, file: Int, rank: Int): Piece? = + if (file in 0..7 && rank in 0..7) board[squareOf(file, rank)] else null + + /** Whether [square] is attacked by the side [byWhite], king included. */ + fun isAttacked(board: List, square: Int, byWhite: Boolean): Boolean { + val file = fileOf(square) + val rank = rankOf(square) + + // A pawn attacks forwards, so an attacker stands one rank behind. + val pawnRank = rank - if (byWhite) 1 else -1 + for (side in intArrayOf(-1, 1)) { + val found = pieceAt(board, file + side, pawnRank) + if (found != null && found.white == byWhite && found.kind == PieceKind.PAWN) return true + } + + for ((df, dr) in KNIGHT_STEPS) { + val found = pieceAt(board, file + df, rank + dr) + if (found != null && found.white == byWhite && found.kind == PieceKind.KNIGHT) { + return true + } + } + + for ((df, dr) in KING_STEPS) { + val found = pieceAt(board, file + df, rank + dr) + if (found != null && found.white == byWhite && found.kind == PieceKind.KING) return true + } + + if (raysHit(board, file, rank, byWhite, ROOK_DIRS, PieceKind.ROOK)) return true + if (raysHit(board, file, rank, byWhite, BISHOP_DIRS, PieceKind.BISHOP)) return true + return false + } + + /** Walks each direction until something blocks, looking for [kind] or a queen. */ + private fun raysHit( + board: List, + file: Int, + rank: Int, + byWhite: Boolean, + directions: List>, + kind: PieceKind, + ): Boolean { + for ((df, dr) in directions) { + var f = file + df + var r = rank + dr + while (f in 0..7 && r in 0..7) { + val found = board[squareOf(f, r)] + if (found != null) { + if (found.white == byWhite && + (found.kind == kind || found.kind == PieceKind.QUEEN) + ) { + return true + } + break + } + f += df + r += dr + } + } + return false + } + + /** Whether the side to move is in check. */ + fun inCheck(state: ChessState): Boolean = inCheck(state.board, state.whiteToMove) + + fun inCheck(board: List, white: Boolean): Boolean { + val king = board.indexOfFirst { it != null && it.white == white && it.kind == PieceKind.KING } + // A position with no king cannot be in check. Only reachable from a + // hand-written FEN, but it must not throw. + return king >= 0 && isAttacked(board, king, byWhite = !white) + } + + // ----------------------------------------------------------------------- + // Move generation + // ----------------------------------------------------------------------- + + override fun legalMoves(state: ChessState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + return legalMoves(state) + } + + /** Every move the side to move may actually play. */ + fun legalMoves(state: ChessState): List { + if (state.phase != ChessPhase.PLAYING) return emptyList() + val white = state.whiteToMove + return pseudoMoves(state).filter { move -> + !inCheck(make(state, move).board, white) + } + } + + /** Moves that obey the way pieces travel, but may leave the king in check. */ + private fun pseudoMoves(state: ChessState): List { + val moves = mutableListOf() + val white = state.whiteToMove + for (square in 0 until 64) { + val piece = state.board[square] ?: continue + if (piece.white != white) continue + when (piece.kind) { + PieceKind.PAWN -> pawnMoves(state, square, white, moves) + PieceKind.KNIGHT -> stepMoves(state, square, white, KNIGHT_STEPS, moves) + PieceKind.KING -> { + stepMoves(state, square, white, KING_STEPS, moves) + castlingMoves(state, square, white, moves) + } + + PieceKind.BISHOP -> slideMoves(state, square, white, BISHOP_DIRS, moves) + PieceKind.ROOK -> slideMoves(state, square, white, ROOK_DIRS, moves) + PieceKind.QUEEN -> + slideMoves(state, square, white, ROOK_DIRS + BISHOP_DIRS, moves) + } + } + return moves + } + + private val PROMOTIONS = + listOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT) + + private fun pawnMoves( + state: ChessState, + from: Int, + white: Boolean, + moves: MutableList, + ) { + val board = state.board + val file = fileOf(from) + val rank = rankOf(from) + val forward = if (white) 1 else -1 + val startRank = if (white) 1 else 6 + val lastRank = if (white) 7 else 0 + + fun addPawnMove(to: Int) { + if (rankOf(to) == lastRank) { + PROMOTIONS.forEach { moves += ChessMove(from, to, it) } + } else { + moves += ChessMove(from, to) + } + } + + val ahead = rank + forward + if (ahead in 0..7 && board[squareOf(file, ahead)] == null) { + addPawnMove(squareOf(file, ahead)) + val twoAhead = rank + 2 * forward + if (rank == startRank && board[squareOf(file, twoAhead)] == null) { + moves += ChessMove(from, squareOf(file, twoAhead)) + } + } + + if (ahead !in 0..7) return + for (side in intArrayOf(-1, 1)) { + val captureFile = file + side + if (captureFile !in 0..7) continue + val to = squareOf(captureFile, ahead) + val occupant = board[to] + when { + occupant != null && occupant.white != white -> addPawnMove(to) + // The square behind a pawn that has just stepped two. + occupant == null && to == state.enPassant -> moves += ChessMove(from, to) + } + } + } + + private fun stepMoves( + state: ChessState, + from: Int, + white: Boolean, + steps: List>, + moves: MutableList, + ) { + val file = fileOf(from) + val rank = rankOf(from) + for ((df, dr) in steps) { + val f = file + df + val r = rank + dr + if (f !in 0..7 || r !in 0..7) continue + val to = squareOf(f, r) + val occupant = state.board[to] + if (occupant == null || occupant.white != white) moves += ChessMove(from, to) + } + } + + private fun slideMoves( + state: ChessState, + from: Int, + white: Boolean, + directions: List>, + moves: MutableList, + ) { + val file = fileOf(from) + val rank = rankOf(from) + for ((df, dr) in directions) { + var f = file + df + var r = rank + dr + while (f in 0..7 && r in 0..7) { + val to = squareOf(f, r) + val occupant = state.board[to] + if (occupant == null) { + moves += ChessMove(from, to) + } else { + if (occupant.white != white) moves += ChessMove(from, to) + break + } + f += df + r += dr + } + } + } + + private fun castlingMoves( + state: ChessState, + from: Int, + white: Boolean, + moves: MutableList, + ) { + val homeRank = if (white) 0 else 7 + // A hand-written FEN can claim rights for a king that has moved, so the + // king's own square is checked rather than trusted. + if (from != squareOf(4, homeRank)) return + // Castling out of check is never allowed. + if (isAttacked(state.board, from, byWhite = !white)) return + + fun clear(vararg files: Int) = files.all { state.board[squareOf(it, homeRank)] == null } + fun safe(vararg files: Int) = files.all { + !isAttacked(state.board, squareOf(it, homeRank), byWhite = !white) + } + + fun rookAt(file: Int): Boolean { + val piece = state.board[squareOf(file, homeRank)] + return piece != null && piece.white == white && piece.kind == PieceKind.ROOK + } + + if (state.castling.kingSide(white) && rookAt(7) && clear(5, 6) && safe(5, 6)) { + moves += ChessMove(from, squareOf(6, homeRank)) + } + // The b-file square must be empty but may be attacked — the king does + // not pass over it. + if (state.castling.queenSide(white) && rookAt(0) && clear(1, 2, 3) && safe(2, 3)) { + moves += ChessMove(from, squareOf(2, homeRank)) + } + } + + // ----------------------------------------------------------------------- + // Making a move + // ----------------------------------------------------------------------- + + /** The position a move produces, before any terminal test. */ + private data class Made( + val board: List, + val castling: CastlingRights, + val enPassant: Int?, + val halfmoveClock: Int, + val captured: Piece?, + val castled: Boolean, + ) + + private fun make(state: ChessState, move: ChessMove): Made { + val board = state.board.toMutableList() + val piece = requireNotNull(board[move.from]) { "No piece on ${squareName(move.from)}" } + var captured = board[move.to] + + // Taking en passant removes a pawn that is not on the target square. + if (piece.kind == PieceKind.PAWN && move.to == state.enPassant && captured == null) { + val victim = squareOf(fileOf(move.to), rankOf(move.from)) + captured = board[victim] + board[victim] = null + } + + board[move.from] = null + board[move.to] = move.promotion?.let { Piece(it, piece.white) } ?: piece + + val castled = piece.kind == PieceKind.KING && + abs(fileOf(move.to) - fileOf(move.from)) == 2 + if (castled) { + val homeRank = rankOf(move.from) + val rookFrom = squareOf(if (fileOf(move.to) == 6) 7 else 0, homeRank) + val rookTo = squareOf(if (fileOf(move.to) == 6) 5 else 3, homeRank) + board[rookTo] = board[rookFrom] + board[rookFrom] = null + } + + val doubleStep = piece.kind == PieceKind.PAWN && + abs(rankOf(move.to) - rankOf(move.from)) == 2 + val enPassant = if (doubleStep) { + squareOf(fileOf(move.from), (rankOf(move.from) + rankOf(move.to)) / 2) + } else { + null + } + + var castling = state.castling + if (piece.kind == PieceKind.KING) { + castling = if (piece.white) { + castling.copy(whiteKingSide = false, whiteQueenSide = false) + } else { + castling.copy(blackKingSide = false, blackQueenSide = false) + } + } + // A rook that moves loses its right; so does one that is captured on + // its own corner, which is the case that is easy to forget. + castling = clearCornerRight(castling, move.from) + castling = clearCornerRight(castling, move.to) + + val resetsClock = piece.kind == PieceKind.PAWN || captured != null + return Made( + board = board, + castling = castling, + enPassant = enPassant, + halfmoveClock = if (resetsClock) 0 else state.halfmoveClock + 1, + captured = captured, + castled = castled, + ) + } + + private fun clearCornerRight(rights: CastlingRights, square: Int): CastlingRights = + when (square) { + squareOf(0, 0) -> rights.copy(whiteQueenSide = false) + squareOf(7, 0) -> rights.copy(whiteKingSide = false) + squareOf(0, 7) -> rights.copy(blackQueenSide = false) + squareOf(7, 7) -> rights.copy(blackKingSide = false) + else -> rights + } + + override fun applyMove(state: ChessState, seat: Int, move: ChessMove): ChessState { + require(currentSeat(state) == seat) { "Not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + + val notation = baseNotation(state, move) + val made = make(state, move) + val key = positionKey(made.board, !state.whiteToMove, made.castling, made.enPassant) + + val next = state.copy( + board = made.board, + whiteToMove = !state.whiteToMove, + castling = made.castling, + enPassant = made.enPassant, + halfmoveClock = made.halfmoveClock, + fullmoveNumber = state.fullmoveNumber + if (state.whiteToMove) 0 else 1, + lastMove = move, + repetitionKeys = state.repetitionKeys + key, + ).withTerminalCheck() + + val suffix = when { + next.outcome == ChessOutcome.WHITE_WINS || next.outcome == ChessOutcome.BLACK_WINS -> + "#" + + inCheck(next) -> "+" + else -> "" + } + return next.copy(moveLog = state.moveLog + (notation + suffix)) + } + + /** Applies the terminal tests to a position whose side to move is set. */ + private fun ChessState.withTerminalCheck(): ChessState { + if (legalMoves(this).isEmpty()) { + val outcome = when { + // Whoever is to move has been mated, so the other side wins. + inCheck(this) -> if (whiteToMove) { + ChessOutcome.BLACK_WINS + } else { + ChessOutcome.WHITE_WINS + } + + else -> ChessOutcome.STALEMATE + } + return copy(phase = ChessPhase.GAME_OVER, outcome = outcome) + } + if (insufficientMaterial(board)) { + return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.INSUFFICIENT_MATERIAL) + } + if (options.fiftyMoveRule && halfmoveClock >= 100) { + return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.FIFTY_MOVE) + } + if (options.threefoldRepetition && + repetitionKeys.count { it == repetitionKeys.last() } >= 3 + ) { + return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.REPETITION) + } + return this + } + + /** Positions from which no sequence of legal moves can produce mate. */ + fun insufficientMaterial(board: List): Boolean { + val pieces = board.withIndex().mapNotNull { (square, piece) -> + piece?.takeIf { it.kind != PieceKind.KING }?.let { square to it } + } + if (pieces.any { + it.second.kind == PieceKind.PAWN || + it.second.kind == PieceKind.ROOK || + it.second.kind == PieceKind.QUEEN + } + ) { + return false + } + return when (pieces.size) { + // Bare kings, or a king and one minor piece. + 0, 1 -> true + // Two bishops on the same colour square can never mate, whichever + // sides they belong to. + 2 -> pieces.all { it.second.kind == PieceKind.BISHOP } && + isLightSquare(pieces[0].first) == isLightSquare(pieces[1].first) + + else -> false + } + } + + // ----------------------------------------------------------------------- + // Notation + // ----------------------------------------------------------------------- + + /** Algebraic notation without the check or mate suffix, which needs the result. */ + private fun baseNotation(state: ChessState, move: ChessMove): String { + val piece = state.board[move.from] ?: return move.toString() + if (piece.kind == PieceKind.KING && abs(fileOf(move.to) - fileOf(move.from)) == 2) { + return if (fileOf(move.to) == 6) "O-O" else "O-O-O" + } + + val captures = state.board[move.to] != null || + (piece.kind == PieceKind.PAWN && move.to == state.enPassant) + + return buildString { + if (piece.kind == PieceKind.PAWN) { + if (captures) append('a' + fileOf(move.from)) + } else { + append(piece.kind.letter) + append(disambiguation(state, move, piece.kind)) + } + if (captures) append('x') + append(squareName(move.to)) + move.promotion?.let { append('=').append(it.letter) } + } + } + + /** + * The least that distinguishes this move from another of the same kind to + * the same square — the file if that is enough, otherwise the rank, and + * both when two pieces share a file and a rank with a third. + */ + private fun disambiguation(state: ChessState, move: ChessMove, kind: PieceKind): String { + val rivals = legalMoves(state).filter { other -> + other.to == move.to && + other.from != move.from && + state.board[other.from]?.kind == kind + } + if (rivals.isEmpty()) return "" + val fileIsUnique = rivals.none { fileOf(it.from) == fileOf(move.from) } + if (fileIsUnique) return ('a' + fileOf(move.from)).toString() + val rankIsUnique = rivals.none { rankOf(it.from) == rankOf(move.from) } + if (rankIsUnique) return (rankOf(move.from) + 1).toString() + return squareName(move.from) + } + + // ----------------------------------------------------------------------- + // Engine contract + // ----------------------------------------------------------------------- + + override fun isFinished(state: ChessState): Boolean = state.phase == ChessPhase.GAME_OVER + + override fun summary(state: ChessState): String = state.outcome?.label + ?: if (state.whiteToMove) "White to move" else "Black to move" + + /** Chess hides nothing: both players see the same board. */ + override fun viewFor(state: ChessState, seat: Int): ChessState = state + + override fun encodeState(state: ChessState): String = json.encodeToString(state) + override fun decodeState(json: String): ChessState = this.json.decodeFromString(json) + override fun encodeMove(move: ChessMove): String = json.encodeToString(move) + override fun decodeMove(json: String): ChessMove = this.json.decodeFromString(json) + + /** + * Counts the leaf nodes at [depth]. Only used by the tests, but it lives + * here because it is the single most valuable thing that can be said about + * a move generator: the published counts for a given position are exact, so + * a generator that matches them at depth four has essentially no room left + * for a rule it gets wrong. + */ + fun perft(state: ChessState, depth: Int): Long { + if (depth == 0) return 1L + val moves = legalMoves(state) + if (depth == 1) return moves.size.toLong() + return moves.sumOf { perft(advanced(state, it), depth - 1) } + } + + /** + * The position after [move] with none of the terminal tests applied. + * + * The search walks millions of positions it will never keep, and running + * the repetition and fifty-move checks on every one of them would cost more + * than the search itself. Anything that ends up on the board goes through + * [applyMove] instead. + */ + fun advanced(state: ChessState, move: ChessMove): ChessState { + val made = make(state, move) + return state.copy( + board = made.board, + whiteToMove = !state.whiteToMove, + castling = made.castling, + enPassant = made.enPassant, + halfmoveClock = made.halfmoveClock, + lastMove = move, + ) + } + + /** Whether [move] takes something — including en passant, which takes nothing on its square. */ + fun isCapture(state: ChessState, move: ChessMove): Boolean = + state.board[move.to] != null || + (state.board[move.from]?.kind == PieceKind.PAWN && move.to == state.enPassant) +} diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index 0bd5fe9..7c4c045 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -1,7 +1,9 @@ package org.prolibertate.games.net import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -231,17 +233,24 @@ class MatchController( if (seatKinds[seat] != PlayerKind.AI) return // a person's turn delay(holdBeforeNextMove(current) + aiThinkingMillis()) - val chosen = lock.withLock { + + val legal = rules.legalMoves(current, seat) + if (legal.isEmpty()) return + // Off the caller's thread. A card AI answers instantly, but a chess + // search runs for a second or more, and this scope belongs to the + // screen — thinking here would freeze the board it is drawn on. + val move = withContext(Dispatchers.Default) { ai.chooseMove(current, seat, legal) } + + val applied = lock.withLock { val live = authoritative ?: return - if (rules.currentSeat(live) != seat) return@withLock null - val legal = rules.legalMoves(live, seat) - if (legal.isEmpty()) return@withLock null - val move = ai.chooseMove(live, seat, legal) + // Nothing else can move while an AI seat is on the clock, but + // the state is re-read rather than assumed. + if (rules.currentSeat(live) != seat) return@withLock false authoritative = rules.applyMove(live, seat, move) publish() - move + true } - if (chosen == null) return + if (!applied) return } } diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index 8902894..b706ec3 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -146,7 +146,7 @@ private fun AppContent( descriptor = GameCatalog.byId(route.gameId)!!, playerName = settings.displayName, onBack = { pop() }, - onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) }, + onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) }, onHostOnline = { optionsJson -> push(Route.Lobby(route.gameId, optionsJson, hosting = true)) }, 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 0443203..c2552de 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -30,6 +30,8 @@ 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.chess.ChessLevel +import org.prolibertate.games.game.chess.ChessOptions import org.prolibertate.games.game.crazyeights.CrazyEightsOptions import org.prolibertate.games.game.euchre.EuchreOptions import org.prolibertate.games.game.golf.GolfOptions @@ -54,7 +56,7 @@ fun GameSetupScreen( descriptor: GameDescriptor, playerName: String, onBack: () -> Unit, - onPlayOffline: (TableConfig) -> Unit, + onPlayOffline: (TableConfig, Int) -> Unit, onHostOnline: (String) -> Unit, ) { var euchre by remember { mutableStateOf(EuchreOptions()) } @@ -64,6 +66,10 @@ fun GameSetupScreen( var kaiser by remember { mutableStateOf(KaiserOptions()) } var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) } var wizard by remember { mutableStateOf(WizardOptions()) } + var chess by remember { mutableStateOf(ChessOptions()) } + // Not an engine option: which seat the local player takes. Seat 0 is + // always White, so choosing Black means sitting in seat 1. + var playWhite by remember { mutableStateOf(true) } val optionsJson = when (descriptor.id) { GameCatalog.EUCHRE -> setupJson.encodeToString(euchre) @@ -73,6 +79,7 @@ fun GameSetupScreen( GameCatalog.KAISER -> setupJson.encodeToString(kaiser) GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights) GameCatalog.WIZARD -> setupJson.encodeToString(wizard) + GameCatalog.CHESS -> setupJson.encodeToString(chess) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -98,6 +105,13 @@ fun GameSetupScreen( } GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it } + GameCatalog.CHESS -> ChessOptionsEditor( + options = chess, + playWhite = playWhite, + onChange = { chess = it }, + onColour = { playWhite = it }, + ) + else -> Text("No options yet for this game.") } @@ -110,9 +124,13 @@ fun GameSetupScreen( style = MaterialTheme.typography.bodySmall, ) + // Chess is the only game so far where the local player picks a + // seat rather than always taking the first one. + val localSeat = if (descriptor.id == GameCatalog.CHESS && !playWhite) 1 else 0 PrimaryAction(text = "Play against the computer") { onPlayOffline( - offlineConfig(descriptor.id, optionsJson, seatCount, playerName) + offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat), + localSeat, ) } PrimaryAction(text = "Host a game for others to join") { @@ -122,19 +140,20 @@ fun GameSetupScreen( } } -/** Seat 0 is the local player; the rest start as AI. */ +/** [localSeat] is the person at this device; every other seat starts as AI. */ private fun offlineConfig( gameId: String, optionsJson: String, seatCount: Int, playerName: String, + localSeat: Int = 0, ): TableConfig = TableConfig( gameId = gameId, seats = (0 until seatCount).map { seat -> PlayerSlot( seat = seat, - name = if (seat == 0) playerName else "Computer $seat", - kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, + name = if (seat == localSeat) playerName else "Computer $seat", + kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, team = teamForSeat(gameId, seat), ) }, @@ -170,6 +189,7 @@ fun seatCountFor(gameId: String, optionsJson: String): Int { GameCatalog.WIZARD -> setupJson.decodeFromString(optionsJson).playerCount + GameCatalog.CHESS -> 2 else -> fallback } }.getOrDefault(fallback) @@ -456,6 +476,48 @@ private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions } } +@Composable +private fun ChessOptionsEditor( + options: ChessOptions, + playWhite: Boolean, + onChange: (ChessOptions) -> Unit, + onColour: (Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "You play", + values = listOf(true, false), + selected = playWhite, + display = { if (it) "White" else "Black" }, + onSelect = onColour, + ) + ChipRow( + label = "Opponent", + values = ChessLevel.entries.toList(), + selected = options.level, + display = { it.label }, + onSelect = { onChange(options.copy(level = it)) }, + ) + ToggleRow( + title = "Fifty-move rule", + subtitle = "Fifty moves each with no capture and no pawn move is a draw", + checked = options.fiftyMoveRule, + onChange = { onChange(options.copy(fiftyMoveRule = it)) }, + ) + ToggleRow( + title = "Threefold repetition", + subtitle = "The same position three times over is a draw", + checked = options.threefoldRepetition, + onChange = { onChange(options.copy(threefoldRepetition = it)) }, + ) + Text( + text = "Full rules: castling, en passant, promotion, stalemate, and a draw " + + "when neither side has enough material to mate.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun ChipRow( 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 c8771c0..9ab7e12 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,10 @@ 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.chess.ChessAi +import org.prolibertate.games.game.chess.ChessMove +import org.prolibertate.games.game.chess.ChessRules +import org.prolibertate.games.game.chess.ChessState import org.prolibertate.games.game.crazyeights.CrazyEightsAi import org.prolibertate.games.game.crazyeights.CrazyEightsMove import org.prolibertate.games.game.crazyeights.CrazyEightsPhase @@ -43,6 +47,7 @@ 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.ChessScreen import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.GolfScreen @@ -273,6 +278,27 @@ fun PlayScreen( CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.CHESS -> { + val controller = remember(route) { + MatchController( + rules = ChessRules, + // Strength comes from the table's own options, so the setup + // screen sets it without this having to decode anything. + ai = ChessAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // The search is the thinking time; adding more on top of it + // would only make the computer look slower than it is. + aiThinkingMillis = { settings.scaled(250L) }, + ) + } + StartMatch(env, controller, route) + ChessScreen(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/ChessScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt new file mode 100644 index 0000000..72a302a --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt @@ -0,0 +1,378 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +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.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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prolibertate.games.game.chess.ChessMove +import org.prolibertate.games.game.chess.ChessPhase +import org.prolibertate.games.game.chess.ChessRules +import org.prolibertate.games.game.chess.ChessState +import org.prolibertate.games.game.chess.Piece +import org.prolibertate.games.game.chess.PieceKind +import org.prolibertate.games.game.chess.fileOf +import org.prolibertate.games.game.chess.isLightSquare +import org.prolibertate.games.game.chess.rankOf +import org.prolibertate.games.game.chess.seatIsWhite +import org.prolibertate.games.game.chess.squareName +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold + +/** Board colours, kept off the theme so the board reads the same in either mode. */ +private val LightSquare = Color(0xFFEEDAB4) +private val DarkSquare = Color(0xFF9A7248) +private val SelectedSquare = Color(0x8830C030) +private val LastMoveSquare = Color(0x55FFC107) +private val CheckSquare = Color(0x99D32F2F) +private val MoveDot = Color(0x66000000) + +/** + * The chess board. + * + * Tap a piece, then tap where it goes. The board is always drawn from the local + * player's side, so a game against the computer as Black is played the way it + * would be over a real board rather than upside down. + */ +@Composable +fun ChessScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + + var selected by remember { mutableStateOf(null) } + // A pawn reaching the last rank is one tap that stands for four moves, so + // the destination is held while the piece is chosen. + var promotionTo by remember { mutableStateOf(null) } + + val current = state + if (current == null) { + ScreenScaffold(title = "Chess", onBack = onExit) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + } + return + } + + val flipped = !seatIsWhite(localSeat) + val fromSelected = selected?.let { from -> legal.filter { it.from == from } }.orEmpty() + + val onSquareTapped: (Int) -> Unit = { square -> + val destinations = fromSelected.filter { it.to == square } + when { + destinations.any { it.promotion != null } -> promotionTo = square + destinations.isNotEmpty() -> { + controller.submit(destinations.first()) + selected = null + } + // Tapping another of your own pieces switches the selection rather + // than clearing it, which is what a player expects after a misclick. + legal.any { it.from == square } -> selected = square + else -> selected = null + } + } + + ScreenScaffold(title = "Chess", onBack = onExit) { modifier -> + BoxWithConstraints(modifier = modifier) { + val wide = maxWidth > maxHeight + val boardSide = if (wide) { + minOf(maxHeight, maxWidth * 0.62f) + } else { + minOf(maxWidth, maxHeight * 0.72f) + } + + val board: @Composable () -> Unit = { + Board( + state = current, + legalFromSelected = fromSelected, + selected = selected, + flipped = flipped, + side = boardSide, + onTap = onSquareTapped, + ) + } + val panel: @Composable (Modifier) -> Unit = { panelModifier -> + Column( + modifier = panelModifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatusLine(current, localSeat) + MoveList(current, modifier = Modifier.weight(1f, fill = false)) + } + } + + if (wide) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + board() + panel(Modifier.weight(1f).fillMaxSize()) + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + board() + panel(Modifier.fillMaxWidth().weight(1f)) + } + } + + promotionTo?.let { destination -> + PromotionDialog( + white = current.whiteToMove, + choices = fromSelected.filter { it.to == destination }, + onPick = { + controller.submit(it) + promotionTo = null + selected = null + }, + onDismiss = { promotionTo = null }, + ) + } + + if (current.phase == ChessPhase.GAME_OVER) { + ResultDialog(current, onExit) + } + } + } +} + +@Composable +private fun Board( + state: ChessState, + legalFromSelected: List, + selected: Int?, + flipped: Boolean, + side: Dp, + onTap: (Int) -> Unit, +) { + val squareSide = side / 8 + val destinations = legalFromSelected.map { it.to }.toSet() + val checkedKing = if (ChessRules.inCheck(state)) state.kingSquare(state.whiteToMove) else null + + Column(modifier = Modifier.size(side).border(2.dp, DarkSquare)) { + for (row in 0 until 8) { + Row(modifier = Modifier.weight(1f)) { + for (column in 0 until 8) { + val display = row * 8 + column + // Turning the board round is one subtraction, so the rest of + // the screen never has to think about which side is which. + val square = if (flipped) 63 - display else display + Square( + state = state, + square = square, + squareSide = squareSide, + selected = square == selected, + isDestination = square in destinations, + inCheck = square == checkedKing, + modifier = Modifier.weight(1f).fillMaxSize(), + onTap = { onTap(square) }, + ) + } + } + } + } +} + +@Composable +private fun Square( + state: ChessState, + square: Int, + squareSide: Dp, + selected: Boolean, + isDestination: Boolean, + inCheck: Boolean, + modifier: Modifier, + onTap: () -> Unit, +) { + val piece = state.board[square] + val touchedByLastMove = + state.lastMove?.let { square == it.from || square == it.to } == true + + Box( + modifier = modifier + .background(if (isLightSquare(square)) LightSquare else DarkSquare) + .then(if (touchedByLastMove) Modifier.background(LastMoveSquare) else Modifier) + .then(if (inCheck) Modifier.background(CheckSquare) else Modifier) + .then(if (selected) Modifier.background(SelectedSquare) else Modifier) + .clickable { onTap() }, + contentAlignment = Alignment.Center, + ) { + if (piece != null) { + Text( + text = piece.glyph, + fontSize = (squareSide.value * 0.78f).sp, + // The glyphs carry their own colour, but an outline colour keeps + // white pieces visible on the light squares. + color = if (piece.white) Color(0xFFFFFFFF) else Color(0xFF101010), + modifier = Modifier.padding(bottom = squareSide * 0.08f), + ) + } + if (isDestination) { + // A dot for an empty square, a ring for something to take. + if (piece == null) { + Box( + modifier = Modifier + .size(squareSide * 0.28f) + .clip(CircleShape) + .background(MoveDot) + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .padding(squareSide * 0.04f) + .border(squareSide * 0.08f, MoveDot, CircleShape) + ) + } + } + // Coordinates on the outer files and ranks, as a printed board has. + if (fileOf(square) == 0) { + Text( + text = "${rankOf(square) + 1}", + fontSize = (squareSide.value * 0.2f).sp, + color = if (isLightSquare(square)) DarkSquare else LightSquare, + modifier = Modifier.align(Alignment.TopStart).padding(1.dp), + ) + } + if (rankOf(square) == 0) { + Text( + text = "${'a' + fileOf(square)}", + fontSize = (squareSide.value * 0.2f).sp, + color = if (isLightSquare(square)) DarkSquare else LightSquare, + modifier = Modifier.align(Alignment.BottomEnd).padding(1.dp), + ) + } + } +} + +@Composable +private fun StatusLine(state: ChessState, localSeat: Int) { + val yourMove = state.turnSeat == localSeat && state.phase == ChessPhase.PLAYING + Column { + Text( + text = state.outcome?.label ?: when { + yourMove && ChessRules.inCheck(state) -> "You are in check" + yourMove -> "Your move" + ChessRules.inCheck(state) -> "Check" + else -> "Thinking…" + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "You play ${if (seatIsWhite(localSeat)) "White" else "Black"} · " + + "move ${state.fullmoveNumber}", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +/** The game score, two half-moves to a line, most recent at the bottom. */ +@Composable +private fun MoveList(state: ChessState, modifier: Modifier = Modifier) { + if (state.moveLog.isEmpty()) { + Text("No moves yet.", style = MaterialTheme.typography.bodySmall, modifier = modifier) + return + } + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + state.moveLog.chunked(2).forEachIndexed { index, pair -> + Text( + text = "${index + 1}. ${pair.joinToString(" ")}", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun PromotionDialog( + white: Boolean, + choices: List, + onPick: (ChessMove) -> Unit, + onDismiss: () -> Unit, +) { + val order = listOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Promote to") }, + text = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + order.forEach { kind -> + val move = choices.firstOrNull { it.promotion == kind } ?: return@forEach + Button(onClick = { onPick(move) }, modifier = Modifier.width(64.dp)) { + Text( + text = Piece(kind, white).glyph, + fontSize = 24.sp, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun ResultDialog(state: ChessState, onExit: () -> Unit) { + var dismissed by remember(state.outcome) { mutableStateOf(false) } + if (dismissed) return + + AlertDialog( + onDismissRequest = { dismissed = true }, + title = { Text("Game over") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold) + state.lastMove?.let { + Text( + text = "Last move ${state.moveLog.lastOrNull() ?: squareName(it.to)}", + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + text = "${state.moveLog.size} half-moves played.", + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { TextButton(onClick = onExit) { Text("Back to the menu") } }, + dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } }, + ) +} 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 b4c2ae7..e548ef0 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -58,6 +58,7 @@ class CatalogTest { GameCatalog.KAISER, GameCatalog.CRAZY_EIGHTS, GameCatalog.WIZARD, + GameCatalog.CHESS, ), GameCatalog.playable.map { it.id }.toSet(), ) diff --git a/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt b/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt new file mode 100644 index 0000000..be86777 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt @@ -0,0 +1,131 @@ +package org.prolibertate.games.game.chess + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChessAiTest { + + private fun position(fen: String, level: ChessLevel = ChessLevel.CLUB) = + ChessRules.fromPosition(ChessOptions(level = level), fen) + + private fun ChessAi.moveIn(state: ChessState): ChessMove = + chooseMove(state, state.turnSeat, ChessRules.legalMoves(state)) + + @Test + fun `it only ever returns a legal move`() { + val ai = ChessAi() + var state = ChessRules.fromPosition(ChessOptions(), START_FEN) + repeat(30) { + if (ChessRules.isFinished(state)) return + val legal = ChessRules.legalMoves(state) + val move = ai.moveIn(state) + assertTrue("$move is not legal in ${state.toFen()}", legal.contains(move)) + state = ChessRules.applyMove(state, state.turnSeat, move) + } + } + + @Test + fun `it takes a piece that is hanging`() { + // The black queen on d5 is undefended and the rook on d1 sees it. + val state = position("4k3/8/8/3q4/8/8/8/3RK3 w - - 0 1") + assertEquals("d1d5", ChessAi(ChessLevel.STRONG).moveIn(state).toString()) + } + + @Test + fun `it does not give a piece away for nothing`() { + // Rb1 and Rb7 are both available; Rb7 hangs the rook to the king. + val state = position("8/1k6/8/8/8/8/8/1R2K3 w - - 0 1") + val move = ChessAi(ChessLevel.STRONG).moveIn(state) + assertTrue("played $move into the king", move.toString() != "b1b7") + } + + @Test + fun `it finds mate in one`() { + val state = position("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1") + assertEquals("a1a8", ChessAi(ChessLevel.STRONG).moveIn(state).toString()) + } + + @Test + fun `it gets out of check rather than ignoring it`() { + val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1") + val move = ChessAi().moveIn(state) + val after = ChessRules.applyMove(state, state.turnSeat, move) + assertTrue("still in check after $move", !ChessRules.inCheck(after.board, white = true)) + } + + @Test + fun `it promotes to a queen when there is nothing to fear`() { + val state = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1") + val move = ChessAi(ChessLevel.STRONG).moveIn(state) + assertEquals(PieceKind.QUEEN, move.promotion) + } + + /** The same position with the colours swapped and the board turned round. */ + private fun mirrored(state: ChessState): ChessState { + val board = arrayOfNulls(64) + for (square in 0 until 64) { + state.board[square]?.let { board[square xor 56] = it.copy(white = !it.white) } + } + return state.copy( + board = board.toList(), + whiteToMove = !state.whiteToMove, + castling = CastlingRights( + whiteKingSide = state.castling.blackKingSide, + whiteQueenSide = state.castling.blackQueenSide, + blackKingSide = state.castling.whiteKingSide, + blackQueenSide = state.castling.whiteQueenSide, + ), + enPassant = state.enPassant?.let { it xor 56 }, + ) + } + + @Test + fun `the evaluation is symmetric`() { + // A position and its mirror image must score identically from the side + // to move, or the search believes one colour is better simply for being + // that colour — and it will then play for it. + listOf( + "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4", + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", + "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "4k3/8/8/8/8/8/8/3RK3 w - - 0 1", + ).forEach { fen -> + val state = position(fen) + assertEquals(fen, ChessAi().evaluate(state), ChessAi().evaluate(mirrored(state))) + } + } + + @Test + fun `two engines play a whole game without an illegal move`() { + val ai = ChessAi(ChessLevel.CASUAL) + var state = ChessRules.fromPosition(ChessOptions(level = ChessLevel.CASUAL), START_FEN) + var plies = 0 + while (!ChessRules.isFinished(state) && plies++ < 220) { + val legal = ChessRules.legalMoves(state) + val move = ai.moveIn(state) + assertTrue("illegal $move", legal.contains(move)) + state = ChessRules.applyMove(state, state.turnSeat, move) + } + // Either somebody was mated or the position ran into one of the draw + // rules; both are results. What matters is that it never got stuck. + assertTrue( + "no result and no legal continuation", + ChessRules.isFinished(state) || ChessRules.legalMoves(state).isNotEmpty(), + ) + } + + @Test + fun `a move takes a reasonable amount of time at every level`() { + // Rough guard rather than a benchmark: a phone should not sit thinking + // for a quarter of a minute, and the node budget is what enforces it. + val opening = "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4" + ChessLevel.entries.forEach { level -> + val state = position(opening, level) + val started = System.nanoTime() + ChessAi(level).moveIn(state) + val millis = (System.nanoTime() - started) / 1_000_000 + assertTrue("$level took ${millis}ms", millis < 15_000) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/chess/ChessRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/chess/ChessRulesTest.kt new file mode 100644 index 0000000..42135c3 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/chess/ChessRulesTest.kt @@ -0,0 +1,417 @@ +package org.prolibertate.games.game.chess + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +class ChessRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: ChessOptions = ChessOptions()) = TableConfig( + gameId = "chess", + seats = (0 until 2).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = 1L, + ) + + private fun position(fen: String, options: ChessOptions = ChessOptions()) = + ChessRules.fromPosition(options, fen) + + private fun ChessState.moveTo(notation: String): ChessState { + val move = ChessRules.legalMoves(this).firstOrNull { it.toString() == notation } + assertNotNull("$notation is not legal in ${toFen()}", move) + return ChessRules.applyMove(this, turnSeat, move!!) + } + + // -- Squares and FEN ---------------------------------------------------- + + @Test + fun `squares are numbered the way a FEN reads`() { + assertEquals(0, squareFromName("a8")) + assertEquals(7, squareFromName("h8")) + assertEquals(56, squareFromName("a1")) + assertEquals(63, squareFromName("h1")) + (0 until 64).forEach { assertEquals(it, squareFromName(squareName(it))) } + } + + @Test + fun `a1 is dark and h1 is light`() { + assertFalse(isLightSquare(squareFromName("a1"))) + assertTrue(isLightSquare(squareFromName("h1"))) + assertTrue(isLightSquare(squareFromName("e4"))) + } + + @Test + fun `the opening array round trips through FEN`() { + val state = ChessRules.initialState(config()) + assertEquals(START_FEN, state.toFen()) + assertEquals(32, state.board.count { it != null }) + assertEquals("white starts", 0, state.turnSeat) + } + + @Test + fun `every published test position parses back to itself`() { + PERFT_POSITIONS.forEach { (fen, _) -> + assertEquals(fen, position(fen).toFen()) + } + } + + // -- Perft -------------------------------------------------------------- + // + // These counts are published and exact. A generator that matches them has + // essentially nowhere left to hide a rule it gets wrong: the positions are + // chosen to exercise castling through check, en passant that would expose + // a king, promotion with capture, and pins. + + @Test + fun `perft matches the published counts from the opening array`() { + val state = ChessRules.initialState(config()) + assertEquals(20L, ChessRules.perft(state, 1)) + assertEquals(400L, ChessRules.perft(state, 2)) + assertEquals(8_902L, ChessRules.perft(state, 3)) + assertEquals(197_281L, ChessRules.perft(state, 4)) + } + + @Test + fun `perft matches the published counts for the standard test positions`() { + PERFT_POSITIONS.forEach { (fen, counts) -> + val state = position(fen) + counts.forEachIndexed { index, expected -> + assertEquals( + "perft(${index + 1}) for $fen", + expected, + ChessRules.perft(state, index + 1), + ) + } + } + } + + // -- Individual rules --------------------------------------------------- + + @Test + fun `a pawn on its home rank may step one or two`() { + val state = ChessRules.initialState(config()) + val fromE2 = ChessRules.legalMoves(state).filter { squareName(it.from) == "e2" } + assertEquals(setOf("e2e3", "e2e4"), fromE2.map { it.toString() }.toSet()) + } + + @Test + fun `a double step can be answered en passant, and only immediately`() { + var state = position("4k3/8/8/8/4p3/8/3P4/4K3 w - - 0 1") + state = state.moveTo("d2d4") + assertEquals("the skipped square is the target", "d3", squareName(state.enPassant!!)) + + val enPassant = ChessRules.legalMoves(state).first { it.toString() == "e4d3" } + val after = ChessRules.applyMove(state, state.turnSeat, enPassant) + assertNull("the pawn that stepped two is gone", after.board[squareFromName("d4")]) + assertNotNull("and the capturer stands behind it", after.board[squareFromName("d3")]) + + // Waiting a move loses the right. + var later = position("4k3/8/8/8/4p3/8/3P4/4K3 w - - 0 1").moveTo("d2d4") + later = later.moveTo("e8d8") + later = later.moveTo("e1d1") + assertTrue(ChessRules.legalMoves(later).none { it.toString() == "e4d3" }) + } + + @Test + fun `en passant is refused when it would expose the king`() { + // White king on e5, black rook on h5: taking en passant clears two + // pawns off the fifth rank at once and hangs the king. + val state = position("8/8/8/K1Pp3r/8/8/8/4k3 w - d6 0 1") + assertTrue( + "c5d6 must not be legal", + ChessRules.legalMoves(state).none { it.toString() == "c5d6" }, + ) + } + + @Test + fun `a pawn reaching the last rank offers all four promotions`() { + val state = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1") + val promotions = ChessRules.legalMoves(state) + .filter { squareName(it.from) == "a7" } + .mapNotNull { it.promotion } + assertEquals( + setOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT), + promotions.toSet(), + ) + + val queened = state.moveTo("a7a8q") + assertEquals(Piece(PieceKind.QUEEN, white = true), queened.board[squareFromName("a8")]) + } + + @Test + fun `castling moves the rook and gives up both rights`() { + var state = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1") + state = state.moveTo("e1g1") + assertEquals(Piece(PieceKind.KING, true), state.board[squareFromName("g1")]) + assertEquals("the rook jumps over", Piece(PieceKind.ROOK, true), state.board[squareFromName("f1")]) + assertNull(state.board[squareFromName("h1")]) + assertFalse(state.castling.whiteKingSide) + assertFalse(state.castling.whiteQueenSide) + assertTrue("black keeps its own", state.castling.blackKingSide) + + val queenSide = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1c1") + assertEquals(Piece(PieceKind.ROOK, true), queenSide.board[squareFromName("d1")]) + } + + @Test + fun `you may not castle out of, through, or into check`() { + // Rook on e8 attacks the king's own square. + assertTrue( + "out of check", + ChessRules.legalMoves(position("4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1")) + .none { it.toString() == "e1g1" || it.toString() == "e1c1" }, + ) + // Rook on f8 attacks the square the king crosses. + assertTrue( + "through check", + ChessRules.legalMoves(position("5r2/8/8/8/8/8/8/R3K2R w KQ - 0 1")) + .none { it.toString() == "e1g1" }, + ) + // Rook on g8 attacks the square the king lands on. + assertTrue( + "into check", + ChessRules.legalMoves(position("6r1/8/8/8/8/8/8/R3K2R w KQ - 0 1")) + .none { it.toString() == "e1g1" }, + ) + // The b-file square may be attacked; the king does not cross it. + assertTrue( + "b1 attacked is still fine", + ChessRules.legalMoves(position("1r6/8/8/8/8/8/8/R3K2R w KQ - 0 1")) + .any { it.toString() == "e1c1" }, + ) + } + + @Test + fun `castling rights go when a rook moves or is taken on its corner`() { + val moved = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("a1b1") + assertFalse(moved.castling.whiteQueenSide) + assertTrue(moved.castling.whiteKingSide) + + // White's rook takes on a8, so Black loses the queen's side. + val taken = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1") + .moveTo("a1a8") + assertFalse("captured rook loses its right", taken.castling.blackQueenSide) + assertTrue(taken.castling.blackKingSide) + } + + @Test + fun `a pinned piece may not step off the line`() { + // The knight on e2 is pinned against the king by the rook on e8. + val state = position("4r3/8/8/8/8/8/4N3/4K3 w - - 0 1") + assertTrue( + ChessRules.legalMoves(state).none { squareName(it.from) == "e2" }, + ) + } + + @Test + fun `in check you must answer the check`() { + val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1") + assertTrue(ChessRules.inCheck(state)) + val moves = ChessRules.legalMoves(state).map { it.toString() }.toSet() + // Take the rook, or step off the file. Nothing else. + assertEquals(setOf("e1e2", "e1d1", "e1f1"), moves) + } + + // -- Endings ------------------------------------------------------------ + + @Test + fun `the back rank mate ends the game`() { + val state = position("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1").moveTo("a1a8") + assertTrue(ChessRules.isFinished(state)) + assertEquals(ChessOutcome.WHITE_WINS, state.outcome) + assertEquals("recorded as mate", "Ra8#", state.moveLog.last()) + assertNull("nobody is on the clock", ChessRules.currentSeat(state)) + } + + @Test + fun `fools mate is mate`() { + var state = ChessRules.initialState(config()) + state = state.moveTo("f2f3") + state = state.moveTo("e7e5") + state = state.moveTo("g2g4") + state = state.moveTo("d8h4") + assertEquals(ChessOutcome.BLACK_WINS, state.outcome) + assertEquals("Qh4#", state.moveLog.last()) + } + + @Test + fun `a king with no move and no check is stalemate`() { + // Qf7 takes g7, g8 and h7 away without touching h8. + val state = position("7k/8/6Q1/8/8/8/8/K7 w - - 0 1").moveTo("g6f7") + assertEquals(ChessOutcome.STALEMATE, state.outcome) + assertTrue(ChessRules.isFinished(state)) + } + + @Test + fun `bare kings cannot mate and the game is drawn`() { + val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1").moveTo("e1e2") + assertEquals(ChessOutcome.INSUFFICIENT_MATERIAL, state.outcome) + } + + @Test + fun `what counts as too little material`() { + fun material(fen: String) = ChessRules.insufficientMaterial(parseFen(fen).board) + assertTrue("bare kings", material("4k3/8/8/8/8/8/8/4K3 w - - 0 1")) + assertTrue("one knight", material("4k3/8/8/8/8/8/8/3NK3 w - - 0 1")) + assertTrue("one bishop", material("4k3/8/8/8/8/8/8/3BK3 w - - 0 1")) + // Both bishops on dark squares: nothing can ever be forced. + assertTrue("same-colour bishops", material("2b1k3/8/8/8/8/8/8/3BK3 w - - 0 1")) + assertFalse("opposite-colour bishops", material("3bk3/8/8/8/8/8/8/3BK3 w - - 0 1")) + assertFalse("a rook is enough", material("4k3/8/8/8/8/8/8/3RK3 w - - 0 1")) + assertFalse("a pawn can promote", material("4k3/8/8/8/8/8/3P4/4K3 w - - 0 1")) + assertFalse("two knights are not automatic", material("4k3/8/8/8/8/8/8/2NNK3 w - - 0 1")) + } + + @Test + fun `the fifty-move rule ends it, and can be switched off`() { + val fen = "4k3/8/8/8/8/8/8/R3K3 w - - 99 60" + val drawn = position(fen).moveTo("a1a2") + assertEquals(ChessOutcome.FIFTY_MOVE, drawn.outcome) + + val played = position(fen, ChessOptions(fiftyMoveRule = false)).moveTo("a1a2") + assertNull("the toggle keeps the game alive", played.outcome) + } + + @Test + fun `a capture or a pawn move resets the clock`() { + val state = position("4k3/8/8/8/8/8/3P4/4K3 w - - 40 60").moveTo("d2d4") + assertEquals(0, state.halfmoveClock) + } + + @Test + fun `the same position three times is a draw`() { + // Both kings walk back and forth; the third repetition ends it. + var state = position("4k3/8/8/8/8/8/8/R3K3 w - - 0 1") + var guard = 0 + while (!ChessRules.isFinished(state) && guard++ < 40) { + val square = if (guard % 4 == 1 || guard % 4 == 2) { + if (state.whiteToMove) "a1b1" else "e8d8" + } else { + if (state.whiteToMove) "b1a1" else "d8e8" + } + state = state.moveTo(square) + } + assertEquals(ChessOutcome.REPETITION, state.outcome) + } + + // -- Notation ----------------------------------------------------------- + + @Test + fun `the move log reads as algebraic notation`() { + var state = ChessRules.initialState(config()) + state = state.moveTo("e2e4") + state = state.moveTo("e7e5") + state = state.moveTo("g1f3") + state = state.moveTo("b8c6") + state = state.moveTo("f1b5") + assertEquals(listOf("e4", "e5", "Nf3", "Nc6", "Bb5"), state.moveLog) + } + + @Test + fun `captures, castling and promotion are all written out`() { + val taken = position("4k3/8/8/3p4/4P3/8/8/4K3 w - - 0 1").moveTo("e4d5") + assertEquals("exd5", taken.moveLog.last()) + + val castled = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1g1") + assertEquals("O-O", castled.moveLog.last()) + + val long = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1c1") + assertEquals("O-O-O", long.moveLog.last()) + + val promoted = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1").moveTo("a7a8r") + assertEquals("a8=R+", promoted.moveLog.last()) + } + + @Test + fun `two pieces reaching the same square are told apart`() { + // Knights on c3 and g1 both reach e2: the file separates them. + val byFile = position("4k3/8/8/8/8/2N5/8/4K1N1 w - - 0 1").moveTo("c3e2") + assertEquals("Nce2", byFile.moveLog.last()) + + // Rooks on a1 and a5 both reach a3, sharing a file: the rank separates them. + val byRank = position("4k3/8/8/R7/8/8/8/R3K3 w - - 0 1").moveTo("a1a3") + assertEquals("R1a3", byRank.moveLog.last()) + } + + // -- Playing it out ----------------------------------------------------- + + @Test + fun `random legal play always reaches a result`() { + repeat(12) { iteration -> + val random = Random(iteration.toLong()) + var state = ChessRules.initialState(config()) + var guard = 0 + while (!ChessRules.isFinished(state) && guard++ < 1_000) { + val seat = ChessRules.currentSeat(state)!! + val legal = ChessRules.legalMoves(state, seat) + assertTrue("no legal move but the game is not over", legal.isNotEmpty()) + state = ChessRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("game $iteration never ended", ChessRules.isFinished(state)) + assertNotNull(state.outcome) + } + } + + @Test + fun `the wrong seat cannot move`() { + val state = ChessRules.initialState(config()) + assertTrue(ChessRules.legalMoves(state, BLACK_SEAT).isEmpty()) + val move = ChessRules.legalMoves(state, WHITE_SEAT).first() + runCatching { ChessRules.applyMove(state, BLACK_SEAT, move) } + .onSuccess { throw AssertionError("Black was allowed to play White's move") } + } + + // -- Wire format -------------------------------------------------------- + + @Test + fun `both sides see the same board`() { + val state = ChessRules.initialState(config()) + assertEquals(state, ChessRules.viewFor(state, WHITE_SEAT)) + assertEquals(state, ChessRules.viewFor(state, BLACK_SEAT)) + } + + @Test + fun `state and moves survive a round trip`() { + val state = ChessRules.initialState(config()).moveTo("e2e4") + assertEquals(state, ChessRules.decodeState(ChessRules.encodeState(state))) + listOf( + ChessMove(squareFromName("e2"), squareFromName("e4")), + ChessMove(squareFromName("a7"), squareFromName("a8"), PieceKind.QUEEN), + ).forEach { assertEquals(it, ChessRules.decodeMove(ChessRules.encodeMove(it))) } + } + + private companion object { + /** + * The standard perft suite. Depths are kept where they run in a second + * or two apiece — deep enough that castling, en passant, promotion and + * pin handling are all exercised many thousands of times over. + */ + val PERFT_POSITIONS: List>> = listOf( + // Kiwipete: castling both ways, pins, and a board full of tactics. + "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" to + listOf(48L, 2_039L, 97_862L), + // A rook-and-pawn endgame that catches en passant errors. + "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1" to + listOf(14L, 191L, 2_812L, 43_238L), + // Promotion with capture, from both sides. + "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1" to + listOf(6L, 264L, 9_467L), + // A cramped position where most moves are illegal. + "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8" to + listOf(44L, 1_486L, 62_379L), + ) + } +} diff --git a/docs/RULES-chess.md b/docs/RULES-chess.md new file mode 100644 index 0000000..1e7abc6 --- /dev/null +++ b/docs/RULES-chess.md @@ -0,0 +1,84 @@ +# Chess — implemented rules + +The full game, not a subset. This is the ruleset the engine enforces. Anything +marked **toggle** is in the game setup screen. + +## Table + +Two players. Seat 0 is always White; choosing to play Black in setup puts you in +seat 1 and turns the board round so your pieces are still at the near edge. + +## Movement + +All of it, including the parts that are usually left out: + +- **Castling**, both sides, with every condition: the king and the rook must not + have moved, the squares between them must be empty, and the king may not + castle out of check, through an attacked square, or into check. The b-file + square may be attacked on the queen's side — the king does not cross it. +- **En passant**, on the move immediately after a pawn steps two squares and + only then. It is refused when taking would expose your own king, which is the + one case a naive implementation gets wrong. +- **Promotion** to queen, rook, bishop or knight, chosen when the pawn arrives. +- **Pins** — a piece that shields its own king may not step off the line. + +Rights are given up correctly: a rook that moves loses its castling right, and +so does a rook captured on its own corner. + +## How a game ends + +| Ending | When | +| --- | --- | +| Checkmate | No legal move, and in check | +| Stalemate | No legal move, and not in check | +| Insufficient material | Neither side has enough to mate, whatever either plays | +| Fifty-move rule | A hundred half-moves with no capture and no pawn move — **toggle** | +| Threefold repetition | The same position, side to move and rights, three times — **toggle** | + +Insufficient material covers bare kings, a king and one minor piece, and two +bishops on the same colour square. King and two knights is *not* included: it +cannot be forced, but it can happen with cooperation, so it is not a dead +position. + +Both draw rules apply automatically rather than needing a claim. Switch either +off in setup for a game that runs until somebody is mated. + +## Notation + +The move list is ordinary algebraic: `e4`, `Nf3`, `exd5`, `O-O`, `a8=Q+`, +`Ra8#`. Two pieces that can reach the same square are told apart by file, or by +rank when they share a file, or by the whole square when they share both. + +## Positions + +The engine reads and writes FEN, so a table can start from a set position rather +than the opening array — a puzzle, or an adjourned game. + +## What the computer opponent does + +A negamax search with alpha-beta pruning, iterative deepening, and a +capture-only quiescence tail. The quiescence tail is what stops it giving pieces +away: a search that simply stops after a fixed number of moves scores a position +in the middle of an exchange as though the recapture never comes. + +Evaluation is material plus piece placement, with a term in bare endgames that +drives the losing king towards the edge — without it a won endgame just shuffles. + +**Toggle** for three strengths: + +| Level | Search | Plays | +| --- | --- | --- | +| Casual | 2 ply | Takes any move within about two thirds of a pawn of the best one | +| Club | 3 ply | Nearly always the best move it can see | +| Strong | 4 ply | The best move it can see | + +Every level is capped by a node budget, so no single move can hang the app on a +sharp position — it plays the best move from the last depth it finished. + +## Testing + +Move generation is checked against the published **perft** counts: the exact +number of positions reachable at a given depth. The opening array is verified to +depth four (197,281 positions), along with four standard test positions chosen +because they break generators — castling through check, en passant that would +expose a king, promotion with capture, and heavily pinned pieces.