diff --git a/README.md b/README.md index 43b356b..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 four games are playable end to end: +The app shell is complete and eight 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, 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, @@ -148,8 +148,12 @@ 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) +- [Chess](docs/RULES-chess.md) ## Layout @@ -158,9 +162,13 @@ 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 + 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 c65f956..5a09f32 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, @@ -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/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/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/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/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 6af6d44..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,12 +30,20 @@ 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 +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. @@ -48,28 +56,33 @@ fun GameSetupScreen( descriptor: GameDescriptor, playerName: String, onBack: () -> Unit, - onPlayOffline: (TableConfig) -> Unit, + onPlayOffline: (TableConfig, Int) -> Unit, onHostOnline: (String) -> Unit, ) { var euchre by remember { mutableStateOf(EuchreOptions()) } 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()) } + 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) 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) + GameCatalog.CHESS -> setupJson.encodeToString(chess) 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 +99,19 @@ 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 } + GameCatalog.CHESS -> ChessOptionsEditor( + options = chess, + playWhite = playWhite, + onChange = { chess = it }, + onColour = { playWhite = it }, + ) + else -> Text("No options yet for this game.") } @@ -98,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") { @@ -110,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), ) }, @@ -131,9 +162,42 @@ 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 + + GameCatalog.CHESS -> 2 + 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 +369,155 @@ 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, + ) + } +} + +@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/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..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,15 @@ 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 +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 +26,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 +40,22 @@ 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.ChessScreen +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 +177,128 @@ 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) + } + + 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/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..e548ef0 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,10 @@ class CatalogTest { GameCatalog.SEQUENCE, GameCatalog.PRESIDENT, GameCatalog.GOLF, + 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/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-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. 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.