From 284f9f3f950dc327e7b95440d5e82d9a76e48007 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:58:12 +0000 Subject: [PATCH] Add Checkers, Backgammon, Mastermind, and Pirates and Bulgars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four games that were listed but not written. Nothing in the catalogue is marked "coming soon" any more. Checkers is English draughts on the thirty-two dark squares, numbered the way the game is written down. Captures are compulsory and a multiple jump is one move object, so a player cannot stop halfway through one — true by construction rather than by a rule that has to be enforced somewhere. The move generator matches the published perft counts at one to four ply (7, 49, 302, 1469), which fold in compulsory capture, crowning and the men's forward-only rule all at once. Backgammon's hard rule is not how a checker moves but that a player must use as much of the roll as they can, and that cannot be checked one move at a time: a move can be illegal precisely because playing it strands the other die. So the generator works backwards from whole turns — memoised depth-first over the remaining dice — and offers only the first steps of the longest plays. Recomputing it after each checker is what makes the rule true by construction. Every die comes from the table's seed and a count of rolls made, which is in the state rather than beside it so a game is reproducible from what crosses the wire. There is no doubling cube; the rules document says so plainly. Mastermind is played as a duel — both players are set a code and both are breaking one — because the pencil-and-paper game gives the codemaker nothing to do. It ends at the foot of a round, never mid-round, or going first would be worth half a guess. It is the one game here with anything to hide, so viewFor does real work: the host sends each device only its own code, and a device that was never given a code refuses to score a guess against it rather than quietly answering "nothing". Pirates and Bulgars is Sackson's theming of Asalto, and the rules document is explicit about which parts are attested and which had to be chosen — the book could not be obtained. Two hunters who take by jumping against twenty-four who cannot take at all, on a thirty-three point cross whose drawn lines come from the same adjacency table the moves come from, so the board and the rules cannot disagree. 130 unit tests across the four engines, all run locally as well as in CI: game/ has no Android in it, which is what makes that possible. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud --- README.md | 34 +- .../org/prolibertate/games/game/Catalog.kt | 21 +- .../games/game/backgammon/BackgammonAi.kt | 214 +++++++++ .../games/game/backgammon/BackgammonModel.kt | 193 ++++++++ .../games/game/backgammon/BackgammonRules.kt | 442 ++++++++++++++++++ .../games/game/checkers/CheckersAi.kt | 189 ++++++++ .../games/game/checkers/CheckersModel.kt | 183 ++++++++ .../games/game/checkers/CheckersRules.kt | 372 +++++++++++++++ .../games/game/mastermind/MastermindAi.kt | 92 ++++ .../games/game/mastermind/MastermindModel.kt | 170 +++++++ .../games/game/mastermind/MastermindRules.kt | 172 +++++++ .../games/game/pirates/PiratesAi.kt | 170 +++++++ .../games/game/pirates/PiratesModel.kt | 201 ++++++++ .../games/game/pirates/PiratesRules.kt | 255 ++++++++++ .../prolibertate/games/ui/GameSetupScreen.kt | 218 ++++++++- .../org/prolibertate/games/ui/PlayScreen.kt | 90 ++++ .../games/ui/game/BackgammonScreen.kt | 419 +++++++++++++++++ .../games/ui/game/CheckersScreen.kt | 353 ++++++++++++++ .../games/ui/game/MastermindScreen.kt | 356 ++++++++++++++ .../games/ui/game/PiratesScreen.kt | 410 ++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 21 +- .../game/backgammon/BackgammonRulesTest.kt | 408 ++++++++++++++++ .../games/game/checkers/CheckersRulesTest.kt | 350 ++++++++++++++ .../game/mastermind/MastermindRulesTest.kt | 285 +++++++++++ .../games/game/pirates/PiratesRulesTest.kt | 326 +++++++++++++ docs/RULES-backgammon.md | 96 ++++ docs/RULES-checkers.md | 95 ++++ docs/RULES-mastermind.md | 79 ++++ docs/RULES-pirates.md | 105 +++++ 29 files changed, 6299 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/checkers/CheckersAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/checkers/CheckersModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/checkers/CheckersRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/mastermind/MastermindModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/mastermind/MastermindRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pirates/PiratesAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pirates/PiratesModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pirates/PiratesRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/BackgammonScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/CheckersScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/PiratesScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/backgammon/BackgammonRulesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/checkers/CheckersRulesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/mastermind/MastermindRulesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/pirates/PiratesRulesTest.kt create mode 100644 docs/RULES-backgammon.md create mode 100644 docs/RULES-checkers.md create mode 100644 docs/RULES-mastermind.md create mode 100644 docs/RULES-pirates.md diff --git a/README.md b/README.md index 9e0e83c..da5c57b 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,23 @@ or against other people over Wi-Fi (LAN). ## What works today -The app shell is complete and ten games are playable end to end: +The app shell is complete and every game in the catalogue is playable end to end: | | | | --- | --- | -| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess, Ta Yü, Nine Men's Morris | -| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Pirates and Bulgars | +| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s | +| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü | -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, -so nobody installs it and taps into a dead end. The menu decides this from the -installed APK's own version name, so it follows the build rather than the -channel selected for future updates. Adding one means -writing a `GameRules` implementation, a `GameAi`, and a screen, then flipping -`available` in `GameCatalog` — the menu, lobby, networking, settings and OTA +Nothing is marked *Coming soon* at the moment, because nothing is waiting. The +machinery for it is still there and still tested: a game listed in `GameCatalog` +with `available = false` appears in the menu marked *Coming soon* **on dev builds +only**, so a production release lists just what can actually be played and nobody +installs it and taps into a dead end. The menu decides this from the installed +APK's own version name, so it follows the build rather than the channel selected +for future updates. + +Adding a game means writing a `GameRules` implementation, a `GameAi` and a +screen, then flipping `available` — the menu, lobby, networking, settings and OTA layers are game-agnostic and need no changes. ## Playing with other people @@ -269,7 +272,14 @@ game: - [Wizard](docs/RULES-wizard.md) - [Crazy 8s](docs/RULES-crazy8s.md) - [Chess](docs/RULES-chess.md) +- [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings** +- [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube** +- [Mastermind](docs/RULES-mastermind.md) — played as a duel, both players + guarding a code and breaking one - [Nine Men's Morris](docs/RULES-morris.md) +- [Pirates and Bulgars](docs/RULES-pirates.md) — **note the caveats about the + reconstruction**: the book the theme comes from could not be obtained, so the + document says which rules are attested and which were chosen - [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**: the game is out of print and the rulebook could not be obtained, so the document says which parts are attested and which were derived @@ -289,6 +299,10 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested crazyeights/ Crazy 8s model, rules, AI chess/ Chess model, FEN, move generation, search morris/ Nine Men's Morris board geometry, mills, rules, search + checkers/ Checkers board, compulsory captures, crowning, search + backgammon/ Backgammon points, dice, the maximal-roll rule, turn search + mastermind/ Mastermind codes, scoring, and a code breaker + pirates/ Pirates and Bulgars — the cross board, the hunt, search tayu/ Ta Yü tile geometry, river rules, scoring, AI net/ wire protocol, LAN transport, lobby, match driver score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store 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 3e93c67..436cc32 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -40,6 +40,7 @@ object GameCatalog { const val CHECKERS = "checkers" const val TAYU = "tayu" const val MORRIS = "morris" + const val PIRATES = "pirates" val all: List = listOf( GameDescriptor( @@ -119,8 +120,9 @@ object GameCatalog { minPlayers = 2, maxPlayers = 2, teamBased = false, - blurb = "Break the hidden code from black and white peg clues.", - available = false, + blurb = "Break the hidden code from black and white peg clues. " + + "Both players get one, and whoever cracks theirs first wins.", + available = true, ), GameDescriptor( id = BACKGAMMON, @@ -130,7 +132,7 @@ object GameCatalog { maxPlayers = 2, teamBased = false, blurb = "Race your checkers home, and hit your opponent on the way.", - available = false, + available = true, ), GameDescriptor( id = CHESS, @@ -150,7 +152,7 @@ object GameCatalog { maxPlayers = 2, teamBased = false, blurb = "Forced captures, kings, and the long diagonal.", - available = false, + available = true, ), GameDescriptor( id = MORRIS, @@ -162,6 +164,17 @@ object GameCatalog { blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.", available = true, ), + GameDescriptor( + id = PIRATES, + title = "Pirates and Bulgars", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "Two pirates hold the stronghold against twenty-four Bulgars. " + + "Only one side can take pieces, and it is not the big one.", + available = true, + ), GameDescriptor( id = TAYU, title = "Ta Yü", diff --git a/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonAi.kt b/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonAi.kt new file mode 100644 index 0000000..67b1754 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonAi.kt @@ -0,0 +1,214 @@ +package org.prolibertate.games.game.backgammon + +import kotlin.random.Random +import org.prolibertate.games.game.engine.GameAi + +/** + * A backgammon player. + * + * This is not a search in the sense the chess and checkers players are. There + * is no tree to walk: the opponent's reply depends on dice nobody has thrown + * yet, so looking further ahead means averaging over twenty-one rolls rather + * than choosing the best one. What it does instead is weigh **whole turns** — + * every distinct way the roll can be played — and score the board each leaves + * behind. + * + * Weighing turns rather than checkers is the whole point. A backgammon move is + * only good in company: splitting the back checkers is right or wrong depending + * on what the other die did, and a player that chose one checker at a time + * would never see it. + * + * The interface hands back one checker at a time, so the best turn is worked + * out and its first step returned; the rest follows on the moves after, because + * the same calculation reaches the same turn. + */ +class BackgammonAi(private val level: BackgammonLevel? = null) : + GameAi { + + override fun chooseMove( + state: BackgammonState, + seat: Int, + legal: List, + ): BackgammonMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val setting = level ?: state.options.level + if (legal.size == 1) return legal.first() + + val random = Random(state.rollsMade * 31L + state.moveLog.size) + + if (setting == BackgammonLevel.CASUAL) { + // Not thoughtless: it still prefers to hit, to come in off the bar + // and to bear off, which is most of what a beginner knows. + val scored = legal.map { move -> move to rough(state, seat, move) } + val best = scored.maxOf { it.second } + val equal = scored.filter { it.second == best } + return equal[random.nextInt(equal.size)].first + } + + val sequences = BackgammonRules.deepestSequences(state, seat) + if (sequences.isEmpty()) return legal.first() + + var best = sequences.first() + var bestScore = -Double.MAX_VALUE + for (sequence in sequences) { + val after = BackgammonRules.playSequence(state, seat, sequence) + val score = if (setting == BackgammonLevel.STRONG) { + // What the position is worth once the dice have had their say. + evaluate(after, seat) - averageReply(after, other(seat)) + } else { + evaluate(after, seat) + } + if (score > bestScore) { + bestScore = score + best = sequence + } + } + return best.first() + } + + /** A quick opinion of one checker, for the level that does not think. */ + private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove): Int { + val landing = BackgammonRules.destinationOf(seat, move.from, move.die) + var score = 0 + if (move.from == BAR) score += 50 + if (landing == null) score += 40 + if (landing != null && state.countOn(landing, other(seat)) == 1) score += 60 + if (landing != null && state.countOn(landing, seat) >= 1) score += 15 + return score + } + + /** + * What the opponent is likely to make of this position, averaged over the + * twenty-one distinct rolls of two dice. + * + * Doubles come up half as often as a mixed pair — 6-5 can be thrown two + * ways and 5-5 only one — so each roll is weighted rather than counted. + * Only the opponent's best answer to each roll is taken: they will not + * play a bad one on purpose. + */ + private fun averageReply(state: BackgammonState, opponent: Int): Double { + if (state.phase == BackgammonPhase.GAME_OVER) return 0.0 + + var total = 0.0 + var weight = 0.0 + for (high in 1..6) { + for (low in 1..high) { + val chance = if (high == low) 1.0 else 2.0 + val dice = if (high == low) List(4) { high } else listOf(high, low) + val rolled = state.copy(turn = opponent, dice = dice, roll = listOf(high, low)) + val replies = BackgammonRules.deepestSequences(rolled, opponent) + val bestReply = replies.maxOfOrNull { sequence -> + evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent) + } ?: evaluate(rolled, opponent) + total += chance * bestReply + weight += chance + } + } + return total / weight + } + + /** + * The position from [seat]'s point of view. + * + * The pip count is the backbone of it — backgammon is a race, and the + * player who is ahead in the race wants to keep it simple. Everything else + * is about what happens when it is not simple: **a blot is only a liability + * if it can be hit**, so exposure is weighted by how far away the enemy + * checkers are, and a made point in your own home board is worth having + * because it is one more number your opponent cannot come in on. + */ + private fun evaluate(state: BackgammonState, seat: Int): Double { + val enemy = other(seat) + + if (state.phase == BackgammonPhase.GAME_OVER) { + val won = state.off[seat] >= CHECKERS + return if (won) WIN else -WIN + } + + var score = 0.0 + + // The race. Being ahead in pips is the plainest good thing there is. + score += (state.pipCount(enemy) - state.pipCount(seat)) * PIP + + // Checkers home and dry, and checkers sent back to the beginning. + score += state.off[seat] * BORNE_OFF + score -= state.off[enemy] * BORNE_OFF + score -= state.bar[seat] * ON_BAR + score += state.bar[enemy] * ON_BAR + + for (point in 0 until POINTS) { + val mine = state.countOn(point, seat) + if (mine == 0) continue + + if (mine >= 2) { + score += POINT_MADE + // A point in your own home board is a door shut in the face of + // anyone you send to the bar. + if (point in homeOf(seat)) score += HOME_POINT + // And an anchor deep in theirs is somewhere safe to come back to. + if (point in homeOf(enemy)) score += ANCHOR + } else { + // A blot, weighted by how easily it can be reached: something + // six away is in real danger, something twenty away is not. + val distance = shortestEnemyDistance(state, seat, point) + score -= when { + distance <= 0 -> 0.0 + distance <= 6 -> BLOT_NEAR + distance <= 12 -> BLOT_FAR + else -> BLOT_DISTANT + } + } + } + + score += primeLength(state, seat) * PRIME + return score + } + + /** How far the nearest enemy checker is from reaching [point], in pips. */ + private fun shortestEnemyDistance(state: BackgammonState, seat: Int, point: Int): Int { + val enemy = other(seat) + // Anything on the bar comes in at the far end and is a threat to the + // whole board on its way round. + if (state.bar[enemy] > 0) { + val fromBar = if (enemy == WHITE_SEAT) 24 - point else point + 1 + if (fromBar in 1..6) return fromBar + } + var best = Int.MAX_VALUE + for (from in 0 until POINTS) { + if (state.countOn(from, enemy) == 0) continue + val gap = (point - from) * directionOf(enemy) + if (gap in 1..24 && gap < best) best = gap + } + return if (best == Int.MAX_VALUE) 0 else best + } + + /** The longest run of consecutive points held, which is what traps a checker. */ + private fun primeLength(state: BackgammonState, seat: Int): Int { + var best = 0 + var run = 0 + for (point in 0 until POINTS) { + if (state.countOn(point, seat) >= 2) { + run++ + if (run > best) best = run + } else { + run = 0 + } + } + return best + } + + private companion object { + const val WIN = 10_000.0 + + const val PIP = 1.0 + const val BORNE_OFF = 12.0 + const val ON_BAR = 25.0 + const val POINT_MADE = 4.0 + const val HOME_POINT = 8.0 + const val ANCHOR = 6.0 + const val PRIME = 5.0 + const val BLOT_NEAR = 14.0 + const val BLOT_FAR = 6.0 + const val BLOT_DISTANT = 2.0 + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonModel.kt b/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonModel.kt new file mode 100644 index 0000000..9e934e4 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonModel.kt @@ -0,0 +1,193 @@ +package org.prolibertate.games.game.backgammon + +import kotlinx.serialization.Serializable + +/** + * The board is twenty-four points, 0 to 23. + * + * **White runs down the numbers and bears off past 0; Black runs up them and + * bears off past 23.** White's home board is points 0–5, Black's is 18–23, and + * each enters from the bar into the *other* player's home: White at 24 minus + * the die, Black at the die minus one. + * + * A point holds a positive number of White checkers or a negative number of + * Black ones, never both — which is the rule that a point belongs to whoever + * has two or more on it, expressed as arithmetic rather than as a check. + */ +const val POINTS = 24 + +const val CHECKERS = 15 + +const val WHITE_SEAT = 0 +const val BLACK_SEAT = 1 + +/** Where a checker sits when it has been hit, as a [BackgammonMove.from]. */ +const val BAR = 24 + +fun other(seat: Int): Int = 1 - seat + +fun seatName(seat: Int): String = if (seat == WHITE_SEAT) "White" else "Black" + +/** Which way this seat's checkers travel along the numbering. */ +fun directionOf(seat: Int): Int = if (seat == WHITE_SEAT) -1 else 1 + +/** The six points this seat bears off from. */ +fun homeOf(seat: Int): IntRange = if (seat == WHITE_SEAT) 0..5 else 18..23 + +/** Where a checker coming off the bar lands for a given die. */ +fun entryPoint(seat: Int, die: Int): Int = if (seat == WHITE_SEAT) 24 - die else die - 1 + +/** + * How far a point is from being borne off, counted in pips: 1 for the last + * point before home, 24 for the far corner. The pip count is the sum of these + * over a player's checkers and is the single most useful number in the game. + */ +fun pipsFrom(seat: Int, point: Int): Int = if (seat == WHITE_SEAT) point + 1 else 24 - point + +@Serializable +enum class BackgammonPhase { PLAYING, GAME_OVER } + +/** + * How a game ended. Backgammon scores by how badly the loser lost: a plain win + * is worth one point, a gammon two — the loser bore nothing off — and a + * backgammon three, where the loser still had a checker on the bar or in the + * winner's home when it finished. + */ +@Serializable +enum class BackgammonOutcome(val label: String, val points: Int) { + WHITE_SINGLE("White wins", 1), + WHITE_GAMMON("White wins a gammon — Black bore nothing off", 2), + WHITE_BACKGAMMON("White wins a backgammon — Black was still stranded", 3), + BLACK_SINGLE("Black wins", 1), + BLACK_GAMMON("Black wins a gammon — White bore nothing off", 2), + BLACK_BACKGAMMON("Black wins a backgammon — White was still stranded", 3), +} + +/** How hard the computer thinks about it. */ +@Serializable +enum class BackgammonLevel(val label: String) { + /** Takes the first playable thing it finds a reason for. */ + CASUAL("Casual"), + + /** Weighs every way the turn could be played and takes the best. */ + CLUB("Club"), + + /** + * The same, and then weighs what the dice might do to it next — every one + * of the twenty-one distinct rolls, and the best reply to each. + */ + STRONG("Strong"), +} + +@Serializable +data class BackgammonOptions( + val level: BackgammonLevel = BackgammonLevel.CLUB, + /** + * Score a gammon as two and a backgammon as three, rather than calling + * every win a win. It changes nothing about the moves and everything about + * whether a hopeless game is worth playing out. + */ + val countGammons: Boolean = true, +) + +/** + * One checker moving one die's worth. + * + * A turn is two moves, or four on a double, and each is submitted on its own — + * which is how a person plays it, one checker at a time. What keeps that honest + * is that the legal moves are recomputed after each one: a move that would + * strand the rest of the turn is never offered, because a move is only legal if + * it starts a way of playing the whole roll. + */ +@Serializable +data class BackgammonMove(val from: Int, val die: Int) { + override fun toString(): String { + val origin = if (from == BAR) "bar" else "${from + 1}" + return "$origin/$die" + } +} + +@Serializable +data class BackgammonState( + val options: BackgammonOptions, + /** + * The table's own seed, which every die of the game is thrown from + * together with [rollsMade]. It is part of the state rather than kept + * beside it so that a game is reproducible from what crosses the wire, and + * so two games running at once cannot get hold of each other's dice. + * + * Nothing is given away by sending it: backgammon has nothing hidden, and + * the host is authoritative about every roll in any case. + */ + val seed: Long, + /** Positive is that many White checkers, negative that many Black ones. */ + val points: List, + /** Checkers waiting to come back on, by seat. */ + val bar: List, + /** Checkers safely borne off, by seat. */ + val off: List, + val turn: Int, + /** The dice still to be played this turn. A double is four of them. */ + val dice: List, + /** The pair as it was rolled, kept so the screen can show it whole. */ + val roll: List, + /** How many rolls have been made, which is what makes the next one. */ + val rollsMade: Int, + val phase: BackgammonPhase, + val outcome: BackgammonOutcome?, + val lastMove: BackgammonMove?, + val moveLog: List, +) { + fun countOn(point: Int, seat: Int): Int { + val value = points[point] + return if (seat == WHITE_SEAT) maxOf(value, 0) else maxOf(-value, 0) + } + + fun ownerOf(point: Int): Int? = when { + points[point] > 0 -> WHITE_SEAT + points[point] < 0 -> BLACK_SEAT + else -> null + } + + /** A single checker, which can be hit and sent back to the bar. */ + fun isBlot(point: Int): Boolean = points[point] == 1 || points[point] == -1 + + /** Two or more of the enemy's: nowhere to land. */ + fun isBlockedFor(point: Int, seat: Int): Boolean = countOn(point, other(seat)) >= 2 + + /** Everything home or borne off, which is what bearing off waits for. */ + fun canBearOff(seat: Int): Boolean { + if (bar[seat] > 0) return false + val home = homeOf(seat) + return (0 until POINTS).none { it !in home && countOn(it, seat) > 0 } + } + + /** + * The pip count: how many pips this seat still has to travel. A checker on + * the bar has the whole board to cross, which is why it costs 25. + */ + fun pipCount(seat: Int): Int { + var pips = bar[seat] * 25 + for (point in 0 until POINTS) pips += countOn(point, seat) * pipsFrom(seat, point) + return pips + } + + fun checkersLeft(seat: Int): Int = CHECKERS - off[seat] +} + +/** + * The standard opening array, from White's point of view: two on the 24, five + * on the 13, three on the 8 and five on the 6 — mirrored for Black. + */ +fun startingPoints(): List { + val points = IntArray(POINTS) + points[23] = 2 + points[12] = 5 + points[7] = 3 + points[5] = 5 + points[0] = -2 + points[11] = -5 + points[16] = -3 + points[18] = -5 + return points.toList() +} diff --git a/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonRules.kt b/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonRules.kt new file mode 100644 index 0000000..c331a64 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonRules.kt @@ -0,0 +1,442 @@ +package org.prolibertate.games.game.backgammon + +import kotlin.random.Random +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 + +/** + * Backgammon as a pure state machine. See RULES-backgammon.md. + * + * The hard rule in backgammon is not how a checker moves, it is that **a player + * must use as much of the roll as they can**. It cannot be checked one move at + * a time, because whether a move is legal depends on what is left afterwards: a + * move that looks fine can be illegal precisely because playing it strands the + * other die. + * + * So the generator works backwards from whole turns. It searches out every way + * the roll could be played, keeps only the longest, and offers their first + * steps. Recomputing that after each checker means the rule is enforced by + * construction — a player physically cannot reach a position where the rest of + * their roll is unplayable when it need not have been. + * + * The dice are rolled from the table's own seed and a count of the rolls made, + * so a game is reproducible from the config it started with and a host and a + * client cannot disagree about what was thrown. + */ +object BackgammonRules : GameRules { + + override val gameId: String = GameCatalog.BACKGAMMON + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): BackgammonState { + require(config.seats.size == 2) { "Backgammon is a two-handed game" } + val options = json.decodeFromString(config.optionsJson) + return initialState(options, config.seed) + } + + fun initialState( + options: BackgammonOptions = BackgammonOptions(), + seed: Long = 0L, + ): BackgammonState { + // The opening roll is one die each and the higher starts, playing both + // — so a double is thrown again rather than played. + var rolls = 0 + var white: Int + var black: Int + do { + white = die(seed, rolls++) + black = die(seed, rolls++) + } while (white == black) + + val opener = if (white > black) WHITE_SEAT else BLACK_SEAT + val roll = listOf(white, black).sortedDescending() + + return BackgammonState( + options = options, + seed = seed, + points = startingPoints(), + bar = listOf(0, 0), + off = listOf(0, 0), + turn = opener, + dice = roll, + roll = roll, + rollsMade = rolls, + phase = BackgammonPhase.PLAYING, + outcome = null, + lastMove = null, + moveLog = emptyList(), + ).skipIfStuck() + } + + private fun die(seed: Long, index: Int): Int = Random(seed * 1_000_003L + index).nextInt(6) + 1 + + override fun currentSeat(state: BackgammonState): Int? = + if (state.phase == BackgammonPhase.GAME_OVER) null else state.turn + + // ----------------------------------------------------------------------- + // Moving one checker + // ----------------------------------------------------------------------- + + /** Where a checker from [from] lands with [die], or null if it runs off the board. */ + fun destinationOf(seat: Int, from: Int, die: Int): Int? { + if (from == BAR) return entryPoint(seat, die) + val landing = from + directionOf(seat) * die + return landing.takeIf { it in 0 until POINTS } + } + + /** + * Whether one checker may be played, ignoring what it leaves behind. The + * "use as much of the roll as you can" rule is applied by [legalMoves] on + * top of this. + */ + private fun canPlay(state: BackgammonState, seat: Int, move: BackgammonMove): Boolean { + if (move.die !in state.dice) return false + + // Anything on the bar comes back on first; nothing else may move. + if (state.bar[seat] > 0 && move.from != BAR) return false + if (move.from == BAR) { + if (state.bar[seat] <= 0) return false + val entry = entryPoint(seat, move.die) + return !state.isBlockedFor(entry, seat) + } + + if (state.countOn(move.from, seat) <= 0) return false + + val landing = destinationOf(seat, move.from, move.die) + if (landing != null) return !state.isBlockedFor(landing, seat) + + // Off the end of the board is bearing off, which has rules of its own. + return canBearOffFrom(state, seat, move.from, move.die) + } + + /** + * Bearing off: the exact die takes a checker from the point that needs it, + * and a bigger die may take one from the highest occupied point when + * nothing sits further back. An overshooting die may never take a checker + * that a smaller one could have reached. + */ + private fun canBearOffFrom( + state: BackgammonState, + seat: Int, + from: Int, + die: Int, + ): Boolean { + if (!state.canBearOff(seat)) return false + val needed = pipsFrom(seat, from) + if (die == needed) return true + if (die < needed) return false + // The die overshoots, so it is only allowed if this is the furthest + // checker from home. + return (0 until POINTS).none { point -> + state.countOn(point, seat) > 0 && pipsFrom(seat, point) > needed + } + } + + /** Applies one checker's move to the board, hitting a blot if there is one. */ + private fun place(state: BackgammonState, seat: Int, move: BackgammonMove): BackgammonState { + val points = state.points.toMutableList() + val bar = state.bar.toMutableList() + val off = state.off.toMutableList() + val sign = if (seat == WHITE_SEAT) 1 else -1 + + if (move.from == BAR) { + bar[seat] = bar[seat] - 1 + } else { + points[move.from] = points[move.from] - sign + } + + val landing = destinationOf(seat, move.from, move.die) + if (landing == null) { + off[seat] = off[seat] + 1 + } else { + // A lone enemy checker on the landing point is hit and starts again. + if (state.countOn(landing, other(seat)) == 1) { + points[landing] = 0 + bar[other(seat)] = bar[other(seat)] + 1 + } + points[landing] = points[landing] + sign + } + + val dice = state.dice.toMutableList() + dice.remove(move.die) + + return state.copy(points = points, bar = bar, off = off, dice = dice, lastMove = move) + } + + // ----------------------------------------------------------------------- + // Moving a whole turn + // ----------------------------------------------------------------------- + + fun legalMoves(state: BackgammonState): List = legalMoves(state, state.turn) + + /** + * The first steps of every way of playing as much of the roll as possible. + * + * Two ways of playing that both use every die are equally legal however + * they differ; one that uses a single die when both could have been used is + * not a legal turn at all. And when only one die can be played, it must be + * the higher — which is the one part of the rule that does not fall out of + * counting alone, so it is applied by hand at the end. + */ + override fun legalMoves(state: BackgammonState, seat: Int): List { + if (state.phase == BackgammonPhase.GAME_OVER || state.turn != seat) return emptyList() + if (state.dice.isEmpty()) return emptyList() + + val memo = mutableMapOf() + val target = depthOf(state, seat, memo) + if (target == 0) return emptyList() + + val moves = candidates(state, seat).filter { move -> + 1 + depthOf(place(state, seat, move), seat, memo) == target + }.distinct() + + if (target == 1) { + val highest = moves.maxOf { it.die } + return moves.filter { it.die == highest } + } + return moves + } + + /** + * How many of the remaining dice can still be played, at best. + * + * Memoised on the position rather than on the path to it: the four orders a + * double can be played in mostly reach the same boards, and without the + * memo the search would walk every one of them separately. The cut-off when + * the best possible depth is reached matters just as much — most turns find + * a full-length play immediately and never look at the rest. + */ + private fun depthOf( + state: BackgammonState, + seat: Int, + memo: MutableMap, + ): Int { + if (state.dice.isEmpty()) return 0 + val key = turnKey(state) + memo[key]?.let { return it } + + var best = 0 + for (move in candidates(state, seat)) { + val depth = 1 + depthOf(place(state, seat, move), seat, memo) + if (depth > best) best = depth + if (best == state.dice.size) break + } + memo[key] = best + return best + } + + /** What a half-played turn amounts to: the board, the bar, and the dice left. */ + private fun turnKey(state: BackgammonState): String = + state.points.joinToString(",") + "|" + state.bar.joinToString(",") + + "|" + state.off.joinToString(",") + "|" + state.dice.sorted().joinToString(",") + + /** + * Every longest way of playing what is left of the roll. + * + * Only for the computer, which wants to weigh whole turns rather than + * single checkers. Branches that cannot reach full length are cut before + * they are walked, and the list is capped: a double with checkers spread + * across the board has more orderings than are worth looking at, and they + * are mostly the same play in a different order anyway. + */ + fun deepestSequences(state: BackgammonState, seat: Int): List> { + val memo = mutableMapOf() + val target = depthOf(state, seat, memo) + if (target == 0) return emptyList() + + val found = mutableListOf>() + val seen = mutableSetOf() + + fun walk(current: BackgammonState, played: List) { + if (found.size >= SEQUENCE_CAP) return + if (played.size == target) { + // Two orderings that end on the same board are one play. + if (seen.add(turnKey(current))) found += played + return + } + for (move in candidates(current, seat)) { + val next = place(current, seat, move) + if (played.size + 1 + depthOf(next, seat, memo) == target) { + walk(next, played + move) + } + } + } + + walk(state, emptyList()) + + if (target == 1 && found.isNotEmpty()) { + val highest = found.maxOf { it.single().die } + return found.filter { it.single().die == highest } + } + return found + } + + /** The checkers that could move with one of the dice still in hand. */ + private fun candidates(state: BackgammonState, seat: Int): List { + if (state.dice.isEmpty()) return emptyList() + val dice = state.dice.distinct() + val origins = if (state.bar[seat] > 0) { + listOf(BAR) + } else { + (0 until POINTS).filter { state.countOn(it, seat) > 0 } + } + return origins.flatMap { from -> + dice.mapNotNull { die -> + BackgammonMove(from, die).takeIf { canPlay(state, seat, it) } + } + } + } + + override fun applyMove( + state: BackgammonState, + seat: Int, + move: BackgammonMove, + ): BackgammonState { + require(state.phase != BackgammonPhase.GAME_OVER) { "The game is over" } + require(state.turn == seat) { "It is not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + + val played = place(state, seat, move) + .copy(moveLog = state.moveLog + "${seatName(seat)} $move") + + val finished = played.withTerminalCheck() + if (finished.phase == BackgammonPhase.GAME_OVER) return finished + + // The turn ends when the dice run out or nothing more can be played. + return if (played.dice.isEmpty() || legalMoves(played, seat).isEmpty()) { + played.passTurn() + } else { + played + } + } + + /** Hands the dice to the other player and throws them. */ + private fun BackgammonState.passTurn(): BackgammonState { + val next = other(turn) + val first = die(seed, rollsMade) + val second = die(seed, rollsMade + 1) + // A double is four moves of the same number, which is the one piece of + // luck in backgammon big enough to decide a game on its own. + val thrown = if (first == second) List(4) { first } else listOf(first, second) + return copy( + turn = next, + dice = thrown, + roll = listOf(first, second), + rollsMade = rollsMade + 2, + ).skipIfStuck() + } + + /** + * A player with nothing they can play loses the turn. + * + * Usually this is somebody stuck on the bar against a closed board. It can + * happen several times over, so it keeps rolling until somebody can move — + * with a cap, because a board that closed against a player who has nothing + * else on it would otherwise roll for ever. + */ + private fun BackgammonState.skipIfStuck(): BackgammonState { + var state = this + var skipped = 0 + while (state.phase != BackgammonPhase.GAME_OVER && + legalMoves(state, state.turn).isEmpty() && + skipped < MAX_SKIPS + ) { + val next = other(state.turn) + val first = die(state.seed, state.rollsMade) + val second = die(state.seed, state.rollsMade + 1) + val thrown = if (first == second) List(4) { first } else listOf(first, second) + state = state.copy( + turn = next, + dice = thrown, + roll = listOf(first, second), + rollsMade = state.rollsMade + 2, + moveLog = state.moveLog + "${seatName(state.turn)} cannot move", + ) + skipped++ + } + return state + } + + /** + * Fifteen checkers off is the game. How much it is worth depends on how the + * loser was left: nothing borne off is a gammon, and still being on the bar + * or in the winner's home on top of that is a backgammon. + */ + private fun BackgammonState.withTerminalCheck(): BackgammonState { + val winner = (0..1).firstOrNull { off[it] >= CHECKERS } ?: return this + val loser = other(winner) + + val stranded = bar[loser] > 0 || homeOf(winner).any { countOn(it, loser) > 0 } + val severity = when { + !options.countGammons || off[loser] > 0 -> 0 + stranded -> 2 + else -> 1 + } + + val outcome = if (winner == WHITE_SEAT) { + when (severity) { + 2 -> BackgammonOutcome.WHITE_BACKGAMMON + 1 -> BackgammonOutcome.WHITE_GAMMON + else -> BackgammonOutcome.WHITE_SINGLE + } + } else { + when (severity) { + 2 -> BackgammonOutcome.BLACK_BACKGAMMON + 1 -> BackgammonOutcome.BLACK_GAMMON + else -> BackgammonOutcome.BLACK_SINGLE + } + } + return copy(phase = BackgammonPhase.GAME_OVER, outcome = outcome, dice = emptyList()) + } + + // ----------------------------------------------------------------------- + // Engine contract + // ----------------------------------------------------------------------- + + override fun isFinished(state: BackgammonState): Boolean = + state.phase == BackgammonPhase.GAME_OVER + + override fun summary(state: BackgammonState): String = state.outcome?.label ?: buildString { + append(seatName(state.turn)) + append(" to play ") + append(state.roll.joinToString("-")) + if (state.dice.size < state.roll.size) append(" (${state.dice.size} left)") + } + + /** Backgammon hides nothing: the board and the dice are on the table. */ + override fun viewFor(state: BackgammonState, seat: Int): BackgammonState = state + + override fun encodeState(state: BackgammonState): String = json.encodeToString(state) + override fun decodeState(json: String): BackgammonState = this.json.decodeFromString(json) + override fun encodeMove(move: BackgammonMove): String = json.encodeToString(move) + override fun decodeMove(json: String): BackgammonMove = this.json.decodeFromString(json) + + /** + * Plays a whole turn's worth of moves in one go, for the search and the + * tests. Anything that ends the turn stops it early. + */ + fun playSequence( + state: BackgammonState, + seat: Int, + sequence: List, + ): BackgammonState { + var current = state + for (move in sequence) { + if (current.phase == BackgammonPhase.GAME_OVER || current.turn != seat) break + current = applyMove(current, seat, move) + } + return current + } + + private const val MAX_SKIPS = 8 + + /** How many distinct ways of playing one roll the computer will weigh. */ + private const val SEQUENCE_CAP = 300 +} diff --git a/app/src/main/java/org/prolibertate/games/game/checkers/CheckersAi.kt b/app/src/main/java/org/prolibertate/games/game/checkers/CheckersAi.kt new file mode 100644 index 0000000..edf6d58 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/checkers/CheckersAi.kt @@ -0,0 +1,189 @@ +package org.prolibertate.games.game.checkers + +import kotlin.random.Random +import org.prolibertate.games.game.engine.GameAi + +/** + * A negamax search with alpha-beta pruning and iterative deepening. + * + * Checkers can be searched deeper than chess for the same money: captures are + * compulsory, so most positions offer a handful of moves rather than thirty, + * and a forced capture costs nothing to search. That is why the levels here go + * to seven ply where the chess ones stop at four. + * + * There is no quiescence tail. The usual reason for one is a search stopping in + * the middle of an exchange, and compulsory capture takes care of that by + * itself: a position where a piece has just been taken almost always has the + * recapture as its only legal move, so the search plays it whether it wanted to + * or not. + */ +class CheckersAi(private val level: CheckersLevel? = null) : + GameAi { + + private var nodeBudget = 0 + private var nodesUsed = 0 + + override fun chooseMove( + state: CheckersState, + seat: Int, + legal: List, + ): CheckersMove { + 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 + + val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode()) + + var best = legal.first() + var scored = legal.map { it to 0 } + + for (depth in 1..setting.depth) { + val results = mutableListOf>() + val ordered = order(legal, preferred = best) + for (move in ordered) { + val score = -negamax( + state = CheckersRules.advanced(state, move), + depth = depth - 1, + alpha = -INFINITY, + beta = INFINITY, + ply = 1, + ) + results += move to score + if (nodesUsed >= nodeBudget) break + } + if (nodesUsed >= nodeBudget && results.size < ordered.size) break + scored = results + best = results.maxBy { it.second }.first + } + + val bestScore = scored.maxOf { it.second } + val acceptable = scored.filter { it.second >= bestScore - setting.slack() } + return acceptable[random.nextInt(acceptable.size)].first + } + + private fun CheckersLevel.slack(): Int = when (this) { + CheckersLevel.CASUAL -> 80 + CheckersLevel.CLUB -> 20 + CheckersLevel.STRONG -> 0 + } + + private fun negamax(state: CheckersState, depth: Int, alpha: Int, beta: Int, ply: Int): Int { + nodesUsed++ + + if (state.phase == CheckersPhase.GAME_OVER) { + // The rules leave the loser on the clock, so a finished position is + // always a loss for the side to move — or a draw. + return when (state.outcome) { + CheckersOutcome.DRAW_NO_PROGRESS, CheckersOutcome.DRAW_REPETITION, null -> 0 + else -> -(WIN - ply) + } + } + if (depth <= 0) return evaluate(state) + + val moves = CheckersRules.legalMoves(state) + if (moves.isEmpty()) return -(WIN - ply) + + var localAlpha = alpha + for (move in order(moves, preferred = null)) { + val score = -negamax( + state = CheckersRules.advanced(state, move), + depth = depth - 1, + alpha = -beta, + beta = -localAlpha, + ply = ply + 1, + ) + if (score >= beta) return beta + if (score > localAlpha) localAlpha = score + if (nodesUsed >= nodeBudget) break + } + return localAlpha + } + + /** Longest captures first: they take the most and they cut the most. */ + private fun order(moves: List, preferred: CheckersMove?): List = + moves.sortedByDescending { move -> + when { + move == preferred -> 1_000 + else -> move.steps.size * 10 + } + } + + /** + * The position from the point of view of the side to move. + * + * Material first, then two things that decide checkers games far more than + * beginners expect: **kings are worth much more than men**, and **men are + * worth more the closer they are to being crowned**, so a search with + * nothing else to choose between will push its men up the board rather than + * shuffle along the back. + * + * The back row is worth holding for the opposite reason — an empty back row + * is a road to a crown for the other side — so the two pull against each + * other, which is about right. + */ + private fun evaluate(state: CheckersState): Int { + val us = state.turn + val them = other(us) + var score = 0 + + for (square in 0 until SQUARES) { + val piece = state.board[square] ?: continue + val sign = if (piece.seat == us) 1 else -1 + score += sign * if (piece.king) KING else MAN + + if (!piece.king) { + // How many rows it has come, out of the seven it needs. + val advanced = if (piece.seat == BLACK_SEAT) rowOf(square) else 7 - rowOf(square) + score += sign * advanced * ADVANCE + } + + // Sitting on the edge cannot be jumped, which is worth a little in + // a game whose entire tactic is being jumped. + if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * EDGE + + if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * BACK_ROW + } + + score += MOBILITY * (mobility(state, us) - mobility(state, them)) + return score + } + + /** + * Somewhere to go, counted as piece-to-empty-square steps rather than as + * legal moves: the legal list is only ever the captures when a capture + * exists, and a position is not immobile for being obliged to take. + */ + private fun mobility(state: CheckersState, seat: Int): Int { + var count = 0 + for (square in 0 until SQUARES) { + val piece = state.board[square] ?: continue + if (piece.seat != seat) continue + val directions = if (piece.king) { + listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1) + } else { + listOf(forwardOf(seat) to 1, forwardOf(seat) to -1) + } + for ((rowStep, columnStep) in directions) { + val target = squareAt(rowOf(square) + rowStep, columnOf(square) + columnStep) + if (target != null && state.board[target] == null) count++ + } + } + return count + } + + private companion object { + const val INFINITY = 1_000_000 + const val WIN = 100_000 + const val NODE_BUDGET = 200_000 + + const val MAN = 100 + const val KING = 175 + const val ADVANCE = 6 + const val EDGE = 4 + const val BACK_ROW = 5 + const val MOBILITY = 2 + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/checkers/CheckersModel.kt b/app/src/main/java/org/prolibertate/games/game/checkers/CheckersModel.kt new file mode 100644 index 0000000..d74d24b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/checkers/CheckersModel.kt @@ -0,0 +1,183 @@ +package org.prolibertate.games.game.checkers + +import kotlinx.serialization.Serializable + +/** + * The board is the thirty-two dark squares, numbered 1–32 the way checkers has + * always been written down: square 1 at Black's left-hand end of the top row, + * running left to right and down the board to 32. + * + * Only dark squares are ever played on, so numbering them alone means a move is + * two small numbers rather than two coordinates, the notation the game is + * recorded in falls out for free, and there is no such thing as an illegal + * light square to have to rule out. + * + * Internally the squares are 0-based; [squareName] adds the one. + */ +const val SQUARES = 32 + +/** The row a square sits on, 0 at the top of the board. */ +fun rowOf(square: Int): Int = square / 4 + +/** + * The column, 0 at the left. Dark squares alternate which half of a pair of + * columns they fall in, which is what the odd-row offset is doing. + */ +fun columnOf(square: Int): Int { + val row = rowOf(square) + val within = square % 4 + return within * 2 + if (row % 2 == 0) 1 else 0 +} + +/** The dark square at a row and column, or null if that square is light. */ +fun squareAt(row: Int, column: Int): Int? { + if (row !in 0..7 || column !in 0..7) return null + val offset = if (row % 2 == 0) 1 else 0 + if ((column - offset) % 2 != 0) return null + return row * 4 + (column - offset) / 2 +} + +fun squareName(square: Int): String = "${square + 1}" + +@Serializable +enum class CheckersPhase { PLAYING, GAME_OVER } + +@Serializable +enum class CheckersOutcome(val label: String) { + BLACK_WINS("Black wins"), + WHITE_WINS("White wins"), + DRAW_NO_PROGRESS("A draw — nothing has been taken or crowned for a long time"), + DRAW_REPETITION("A draw by threefold repetition"), +} + +/** How hard the computer thinks about it. */ +@Serializable +enum class CheckersLevel(val label: String, val depth: Int) { + CASUAL("Casual", depth = 3), + CLUB("Club", depth = 5), + STRONG("Strong", depth = 7), +} + +@Serializable +data class CheckersOptions( + val level: CheckersLevel = CheckersLevel.CLUB, + /** + * A king slides any distance along a diagonal and may take from a + * distance, as in international draughts. Off by default: the English game + * this board and notation come from has kings that move one square like + * anybody else, only in both directions. + */ + val flyingKings: Boolean = false, + /** + * A man that reaches the far row is crowned and the turn ends there, even + * with another jump available. This is the English rule; the international + * one lets the jump continue and crowns only if the piece finishes there. + */ + val crowningEndsTheTurn: Boolean = true, + val threefoldRepetition: Boolean = true, + /** Plies with no capture and no crowning before the game is called a draw. */ + val plyLimitWithoutProgress: Int = 80, +) + +/** + * Black moves down the board from row 0, White moves up from row 7. Black plays + * first, as in every published checkers rulebook. + */ +const val BLACK_SEAT = 0 +const val WHITE_SEAT = 1 + +fun other(seat: Int): Int = 1 - seat + +fun seatName(seat: Int): String = if (seat == BLACK_SEAT) "Black" else "White" + +/** Which way a man of this seat advances: down the rows for Black, up for White. */ +fun forwardOf(seat: Int): Int = if (seat == BLACK_SEAT) 1 else -1 + +/** The row a man of this seat is crowned on. */ +fun crownRowOf(seat: Int): Int = if (seat == BLACK_SEAT) 7 else 0 + +@Serializable +data class Piece(val seat: Int, val king: Boolean = false) { + val glyph: String get() = when { + seat == BLACK_SEAT && king -> "BK" + seat == BLACK_SEAT -> "B" + king -> "WK" + else -> "W" + } +} + +/** + * A move: where the piece started, and the squares it landed on in order. + * + * A quiet move has one landing square. A jump has one per hop, so a triple + * capture is one move with three of them rather than three moves — a + * multiple jump is one turn, and splitting it up would let a player stop + * halfway, which the rules do not allow. + */ +@Serializable +data class CheckersMove(val from: Int, val steps: List) { + val to: Int get() = steps.last() + + val isJump: Boolean get() = steps.isNotEmpty() && jumped(from, steps.first()) != null + + override fun toString(): String { + val separator = if (isJump) "x" else "-" + return (listOf(from) + steps).joinToString(separator) { squareName(it) } + } +} + +/** + * The square hopped over going from [from] to [to], or null when the two are + * not two squares apart on a diagonal. + */ +fun jumped(from: Int, to: Int): Int? { + val rowStep = rowOf(to) - rowOf(from) + val columnStep = columnOf(to) - columnOf(from) + if (kotlin.math.abs(rowStep) != 2 || kotlin.math.abs(columnStep) != 2) return null + return squareAt(rowOf(from) + rowStep / 2, columnOf(from) + columnStep / 2) +} + +@Serializable +data class CheckersState( + val options: CheckersOptions, + /** Thirty-two dark squares; null is empty. */ + val board: List, + val turn: Int, + val phase: CheckersPhase, + val outcome: CheckersOutcome?, + val lastMove: CheckersMove?, + /** Plies since anything was taken or crowned. */ + val pliesSinceProgress: Int, + val repetitionKeys: List, + val moveLog: List, +) { + fun count(seat: Int): Int = board.count { it?.seat == seat } + + fun kings(seat: Int): Int = board.count { it?.seat == seat && it.king } + + fun positionKey(): String = buildString { + for (square in 0 until SQUARES) { + append( + when (val piece = board[square]) { + null -> '.' + else -> when { + piece.seat == BLACK_SEAT && piece.king -> 'K' + piece.seat == BLACK_SEAT -> 'b' + piece.king -> 'Q' + else -> 'w' + } + } + ) + } + append(' ').append(turn) + } +} + +/** The opening array: twelve a side on the three rows nearest each player. */ +fun startingBoard(): List = (0 until SQUARES).map { square -> + when (rowOf(square)) { + 0, 1, 2 -> Piece(BLACK_SEAT) + 5, 6, 7 -> Piece(WHITE_SEAT) + else -> null + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/checkers/CheckersRules.kt b/app/src/main/java/org/prolibertate/games/game/checkers/CheckersRules.kt new file mode 100644 index 0000000..fd16ddf --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/checkers/CheckersRules.kt @@ -0,0 +1,372 @@ +package org.prolibertate.games.game.checkers + +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 + +/** + * Checkers — English draughts — as a pure state machine. See RULES-checkers.md. + * + * Two rules shape everything else here. **Captures are compulsory**, so the + * move generator produces the jumps and throws the quiet moves away whenever + * there is a jump to be had; and **a multiple jump is one turn**, so a + * sequence of hops is one move object rather than several. A player cannot stop + * halfway through a capture, and building it as one move is what makes that + * true by construction rather than by a rule somewhere that has to be enforced. + */ +object CheckersRules : GameRules { + + override val gameId: String = GameCatalog.CHECKERS + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val DIAGONALS = listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1) + + override fun initialState(config: TableConfig): CheckersState { + require(config.seats.size == 2) { "Checkers is a two-handed game" } + val options = json.decodeFromString(config.optionsJson) + return initialState(options) + } + + fun initialState(options: CheckersOptions = CheckersOptions()): CheckersState = CheckersState( + options = options, + board = startingBoard(), + turn = BLACK_SEAT, + phase = CheckersPhase.PLAYING, + outcome = null, + lastMove = null, + pliesSinceProgress = 0, + repetitionKeys = emptyList(), + moveLog = emptyList(), + ).let { start -> start.copy(repetitionKeys = listOf(start.positionKey())) } + + override fun currentSeat(state: CheckersState): Int? = + if (state.phase == CheckersPhase.GAME_OVER) null else state.turn + + // ----------------------------------------------------------------------- + // Moves + // ----------------------------------------------------------------------- + + fun legalMoves(state: CheckersState): List = legalMoves(state, state.turn) + + override fun legalMoves(state: CheckersState, seat: Int): List { + if (state.phase == CheckersPhase.GAME_OVER || state.turn != seat) return emptyList() + + val mine = (0 until SQUARES).filter { state.board[it]?.seat == seat } + + val jumps = mine.flatMap { square -> + val piece = state.board[square]!! + // The piece is lifted before its own jumps are worked out, so the + // square it came from counts as empty for the rest of the sequence + // — a jump can and often does end back where it started. + val board = state.board.toMutableList() + board[square] = null + jumpsFrom(state.options, board, square, piece).map { + CheckersMove(from = square, steps = it) + } + } + + // Compulsory capture: a quiet move is only a move when nothing at all + // can be taken, anywhere on the board. + if (jumps.isNotEmpty()) return jumps + + return mine.flatMap { square -> + slidesFrom(state.options, state.board, square, state.board[square]!!).map { + CheckersMove(from = square, steps = listOf(it)) + } + } + } + + /** Which diagonals a piece may travel along: both ways for a king, forwards for a man. */ + private fun directionsFor(piece: Piece): List> = + if (piece.king) DIAGONALS else DIAGONALS.filter { it.first == forwardOf(piece.seat) } + + /** Quiet moves — one square, or any distance for a flying king. */ + private fun slidesFrom( + options: CheckersOptions, + board: List, + from: Int, + piece: Piece, + ): List = directionsFor(piece).flatMap { (rowStep, columnStep) -> + val reach = if (piece.king && options.flyingKings) 7 else 1 + val landings = mutableListOf() + for (distance in 1..reach) { + val square = squareAt( + rowOf(from) + rowStep * distance, + columnOf(from) + columnStep * distance, + ) ?: break + if (board[square] != null) break + landings += square + } + landings + } + + /** + * Every complete jump sequence from [at], as lists of landing squares. + * + * A sequence only ends where no further jump is available: a player may not + * stop a capture halfway, so a shorter prefix of a longer jump is never + * offered as a move of its own. + * + * [board] is mutated as it recurses and put back afterwards, which is what + * stops the same piece being taken twice in one turn. + */ + private fun jumpsFrom( + options: CheckersOptions, + board: MutableList, + at: Int, + piece: Piece, + ): List> { + val sequences = mutableListOf>() + + for ((rowStep, columnStep) in directionsFor(piece)) { + val hops = if (piece.king && options.flyingKings) { + longJumps(board, at, piece, rowStep, columnStep) + } else { + shortJump(board, at, piece, rowStep, columnStep) + } + + for ((over, landing) in hops) { + val taken = board[over] + board[over] = null + + // A man reaching the crown row stops there under the English + // rule and is crowned; under the other it carries on as a man + // and is only crowned if it finishes there. + val crowning = !piece.king && rowOf(landing) == crownRowOf(piece.seat) + val continuations = if (crowning && options.crowningEndsTheTurn) { + emptyList() + } else { + jumpsFrom(options, board, landing, piece) + } + + board[over] = taken + + if (continuations.isEmpty()) { + sequences += listOf(landing) + } else { + continuations.forEach { sequences += listOf(landing) + it } + } + } + } + + return sequences + } + + /** The one hop a man or a short king can make in a direction, if any. */ + private fun shortJump( + board: List, + at: Int, + piece: Piece, + rowStep: Int, + columnStep: Int, + ): List> { + val over = squareAt(rowOf(at) + rowStep, columnOf(at) + columnStep) ?: return emptyList() + val landing = squareAt(rowOf(at) + rowStep * 2, columnOf(at) + columnStep * 2) + ?: return emptyList() + val victim = board[over] ?: return emptyList() + if (victim.seat == piece.seat) return emptyList() + if (board[landing] != null) return emptyList() + return listOf(over to landing) + } + + /** + * A flying king's captures: the first piece along the diagonal, if it is an + * enemy, taken by landing on any empty square beyond it. + * + * Note that a captured piece is lifted at once rather than left standing + * until the turn ends. International draughts leaves it there as an + * obstacle, which occasionally forbids a sequence this allows; the + * difference is written down in the rules document rather than pretended + * away. + */ + private fun longJumps( + board: List, + at: Int, + piece: Piece, + rowStep: Int, + columnStep: Int, + ): List> { + var distance = 1 + val over: Int + while (true) { + val square = squareAt( + rowOf(at) + rowStep * distance, + columnOf(at) + columnStep * distance, + ) ?: return emptyList() + val occupant = board[square] + if (occupant != null) { + if (occupant.seat == piece.seat) return emptyList() + over = square + break + } + distance++ + } + + val landings = mutableListOf>() + var beyond = distance + 1 + while (true) { + val square = squareAt( + rowOf(at) + rowStep * beyond, + columnOf(at) + columnStep * beyond, + ) ?: break + if (board[square] != null) break + landings += over to square + beyond++ + } + return landings + } + + override fun applyMove(state: CheckersState, seat: Int, move: CheckersMove): CheckersState { + require(state.phase != CheckersPhase.GAME_OVER) { "The game is over" } + require(state.turn == seat) { "It is not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + + val next = make(state, seat, move) + return next + .copy( + repetitionKeys = state.repetitionKeys + next.positionKey(), + moveLog = state.moveLog + "${seatName(seat)} $move", + ) + .withTerminalCheck(includeDraws = true) + } + + /** The position after [move], with the draw bookkeeping left off, for the search. */ + fun advanced(state: CheckersState, move: CheckersMove): CheckersState = + make(state, state.turn, move).withTerminalCheck(includeDraws = false) + + private fun make(state: CheckersState, seat: Int, move: CheckersMove): CheckersState { + val board = state.board.toMutableList() + val piece = requireNotNull(board[move.from]) { "no piece on ${squareName(move.from)}" } + board[move.from] = null + + var taken = 0 + var at = move.from + for (landing in move.steps) { + jumped(at, landing)?.let { over -> + if (board[over] != null) { + board[over] = null + taken++ + } + } ?: run { + // A flying king's capture is further than a single hop, so the + // square it took is found by walking the line rather than by + // halving the distance. + if (state.options.flyingKings && piece.king) { + victimBetween(board, at, landing)?.let { over -> + board[over] = null + taken++ + } + } + } + at = landing + } + + val crowned = !piece.king && rowOf(move.to) == crownRowOf(seat) + board[move.to] = if (crowned) piece.copy(king = true) else piece + + return state.copy( + board = board, + turn = other(seat), + lastMove = move, + // Taking a piece or crowning one is progress; shuffling kings + // around is what the draw rule is there to stop. + pliesSinceProgress = if (taken > 0 || crowned) 0 else state.pliesSinceProgress + 1, + ) + } + + /** The single enemy piece standing between two squares on a diagonal. */ + private fun victimBetween(board: List, from: Int, to: Int): Int? { + val rowStep = (rowOf(to) - rowOf(from)).let { if (it > 0) 1 else -1 } + val columnStep = (columnOf(to) - columnOf(from)).let { if (it > 0) 1 else -1 } + var distance = 1 + while (true) { + val square = squareAt( + rowOf(from) + rowStep * distance, + columnOf(from) + columnStep * distance, + ) ?: return null + if (square == to) return null + if (board[square] != null) return square + distance++ + } + } + + /** + * Whether the game has just ended, from the point of view of the player who + * is now to move: they are the only one who can have lost pieces or run out + * of squares on the ply just played. + */ + private fun CheckersState.withTerminalCheck(includeDraws: Boolean): CheckersState { + val loser = turn + + // No pieces, or no move: both are a loss, and they are the same + // condition as far as the rules are concerned. + if (legalMoves(this, loser).isEmpty()) { + return copy( + phase = CheckersPhase.GAME_OVER, + outcome = if (loser == BLACK_SEAT) { + CheckersOutcome.WHITE_WINS + } else { + CheckersOutcome.BLACK_WINS + }, + ) + } + + if (!includeDraws) return this + + if (options.plyLimitWithoutProgress > 0 && + pliesSinceProgress >= options.plyLimitWithoutProgress + ) { + return copy( + phase = CheckersPhase.GAME_OVER, + outcome = CheckersOutcome.DRAW_NO_PROGRESS, + ) + } + + if (options.threefoldRepetition) { + val here = repetitionKeys.lastOrNull() + if (here != null && repetitionKeys.count { it == here } >= 3) { + return copy( + phase = CheckersPhase.GAME_OVER, + outcome = CheckersOutcome.DRAW_REPETITION, + ) + } + } + + return this + } + + // ----------------------------------------------------------------------- + // Engine contract + // ----------------------------------------------------------------------- + + override fun isFinished(state: CheckersState): Boolean = + state.phase == CheckersPhase.GAME_OVER + + override fun summary(state: CheckersState): String = state.outcome?.label + ?: "${seatName(state.turn)} to move" + + /** Checkers hides nothing: both players see the same board. */ + override fun viewFor(state: CheckersState, seat: Int): CheckersState = state + + override fun encodeState(state: CheckersState): String = json.encodeToString(state) + override fun decodeState(json: String): CheckersState = this.json.decodeFromString(json) + override fun encodeMove(move: CheckersMove): String = json.encodeToString(move) + override fun decodeMove(json: String): CheckersMove = this.json.decodeFromString(json) + + /** Leaf nodes at [depth]. The single most useful check on a move generator. */ + fun perft(state: CheckersState, depth: Int): Long { + if (depth == 0) return 1L + val moves = legalMoves(state) + if (depth == 1) return moves.size.toLong() + return moves.sumOf { move -> + val next = advanced(state, move) + if (next.phase == CheckersPhase.GAME_OVER) 1L else perft(next, depth - 1) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt new file mode 100644 index 0000000..ca339c5 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt @@ -0,0 +1,92 @@ +package org.prolibertate.games.game.mastermind + +import kotlin.random.Random +import org.prolibertate.games.game.engine.GameAi + +/** + * A code breaker. + * + * There is no tree to search here — the opponent makes no reply, and a guess + * cannot be answered badly — so the whole game is in choosing which code to try + * next. Three ways of choosing it, in rising order of how much it thinks: + * + * - **Casual** takes anything untried, ruled out or not. + * - **Club** never guesses a code the answers have already eliminated. That one + * rule is most of the strength. + * - **Strong** is Knuth's idea one ply deep: among the codes still standing, it + * plays the one whose *worst* possible answer leaves the smallest number of + * candidates behind, so it cannot be given a reply that tells it little. + */ +class MastermindAi(private val level: MastermindLevel? = null) : + GameAi { + + override fun chooseMove( + state: MastermindState, + seat: Int, + legal: List, + ): MastermindMove { + require(legal.isNotEmpty()) { "No legal guess for seat $seat" } + val setting = level ?: state.options.level + + // Deterministic for a given position, so a host and a client watching + // the same game agree on what the computer did. + val random = Random(seat * 7919L + state.guesses[seat].size * 31L + state.options.hashCode()) + + if (setting == MastermindLevel.CASUAL) return legal[random.nextInt(legal.size)] + + val standing = MastermindRules.consistentCodes(state, seat) + .filterNot { code -> state.guesses[seat].any { it.code == code } } + // Nothing consistent left means the answers contradict each other, + // which cannot happen in a game this code scored — but a guess still + // has to be returned rather than an exception. + if (standing.isEmpty()) return legal[random.nextInt(legal.size)] + if (standing.size == 1) return MastermindMove(standing.single()) + + if (setting == MastermindLevel.CLUB) { + return MastermindMove(standing[random.nextInt(standing.size)]) + } + + return MastermindMove(minimax(standing, random)) + } + + /** + * The candidate whose worst reply leaves the fewest codes standing. + * + * Both lists are sampled when they are large. The full calculation is every + * candidate against every survivor, which on a fresh six-colour game is + * more than a million scorings — a second or two of a phone's time for a + * choice that a sample of them makes just as well. + */ + private fun minimax(standing: List>, random: Random): List { + val candidates = sample(standing, CANDIDATES, random) + val against = sample(standing, OPPONENTS, random) + + var best = candidates.first() + var bestWorst = Int.MAX_VALUE + for (candidate in candidates) { + val partitions = mutableMapOf() + for (possible in against) { + val answer = scoreGuess(possible, candidate) + partitions[answer] = (partitions[answer] ?: 0) + 1 + } + val worst = partitions.values.max() + if (worst < bestWorst) { + bestWorst = worst + best = candidate + } + } + return best + } + + private fun sample(codes: List>, limit: Int, random: Random): List> { + if (codes.size <= limit) return codes + val picked = LinkedHashSet() + while (picked.size < limit) picked += random.nextInt(codes.size) + return picked.map { codes[it] } + } + + private companion object { + const val CANDIDATES = 80 + const val OPPONENTS = 400 + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindModel.kt b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindModel.kt new file mode 100644 index 0000000..ac12803 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindModel.kt @@ -0,0 +1,170 @@ +package org.prolibertate.games.game.mastermind + +import kotlinx.serialization.Serializable + +/** + * Mastermind as a duel. + * + * The pencil-and-paper game is one person setting a code and another breaking + * it, which makes a poor two-handed game: one player would sit and watch. Here + * **both** players are set a code and both are breaking one, a guess each in + * turn, and whoever cracks theirs first wins. + * + * That symmetry is what makes it fair to end the game at the foot of a round + * rather than the moment somebody gets it: if the first player cracks the code + * on their sixth guess, the second player still gets a sixth guess to equal it, + * and equalling it is a draw. + */ +@Serializable +data class MastermindOptions( + /** How many colours the code is drawn from. */ + val colours: Int = 6, + /** How many pegs long the code is. */ + val length: Int = 4, + /** Whether a colour may appear more than once in the code. */ + val allowDuplicates: Boolean = true, + /** Guesses each. Running out with neither code broken is a draw. */ + val maxGuesses: Int = 10, + val level: MastermindLevel = MastermindLevel.CLUB, +) { + /** How many codes are possible, which is what a guess is chosen from. */ + fun codeSpace(): Int = if (allowDuplicates) { + var total = 1 + repeat(length) { total *= colours } + total + } else { + var total = 1 + for (step in 0 until length) total *= (colours - step).coerceAtLeast(0) + total + } +} + +/** How hard the computer thinks about it. */ +@Serializable +enum class MastermindLevel(val label: String) { + /** + * Guesses anything it has not tried, whether or not the answers so far rule + * it out. Beatable by anybody who is paying attention. + */ + CASUAL("Casual"), + + /** + * Never guesses a code the answers have already ruled out. That alone is a + * respectable player and cracks a four-peg, six-colour code in about five. + */ + CLUB("Club"), + + /** + * Consistent, and among the consistent codes it picks the one whose worst + * answer leaves the fewest possibilities standing — a one-ply minimax over + * the replies, which is Knuth's idea without the full lookahead. + */ + STRONG("Strong"), +} + +/** + * The answer to a guess: how many pegs are the right colour in the right place, + * and how many are the right colour somewhere else. + * + * The two counts never double-count a peg. That is the whole subtlety of + * scoring Mastermind, and it is why [scoreGuess] counts colours rather than + * walking pairs. + */ +@Serializable +data class Feedback(val exact: Int, val misplaced: Int) { + override fun toString(): String = "$exact·$misplaced" +} + +@Serializable +data class Guess(val code: List, val feedback: Feedback) + +/** + * Scores [guess] against [secret]. + * + * Exact matches are counted first and taken out; what is left is compared by + * colour count, so three reds guessed against one red can only ever be worth + * one peg however they are arranged. + */ +fun scoreGuess(secret: List, guess: List): Feedback { + require(secret.size == guess.size) { "a guess must be as long as the code" } + var exact = 0 + val secretLeft = mutableListOf() + val guessLeft = mutableListOf() + for (index in secret.indices) { + if (secret[index] == guess[index]) { + exact++ + } else { + secretLeft += secret[index] + guessLeft += guess[index] + } + } + val counts = secretLeft.groupingBy { it }.eachCount().toMutableMap() + var misplaced = 0 + for (colour in guessLeft) { + val left = counts[colour] ?: 0 + if (left > 0) { + misplaced++ + counts[colour] = left - 1 + } + } + return Feedback(exact = exact, misplaced = misplaced) +} + +/** Every code the options allow, in a fixed order. */ +fun allCodes(options: MastermindOptions): List> { + var codes = listOf(emptyList()) + repeat(options.length) { + codes = codes.flatMap { prefix -> + (0 until options.colours) + .filter { options.allowDuplicates || it !in prefix } + .map { prefix + it } + } + } + return codes +} + +@Serializable +enum class MastermindPhase { GUESSING, GAME_OVER } + +@Serializable +enum class MastermindOutcome(val label: String) { + FIRST_WINS("Broken first by the player who opened"), + SECOND_WINS("Broken first by the player who replied"), + DRAW_BOTH("A draw — both codes broken in the same number of guesses"), + DRAW_NEITHER("A draw — neither code was broken in time"), +} + +@Serializable +data class MastermindState( + val options: MastermindOptions, + /** + * The code each seat is guarding, which is the one their opponent is trying + * to break. Redacted before this ever leaves the host: see + * [MastermindRules.viewFor]. + */ + val secrets: List>, + /** What each seat has guessed at their opponent's code, oldest first. */ + val guesses: List>, + val turn: Int, + val phase: MastermindPhase, + val outcome: MastermindOutcome?, +) { + fun broke(seat: Int): Boolean = + guesses[seat].any { it.feedback.exact == options.length } + + /** How many guesses it took, or null if this seat has not cracked it. */ + fun brokeIn(seat: Int): Int? = guesses[seat] + .indexOfFirst { it.feedback.exact == options.length } + .takeIf { it >= 0 } + ?.plus(1) + + fun guessesLeft(seat: Int): Int = options.maxGuesses - guesses[seat].size + + /** True where the secret has been stripped out for a client's view. */ + fun isHidden(seat: Int): Boolean = secrets[seat].isEmpty() +} + +const val FIRST_SEAT = 0 +const val SECOND_SEAT = 1 + +fun other(seat: Int): Int = 1 - seat diff --git a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindRules.kt b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindRules.kt new file mode 100644 index 0000000..3936276 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindRules.kt @@ -0,0 +1,172 @@ +package org.prolibertate.games.game.mastermind + +import kotlin.random.Random +import kotlinx.serialization.Serializable +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 + +/** A guess at the opponent's code. */ +@Serializable +data class MastermindMove(val code: List) { + override fun toString(): String = code.joinToString("") { ('A' + it).toString() } +} + +/** + * Mastermind as a state machine. See RULES-mastermind.md. + * + * This is the one game here with anything to hide, so [viewFor] does real work: + * the host holds both codes and sends each device only its own, which means an + * opponent's code is never on the wire in the first place. Nothing else in this + * file may leak it either — note that [summary] talks about counts rather than + * colours. + */ +object MastermindRules : GameRules { + + override val gameId: String = GameCatalog.MASTERMIND + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): MastermindState { + require(config.seats.size == 2) { "Mastermind is a two-handed game" } + val options = json.decodeFromString(config.optionsJson) + return initialState(options, config.seed) + } + + fun initialState(options: MastermindOptions, seed: Long = 0L): MastermindState { + require(options.length >= 1) { "a code needs at least one peg" } + require(options.colours >= 2) { "a code needs at least two colours" } + require(options.allowDuplicates || options.colours >= options.length) { + "${options.length} different colours cannot be drawn from ${options.colours}" + } + val random = Random(seed) + return MastermindState( + options = options, + // Both codes come off the table's own seed, so a game can be + // replayed exactly from the config it started with. + secrets = listOf(secretFrom(options, random), secretFrom(options, random)), + guesses = listOf(emptyList(), emptyList()), + turn = FIRST_SEAT, + phase = MastermindPhase.GUESSING, + outcome = null, + ) + } + + private fun secretFrom(options: MastermindOptions, random: Random): List { + if (options.allowDuplicates) { + return List(options.length) { random.nextInt(options.colours) } + } + val bag = (0 until options.colours).toMutableList() + return List(options.length) { bag.removeAt(random.nextInt(bag.size)) } + } + + override fun currentSeat(state: MastermindState): Int? = + if (state.phase == MastermindPhase.GAME_OVER) null else state.turn + + /** + * Every code this seat has not already tried. + * + * Repeating a guess is not illegal by the rules of the game, but it can + * only ever waste a turn, and leaving it out keeps the computer from + * wasting one. + */ + override fun legalMoves(state: MastermindState, seat: Int): List { + if (state.phase == MastermindPhase.GAME_OVER || state.turn != seat) return emptyList() + val tried = state.guesses[seat].map { it.code }.toSet() + return allCodes(state.options).filterNot { it in tried }.map { MastermindMove(it) } + } + + override fun applyMove( + state: MastermindState, + seat: Int, + move: MastermindMove, + ): MastermindState { + require(state.phase != MastermindPhase.GAME_OVER) { "The game is over" } + require(state.turn == seat) { "It is not seat $seat's turn" } + require(move.code.size == state.options.length) { + "a guess is ${state.options.length} pegs long" + } + require(move.code.all { it in 0 until state.options.colours }) { + "that colour is not in this game" + } + require(state.guesses[seat].none { it.code == move.code }) { + "that guess has been made already" + } + val secret = state.secrets[other(seat)] + require(secret.isNotEmpty()) { + "this device does not hold that code — a guess is scored by the host" + } + + val scored = Guess(code = move.code, feedback = scoreGuess(secret, move.code)) + val guesses = state.guesses.toMutableList() + guesses[seat] = guesses[seat] + scored + + return state.copy(guesses = guesses, turn = other(seat)).withTerminalCheck() + } + + /** + * The game ends at the foot of a round, never in the middle of one. + * + * Both players have had the same number of guesses only when the turn has + * come back round to the opener, so that is the only place it is fair to + * compare them — otherwise opening would be worth half a guess. + */ + private fun MastermindState.withTerminalCheck(): MastermindState { + if (turn != FIRST_SEAT) return this + + val first = broke(FIRST_SEAT) + val second = broke(SECOND_SEAT) + val outcome = when { + first && second -> MastermindOutcome.DRAW_BOTH + first -> MastermindOutcome.FIRST_WINS + second -> MastermindOutcome.SECOND_WINS + guessesLeft(FIRST_SEAT) <= 0 && guessesLeft(SECOND_SEAT) <= 0 -> + MastermindOutcome.DRAW_NEITHER + + else -> return this + } + return copy(phase = MastermindPhase.GAME_OVER, outcome = outcome) + } + + override fun isFinished(state: MastermindState): Boolean = + state.phase == MastermindPhase.GAME_OVER + + override fun summary(state: MastermindState): String = state.outcome?.label + ?: "Guess ${state.guesses[state.turn].size + 1} of ${state.options.maxGuesses}" + + /** + * Strips out the code this seat is not entitled to see. + * + * A seat keeps its own code — it is theirs to guard, and the screen shows + * it once the game is over — and loses the one it is trying to break. The + * feedback on every guess stays, because that is the game. + * + * Once the game is finished both codes are shown: there is nothing left to + * protect, and a code breaker wants to see what they were up against. + */ + override fun viewFor(state: MastermindState, seat: Int): MastermindState { + if (state.phase == MastermindPhase.GAME_OVER) return state + val secrets = state.secrets.mapIndexed { index, code -> + if (index == seat) code else emptyList() + } + return state.copy(secrets = secrets) + } + + override fun encodeState(state: MastermindState): String = json.encodeToString(state) + override fun decodeState(json: String): MastermindState = this.json.decodeFromString(json) + override fun encodeMove(move: MastermindMove): String = json.encodeToString(move) + override fun decodeMove(json: String): MastermindMove = this.json.decodeFromString(json) + + /** Codes still consistent with everything [seat] has been told so far. */ + fun consistentCodes(state: MastermindState, seat: Int): List> { + val answers = state.guesses[seat] + return allCodes(state.options).filter { candidate -> + answers.all { scoreGuess(candidate, it.code) == it.feedback } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/pirates/PiratesAi.kt b/app/src/main/java/org/prolibertate/games/game/pirates/PiratesAi.kt new file mode 100644 index 0000000..a2c7ea4 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pirates/PiratesAi.kt @@ -0,0 +1,170 @@ +package org.prolibertate.games.game.pirates + +import kotlin.random.Random +import org.prolibertate.games.game.engine.GameAi + +/** + * A negamax search with alpha-beta pruning and iterative deepening, over the + * same rules engine the game runs on. + * + * The interesting part of a hunt game is that the two sides want opposite + * shapes out of the same board, and one evaluation has to speak for both. The + * pirates want Bulgars gone and room to move; the Bulgars want to be many, to + * be forward, and above all not to be jumpable. The terms below are written + * from the Bulgars' side and negated for the pirates, so the two cannot drift + * apart and start valuing the same position differently. + */ +class PiratesAi(private val level: PiratesLevel? = null) : GameAi { + + private var nodeBudget = 0 + private var nodesUsed = 0 + + override fun chooseMove(state: PiratesState, seat: Int, legal: List): PiratesMove { + 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 + + val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode()) + + var best = legal.first() + var scored = legal.map { it to 0 } + + for (depth in 1..setting.depth) { + val results = mutableListOf>() + val ordered = order(legal, preferred = best) + for (move in ordered) { + val score = -negamax( + state = PiratesRules.advanced(state, move), + depth = depth - 1, + alpha = -INFINITY, + beta = INFINITY, + ply = 1, + ) + results += move to score + if (nodesUsed >= nodeBudget) break + } + if (nodesUsed >= nodeBudget && results.size < ordered.size) break + scored = results + best = results.maxBy { it.second }.first + } + + val bestScore = scored.maxOf { it.second } + val acceptable = scored.filter { it.second >= bestScore - setting.slack() } + return acceptable[random.nextInt(acceptable.size)].first + } + + private fun PiratesLevel.slack(): Int = when (this) { + PiratesLevel.CASUAL -> 60 + PiratesLevel.CLUB -> 15 + PiratesLevel.STRONG -> 0 + } + + private fun negamax(state: PiratesState, depth: Int, alpha: Int, beta: Int, ply: Int): Int { + nodesUsed++ + + if (state.phase == PiratesPhase.GAME_OVER) { + return when (state.outcome) { + PiratesOutcome.DRAW_STALEMATE, null -> 0 + // A finished game leaves the loser on the clock in two of the + // three endings; the third — the stronghold filled — is a win + // for the side that has just moved, so it is read off the + // outcome rather than assumed. + PiratesOutcome.BULGARS_STORM -> + if (state.turn == BULGAR_SEAT) WIN - ply else -(WIN - ply) + + else -> -(WIN - ply) + } + } + if (depth <= 0) return evaluate(state) + + val moves = PiratesRules.legalMoves(state) + if (moves.isEmpty()) return -(WIN - ply) + + var localAlpha = alpha + for (move in order(moves, preferred = null)) { + val score = -negamax( + state = PiratesRules.advanced(state, move), + depth = depth - 1, + alpha = -beta, + beta = -localAlpha, + ply = ply + 1, + ) + if (score >= beta) return beta + if (score > localAlpha) localAlpha = score + if (nodesUsed >= nodeBudget) break + } + return localAlpha + } + + /** Captures first, then moves into the stronghold: the two things that change anything. */ + private fun order(moves: List, preferred: PiratesMove?): List = + moves.sortedByDescending { move -> + when { + move == preferred -> 1_000 + move.steps.size > 1 -> 200 + isJumpFrom(move.from, move.steps.first()) -> 100 + isStronghold(move.to) -> 20 + else -> 0 + } + } + + /** The position from the point of view of the side to move. */ + private fun evaluate(state: PiratesState): Int { + var bulgarView = 0 + + bulgarView += state.count(BULGAR_SEAT) * BULGAR + bulgarView += state.stronghold() * IN_STRONGHOLD + + // How far up the board the crowd has come. The stronghold is at the + // top, so a low row number is ground gained. + for (point in 0 until POINTS) { + if (state.board[point] != BULGAR_SEAT) continue + bulgarView += (GRID - 1 - rowOf(point)) * ADVANCE + // A Bulgar with an empty point directly behind it on a line is one + // a pirate can jump. Being unjumpable is most of staying alive. + if (isExposed(state, point)) bulgarView -= EXPOSED + } + + // Room for the pirates to work in, which is what the Bulgars are + // slowly taking away. + bulgarView -= pirateMobility(state) * PIRATE_ROOM + + return if (state.turn == BULGAR_SEAT) bulgarView else -bulgarView + } + + /** + * Whether a pirate could jump this Bulgar: a pirate on one side of it along + * a line, and an empty point on the other. + */ + private fun isExposed(state: PiratesState, point: Int): Boolean { + for (neighbour in ADJACENCY[point]) { + if (state.board[neighbour] != PIRATE_SEAT) continue + val landing = pointAt( + rowOf(point) + (rowOf(point) - rowOf(neighbour)), + columnOf(point) + (columnOf(point) - columnOf(neighbour)), + ) ?: continue + if (landing in ADJACENCY[point] && state.board[landing] == null) return true + } + return false + } + + private fun pirateMobility(state: PiratesState): Int = + (0 until POINTS) + .filter { state.board[it] == PIRATE_SEAT } + .sumOf { point -> ADJACENCY[point].count { state.board[it] == null } } + + private companion object { + const val INFINITY = 1_000_000 + const val WIN = 100_000 + const val NODE_BUDGET = 150_000 + + const val BULGAR = 100 + const val IN_STRONGHOLD = 55 + const val ADVANCE = 4 + const val EXPOSED = 22 + const val PIRATE_ROOM = 3 + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/pirates/PiratesModel.kt b/app/src/main/java/org/prolibertate/games/game/pirates/PiratesModel.kt new file mode 100644 index 0000000..e5ee377 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pirates/PiratesModel.kt @@ -0,0 +1,201 @@ +package org.prolibertate.games.game.pirates + +import kotlinx.serialization.Serializable + +/** + * Dalmatian Pirates and the Volga Bulgars — Sid Sackson's name for the hunt + * game usually called **Asalto**, and before that a cousin of Fox and Geese. + * + * Two pirates hold a stronghold at the head of a cross-shaped board. Twenty-four + * Bulgars come at it from the other three arms, and cannot take anything: their + * whole method is weight of numbers. The pirates cannot be driven off by force + * either — they are taken only by being penned in — so each side is trying to + * do something the other cannot do back. + * + * See RULES-pirates.md, which is honest about which parts of this are attested + * and which had to be chosen: the book the theme comes from could not be + * obtained, so the mechanics here are Asalto's. + * + * The board is a seven-by-seven grid with the four corners cut away, leaving + * thirty-three points. Points are numbered in reading order. + */ +const val GRID = 7 + +/** True where the cross-shaped board has a point at all. */ +fun isOnBoard(row: Int, column: Int): Boolean = + row in 0 until GRID && column in 0 until GRID && (row in 2..4 || column in 2..4) + +/** The thirty-three points, in reading order. */ +val CELLS: List> = (0 until GRID).flatMap { row -> + (0 until GRID).mapNotNull { column -> (row to column).takeIf { isOnBoard(row, column) } } +} + +val POINTS = CELLS.size + +fun rowOf(point: Int): Int = CELLS[point].first + +fun columnOf(point: Int): Int = CELLS[point].second + +fun pointAt(row: Int, column: Int): Int? = + if (isOnBoard(row, column)) CELLS.indexOf(row to column).takeIf { it >= 0 } else null + +/** + * The stronghold: the nine points of the top arm. + * + * Twenty-four points are left outside it, which is exactly the number of + * Bulgars — the board and the pieces are cut to each other, and the game opens + * with every point occupied but the seven the pirates are not standing on. + */ +val STRONGHOLD: List = (0 until POINTS).filter { rowOf(it) <= 2 && columnOf(it) in 2..4 } + +fun isStronghold(point: Int): Boolean = point in STRONGHOLD + +/** + * The lines drawn on the board. + * + * Orthogonal neighbours are always joined. Diagonals are drawn only where the + * row and column add to an even number, which is the pattern printed on every + * board of this family — it is why the centre of the board has eight lines out + * of it and the point beside it has four. + */ +val ADJACENCY: List> = (0 until POINTS).map { point -> + val row = rowOf(point) + val column = columnOf(point) + val steps = if ((row + column) % 2 == 0) { + listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0, 1 to 1, 1 to -1, -1 to 1, -1 to -1) + } else { + listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0) + } + steps.mapNotNull { (rowStep, columnStep) -> pointAt(row + rowStep, column + columnStep) } +} + +/** Seat 0 is the crowd, seat 1 the pair. */ +const val BULGAR_SEAT = 0 +const val PIRATE_SEAT = 1 + +fun other(seat: Int): Int = 1 - seat + +fun seatName(seat: Int): String = if (seat == BULGAR_SEAT) "the Bulgars" else "the pirates" + +/** The point's name: files a–g left to right, ranks 7–1 top to bottom. */ +fun pointName(point: Int): String = "${'a' + columnOf(point)}${GRID - rowOf(point)}" + +fun pointFromName(name: String): Int? = + pointAt(GRID - (name[1] - '0'), name[0] - 'a') + +@Serializable +enum class PiratesPhase { PLAYING, GAME_OVER } + +@Serializable +enum class PiratesOutcome(val label: String) { + BULGARS_STORM("The Bulgars win — the stronghold is full of them"), + BULGARS_PEN_IN("The Bulgars win — the pirates have nowhere left to go"), + PIRATES_CUT_DOWN("The pirates win — too few Bulgars are left to fill the stronghold"), + DRAW_STALEMATE("A draw — neither side has got anywhere in a long time"), +} + +/** How hard the computer thinks about it. */ +@Serializable +enum class PiratesLevel(val label: String, val depth: Int) { + CASUAL("Casual", depth = 2), + CLUB("Club", depth = 4), + STRONG("Strong", depth = 6), +} + +@Serializable +data class PiratesOptions( + val level: PiratesLevel = PiratesLevel.CLUB, + /** + * A pirate who can take must take. The traditional way of enforcing this is + * *huffing* — the other player removes a pirate that failed to capture — + * which is the same rule with an extra step and a way to forget to use it. + */ + val captureIsCompulsory: Boolean = true, + /** + * Bulgars may not retreat: they move towards the stronghold or across, and + * never back down the board. Turning this off makes them far harder to + * pin, and much less like the game. + */ + val bulgarsMayNotRetreat: Boolean = true, + /** Plies with nothing taken and no ground gained before it is called a draw. */ + val plyLimitWithoutProgress: Int = 60, +) + +/** + * A move: where a piece started and where it landed, one entry per hop. + * + * A Bulgar's move is always a single step. A pirate's may be a chain of jumps, + * which is one turn and one move object — a capture cannot be abandoned + * halfway. + */ +@Serializable +data class PiratesMove(val from: Int, val steps: List) { + val to: Int get() = steps.last() + + override fun toString(): String { + val separator = if (steps.size > 1 || isJumpFrom(from, steps.first())) "x" else "-" + return (listOf(from) + steps).joinToString(separator) { pointName(it) } + } +} + +/** Whether two points are a jump apart: two steps along one line. */ +fun isJumpFrom(from: Int, to: Int): Boolean = jumpedBetween(from, to) != null + +/** The point hopped over, or null when the two are not two steps apart on a line. */ +fun jumpedBetween(from: Int, to: Int): Int? { + val rowStep = rowOf(to) - rowOf(from) + val columnStep = columnOf(to) - columnOf(from) + if (kotlin.math.abs(rowStep) > 2 || kotlin.math.abs(columnStep) > 2) return null + if (kotlin.math.abs(rowStep) != 2 && kotlin.math.abs(columnStep) != 2) return null + // A jump must be along a line, so the two halves have to be steps of one. + if (rowStep % 2 != 0 || columnStep % 2 != 0) return null + val middle = pointAt(rowOf(from) + rowStep / 2, columnOf(from) + columnStep / 2) ?: return null + return middle.takeIf { middle in ADJACENCY[from] && to in ADJACENCY[middle] } +} + +@Serializable +data class PiratesState( + val options: PiratesOptions, + /** Thirty-three points; null is empty, otherwise the seat standing there. */ + val board: List, + val turn: Int, + val phase: PiratesPhase, + val outcome: PiratesOutcome?, + val lastMove: PiratesMove?, + /** Plies since a pirate took something or a Bulgar entered the stronghold. */ + val pliesSinceProgress: Int, + val moveLog: List, +) { + fun count(seat: Int): Int = board.count { it == seat } + + /** How many of the stronghold's nine points the Bulgars are standing on. */ + fun stronghold(): Int = STRONGHOLD.count { board[it] == BULGAR_SEAT } + + fun positionKey(): String = buildString { + for (point in 0 until POINTS) { + append( + when (board[point]) { + BULGAR_SEAT -> 'b' + PIRATE_SEAT -> 'P' + else -> '.' + } + ) + } + append(' ').append(turn) + } +} + +/** + * The opening array: Bulgars on every point outside the stronghold, and the two + * pirates on the stronghold's back corners — the deepest points they have, and + * the ones a Bulgar has to come furthest to reach. + */ +fun startingBoard(): List { + val board = arrayOfNulls(POINTS) + (0 until POINTS).forEach { point -> + if (!isStronghold(point)) board[point] = BULGAR_SEAT + } + val corners = STRONGHOLD.filter { rowOf(it) == 0 && columnOf(it) != 3 } + corners.forEach { board[it] = PIRATE_SEAT } + return board.toList() +} diff --git a/app/src/main/java/org/prolibertate/games/game/pirates/PiratesRules.kt b/app/src/main/java/org/prolibertate/games/game/pirates/PiratesRules.kt new file mode 100644 index 0000000..c9a1d7b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pirates/PiratesRules.kt @@ -0,0 +1,255 @@ +package org.prolibertate.games.game.pirates + +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 + +/** + * Pirates and Bulgars — Asalto — as a pure state machine. See RULES-pirates.md. + * + * The two sides do not play the same game, and almost every line here is one + * side or the other rather than both. Pirates go anywhere along a line and take + * by jumping, in chains, compulsorily. Bulgars go one step and never backwards, + * and cannot take anything at all: they win by arriving. + * + * That asymmetry is the reason the win conditions are counted rather than + * shared. Bulgars win by filling the stronghold or by leaving the pirates + * nowhere to go; pirates win by cutting the Bulgars below the nine it takes to + * fill it — at which point the attack cannot succeed however long it goes on. + */ +object PiratesRules : GameRules { + + override val gameId: String = GameCatalog.PIRATES + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): PiratesState { + require(config.seats.size == 2) { "Pirates and Bulgars is a two-handed game" } + val options = json.decodeFromString(config.optionsJson) + return initialState(options) + } + + fun initialState(options: PiratesOptions = PiratesOptions()): PiratesState = PiratesState( + options = options, + board = startingBoard(), + // The attackers move first: the pirates are already where they want to be. + turn = BULGAR_SEAT, + phase = PiratesPhase.PLAYING, + outcome = null, + lastMove = null, + pliesSinceProgress = 0, + moveLog = emptyList(), + ) + + override fun currentSeat(state: PiratesState): Int? = + if (state.phase == PiratesPhase.GAME_OVER) null else state.turn + + // ----------------------------------------------------------------------- + // Moves + // ----------------------------------------------------------------------- + + fun legalMoves(state: PiratesState): List = legalMoves(state, state.turn) + + override fun legalMoves(state: PiratesState, seat: Int): List { + if (state.phase == PiratesPhase.GAME_OVER || state.turn != seat) return emptyList() + val mine = (0 until POINTS).filter { state.board[it] == seat } + + if (seat == PIRATE_SEAT) { + val jumps = mine.flatMap { from -> + val board = state.board.toMutableList() + board[from] = null + jumpsFrom(board, from).map { PiratesMove(from = from, steps = it) } + } + // A pirate who can take must take — the rule huffing exists to + // enforce, applied directly. + if (jumps.isNotEmpty() && state.options.captureIsCompulsory) return jumps + + val steps = mine.flatMap { from -> + ADJACENCY[from].filter { state.board[it] == null } + .map { PiratesMove(from = from, steps = listOf(it)) } + } + return jumps + steps + } + + return mine.flatMap { from -> + ADJACENCY[from] + .filter { state.board[it] == null && bulgarMayGo(state, from, it) } + .map { PiratesMove(from = from, steps = listOf(it)) } + } + } + + /** + * Whether a Bulgar may make this step. + * + * They press towards the stronghold and never retreat, which is what makes + * the attack a commitment: a Bulgar that walks past a gap cannot come back + * for it. The stronghold is at the top of the board, so "forwards" is up + * the rows and "across" is along one. + */ + private fun bulgarMayGo(state: PiratesState, from: Int, to: Int): Boolean { + if (!state.options.bulgarsMayNotRetreat) return true + return rowOf(to) <= rowOf(from) + } + + /** + * Every complete chain of jumps from [at], as lists of landing points. + * + * [board] arrives with the jumping pirate already lifted, and is mutated as + * the search recurses so a Bulgar cannot be jumped twice in one turn. A + * chain only ends where no further jump exists, so a shorter prefix of a + * longer capture is never offered on its own. + */ + private fun jumpsFrom(board: MutableList, at: Int): List> { + val sequences = mutableListOf>() + + for (over in ADJACENCY[at]) { + if (board[over] != BULGAR_SEAT) continue + val landing = beyond(at, over) ?: continue + if (board[landing] != null) continue + + board[over] = null + val continuations = jumpsFrom(board, landing) + board[over] = BULGAR_SEAT + + if (continuations.isEmpty()) { + sequences += listOf(landing) + } else { + continuations.forEach { sequences += listOf(landing) + it } + } + } + + return sequences + } + + /** The point directly past [over] as seen from [from], if the line runs on. */ + private fun beyond(from: Int, over: Int): Int? { + val landing = pointAt( + rowOf(over) + (rowOf(over) - rowOf(from)), + columnOf(over) + (columnOf(over) - columnOf(from)), + ) ?: return null + return landing.takeIf { it in ADJACENCY[over] } + } + + override fun applyMove(state: PiratesState, seat: Int, move: PiratesMove): PiratesState { + require(state.phase != PiratesPhase.GAME_OVER) { "The game is over" } + require(state.turn == seat) { "It is not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + + return make(state, seat, move) + .copy(moveLog = state.moveLog + "${seatName(seat)}: $move") + .withTerminalCheck(includeDraws = true) + } + + /** The position after [move] with the draw clock left off, for the search. */ + fun advanced(state: PiratesState, move: PiratesMove): PiratesState = + make(state, state.turn, move).withTerminalCheck(includeDraws = false) + + private fun make(state: PiratesState, seat: Int, move: PiratesMove): PiratesState { + val board = state.board.toMutableList() + board[move.from] = null + + var taken = 0 + var at = move.from + for (landing in move.steps) { + jumpedBetween(at, landing)?.let { over -> + if (board[over] != null) { + board[over] = null + taken++ + } + } + at = landing + } + board[move.to] = seat + + // Progress is a pirate taking somebody, or a Bulgar getting into the + // stronghold. Everything else is the two sides circling each other. + val before = state.stronghold() + val next = state.copy(board = board, turn = other(seat), lastMove = move) + val gained = next.stronghold() > before + + return next.copy( + pliesSinceProgress = if (taken > 0 || gained) 0 else state.pliesSinceProgress + 1, + ) + } + + /** + * Whether the game has ended. + * + * Order matters. Filling the stronghold is checked first because it is an + * arrival rather than an absence — a board where the Bulgars have just + * walked into the last square is a Bulgar win even if the pirates have also + * run out of moves on it. + */ + private fun PiratesState.withTerminalCheck(includeDraws: Boolean): PiratesState { + if (stronghold() == STRONGHOLD.size) { + return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.BULGARS_STORM) + } + + // Too few Bulgars left to fill it, so the assault can never succeed + // however long the game runs. + if (count(BULGAR_SEAT) < STRONGHOLD.size) { + return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.PIRATES_CUT_DOWN) + } + + // Whoever is on the clock with nothing to play has lost — which for the + // pirates is being penned in, and for the Bulgars means they have run + // themselves out of forward moves. + if (legalMoves(this, turn).isEmpty()) { + return copy( + phase = PiratesPhase.GAME_OVER, + outcome = if (turn == PIRATE_SEAT) { + PiratesOutcome.BULGARS_PEN_IN + } else { + PiratesOutcome.PIRATES_CUT_DOWN + }, + ) + } + + if (!includeDraws) return this + + if (options.plyLimitWithoutProgress > 0 && + pliesSinceProgress >= options.plyLimitWithoutProgress + ) { + return copy(phase = PiratesPhase.GAME_OVER, outcome = PiratesOutcome.DRAW_STALEMATE) + } + + return this + } + + // ----------------------------------------------------------------------- + // Engine contract + // ----------------------------------------------------------------------- + + override fun isFinished(state: PiratesState): Boolean = state.phase == PiratesPhase.GAME_OVER + + override fun summary(state: PiratesState): String = state.outcome?.label ?: buildString { + append(if (state.turn == BULGAR_SEAT) "The Bulgars" else "The pirates") + append(" to move · ") + append("${state.stronghold()} of ${STRONGHOLD.size} in the stronghold, ") + append("${state.count(BULGAR_SEAT)} Bulgars left") + } + + /** Nothing is hidden: the whole board is on the table. */ + override fun viewFor(state: PiratesState, seat: Int): PiratesState = state + + override fun encodeState(state: PiratesState): String = json.encodeToString(state) + override fun decodeState(json: String): PiratesState = this.json.decodeFromString(json) + override fun encodeMove(move: PiratesMove): String = json.encodeToString(move) + override fun decodeMove(json: String): PiratesMove = this.json.decodeFromString(json) + + /** Leaf nodes at [depth], as a check on the move generator. */ + fun perft(state: PiratesState, depth: Int): Long { + if (depth == 0) return 1L + val moves = legalMoves(state) + if (depth == 1) return moves.size.toLong() + return moves.sumOf { move -> + val next = advanced(state, move) + if (next.phase == PiratesPhase.GAME_OVER) 1L else perft(next, depth - 1) + } + } +} 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 032cc33..8890dfe 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -30,14 +30,22 @@ 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.backgammon.BackgammonLevel +import org.prolibertate.games.game.backgammon.BackgammonOptions +import org.prolibertate.games.game.checkers.CheckersLevel +import org.prolibertate.games.game.checkers.CheckersOptions 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.mastermind.MastermindLevel +import org.prolibertate.games.game.mastermind.MastermindOptions import org.prolibertate.games.game.morris.MorrisLevel import org.prolibertate.games.game.morris.MorrisOptions +import org.prolibertate.games.game.pirates.PiratesLevel +import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.tayu.TayuLevel @@ -74,9 +82,16 @@ fun GameSetupScreen( var chess by remember { mutableStateOf(ChessOptions()) } var tayu by remember { mutableStateOf(TayuOptions()) } var morris by remember { mutableStateOf(MorrisOptions()) } + var checkers by remember { mutableStateOf(CheckersOptions()) } + var backgammon by remember { mutableStateOf(BackgammonOptions()) } + var mastermind by remember { mutableStateOf(MastermindOptions()) } + var pirates by remember { mutableStateOf(PiratesOptions()) } // 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) } + // Pirates and Bulgars is not symmetrical, so which side you take is the + // first thing about the game rather than a detail of it. + var playPirates by remember { mutableStateOf(false) } val optionsJson = when (descriptor.id) { GameCatalog.EUCHRE -> setupJson.encodeToString(euchre) @@ -89,6 +104,10 @@ fun GameSetupScreen( GameCatalog.CHESS -> setupJson.encodeToString(chess) GameCatalog.TAYU -> setupJson.encodeToString(tayu) GameCatalog.MORRIS -> setupJson.encodeToString(morris) + GameCatalog.CHECKERS -> setupJson.encodeToString(checkers) + GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon) + GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind) + GameCatalog.PIRATES -> setupJson.encodeToString(pirates) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -125,6 +144,19 @@ fun GameSetupScreen( GameCatalog.MORRIS -> MorrisOptionsEditor(morris) { morris = it } + GameCatalog.CHECKERS -> CheckersOptionsEditor(checkers) { checkers = it } + + GameCatalog.BACKGAMMON -> BackgammonOptionsEditor(backgammon) { backgammon = it } + + GameCatalog.MASTERMIND -> MastermindOptionsEditor(mastermind) { mastermind = it } + + GameCatalog.PIRATES -> PiratesOptionsEditor( + options = pirates, + playPirates = playPirates, + onChange = { pirates = it }, + onSide = { playPirates = it }, + ) + else -> Text("No options yet for this game.") } @@ -137,9 +169,14 @@ 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 + // Two games let the local player pick a seat rather than always + // taking the first one: chess by colour, and Pirates and Bulgars by + // which side of a very lopsided fight they fancy. + val localSeat = when { + descriptor.id == GameCatalog.CHESS && !playWhite -> 1 + descriptor.id == GameCatalog.PIRATES && playPirates -> 1 + else -> 0 + } PrimaryAction(text = "Play against the computer") { onPlayOffline( offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat), @@ -202,7 +239,8 @@ fun seatCountFor(gameId: String, optionsJson: String): Int { GameCatalog.WIZARD -> setupJson.decodeFromString(optionsJson).playerCount - GameCatalog.CHESS, GameCatalog.MORRIS -> 2 + GameCatalog.CHESS, GameCatalog.MORRIS, GameCatalog.CHECKERS, + GameCatalog.BACKGAMMON, GameCatalog.MASTERMIND, GameCatalog.PIRATES -> 2 GameCatalog.TAYU -> setupJson.decodeFromString(optionsJson).playerCount @@ -537,6 +575,178 @@ private fun ChessOptionsEditor( } } +@Composable +private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Opponent", + values = CheckersLevel.entries.toList(), + selected = options.level, + display = { it.label }, + onSelect = { onChange(options.copy(level = it)) }, + ) + ToggleRow( + title = "Flying kings", + subtitle = "A king slides the length of a diagonal and takes from a distance, " + + "as in the international game", + checked = options.flyingKings, + onChange = { onChange(options.copy(flyingKings = it)) }, + ) + ToggleRow( + title = "Crowning ends the turn", + subtitle = "A man jumping into the back row stops there. Turn this off and it " + + "carries on jumping as a man", + checked = options.crowningEndsTheTurn, + onChange = { onChange(options.copy(crowningEndsTheTurn = 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 = "Captures are always compulsory, and a multiple jump is one turn — " + + "there is no version of this game where you may stop halfway.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun BackgammonOptionsEditor( + options: BackgammonOptions, + onChange: (BackgammonOptions) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Opponent", + values = BackgammonLevel.entries.toList(), + selected = options.level, + display = { it.label }, + onSelect = { onChange(options.copy(level = it)) }, + ) + ToggleRow( + title = "Count gammons", + subtitle = "A loser who bore nothing off has lost double, and one still on the " + + "bar has lost treble", + checked = options.countGammons, + onChange = { onChange(options.copy(countGammons = it)) }, + ) + Text( + text = "One game rather than a match, and no doubling cube: there is nothing " + + "to double when nothing is being kept.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun MastermindOptionsEditor( + options: MastermindOptions, + onChange: (MastermindOptions) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Opponent", + values = MastermindLevel.entries.toList(), + selected = options.level, + display = { it.label }, + onSelect = { onChange(options.copy(level = it)) }, + ) + ChipRow( + label = "Colours", + values = listOf(6, 8), + selected = options.colours, + display = { "$it" }, + onSelect = { colours -> + onChange( + options.copy( + colours = colours, + // A code of distinct colours cannot be longer than the + // number of colours there are. + length = if (options.allowDuplicates) { + options.length + } else { + options.length.coerceAtMost(colours) + }, + ) + ) + }, + ) + ChipRow( + label = "Pegs", + values = listOf(3, 4, 5), + selected = options.length, + display = { "$it" }, + onSelect = { onChange(options.copy(length = it)) }, + ) + ChipRow( + label = "Guesses each", + values = listOf(8, 10, 12), + selected = options.maxGuesses, + display = { "$it" }, + onSelect = { onChange(options.copy(maxGuesses = it)) }, + ) + ToggleRow( + title = "Repeated colours", + subtitle = "A colour may appear more than once in the code", + checked = options.allowDuplicates, + onChange = { onChange(options.copy(allowDuplicates = it)) }, + ) + Text( + text = "${options.codeSpace()} possible codes. Both players are set one and " + + "both are breaking one, so cracking it in the same round as your " + + "opponent is a draw.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun PiratesOptionsEditor( + options: PiratesOptions, + playPirates: Boolean, + onChange: (PiratesOptions) -> Unit, + onSide: (Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "You play", + values = listOf(false, true), + selected = playPirates, + display = { if (it) "the two pirates" else "the twenty-four Bulgars" }, + onSelect = onSide, + ) + ChipRow( + label = "Opponent", + values = PiratesLevel.entries.toList(), + selected = options.level, + display = { it.label }, + onSelect = { onChange(options.copy(level = it)) }, + ) + ToggleRow( + title = "A pirate who can take must take", + subtitle = "The rule that huffing exists to enforce, applied directly", + checked = options.captureIsCompulsory, + onChange = { onChange(options.copy(captureIsCompulsory = it)) }, + ) + ToggleRow( + title = "Bulgars may not retreat", + subtitle = "They press towards the stronghold or across, and never back", + checked = options.bulgarsMayNotRetreat, + onChange = { onChange(options.copy(bulgarsMayNotRetreat = it)) }, + ) + Text( + text = "The two sides are not playing the same game. The pirates take by " + + "jumping and win by cutting the Bulgars below the nine it takes to fill " + + "the stronghold; the Bulgars cannot take anything at all, and win by " + + "filling it or by leaving the pirates nowhere to go.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + @Composable private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 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 16a9b3a..c29eba2 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -31,10 +31,26 @@ 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.backgammon.BackgammonAi +import org.prolibertate.games.game.backgammon.BackgammonMove +import org.prolibertate.games.game.backgammon.BackgammonRules +import org.prolibertate.games.game.backgammon.BackgammonState +import org.prolibertate.games.game.checkers.CheckersAi +import org.prolibertate.games.game.checkers.CheckersMove +import org.prolibertate.games.game.checkers.CheckersRules +import org.prolibertate.games.game.checkers.CheckersState +import org.prolibertate.games.game.mastermind.MastermindAi +import org.prolibertate.games.game.mastermind.MastermindMove +import org.prolibertate.games.game.mastermind.MastermindRules +import org.prolibertate.games.game.mastermind.MastermindState import org.prolibertate.games.game.morris.MorrisAi import org.prolibertate.games.game.morris.MorrisMove import org.prolibertate.games.game.morris.MorrisRules import org.prolibertate.games.game.morris.MorrisState +import org.prolibertate.games.game.pirates.PiratesAi +import org.prolibertate.games.game.pirates.PiratesMove +import org.prolibertate.games.game.pirates.PiratesRules +import org.prolibertate.games.game.pirates.PiratesState import org.prolibertate.games.game.president.PresidentAi import org.prolibertate.games.game.president.PresidentMove import org.prolibertate.games.game.president.PresidentPhase @@ -55,12 +71,16 @@ 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.BackgammonScreen +import org.prolibertate.games.ui.game.CheckersScreen 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.MastermindScreen import org.prolibertate.games.ui.game.MorrisScreen +import org.prolibertate.games.ui.game.PiratesScreen import org.prolibertate.games.ui.game.PresidentScreen import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS import org.prolibertate.games.ui.game.SequenceScreen @@ -307,6 +327,76 @@ fun PlayScreen( TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.CHECKERS -> { + val controller = remember(route) { + MatchController( + rules = CheckersRules, + ai = CheckersAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(250L) }, + ) + } + StartMatch(env, controller, route) + CheckersScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.BACKGAMMON -> { + val controller = remember(route) { + MatchController( + rules = BackgammonRules, + ai = BackgammonAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // A checker at a time, so the pause is per checker rather + // than per turn — half of it, or a double would take an age. + aiThinkingMillis = { settings.scaled(350L) }, + ) + } + StartMatch(env, controller, route) + BackgammonScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.MASTERMIND -> { + val controller = remember(route) { + MatchController( + rules = MastermindRules, + ai = MastermindAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(900L) }, + ) + } + StartMatch(env, controller, route) + MastermindScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.PIRATES -> { + val controller = remember(route) { + MatchController( + rules = PiratesRules, + ai = PiratesAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(250L) }, + ) + } + StartMatch(env, controller, route) + PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + GameCatalog.MORRIS -> { val controller = remember(route) { MatchController( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/BackgammonScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/BackgammonScreen.kt new file mode 100644 index 0000000..4b79af0 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/BackgammonScreen.kt @@ -0,0 +1,419 @@ +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +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.backgammon.BAR +import org.prolibertate.games.game.backgammon.BackgammonMove +import org.prolibertate.games.game.backgammon.BackgammonPhase +import org.prolibertate.games.game.backgammon.BackgammonState +import org.prolibertate.games.game.backgammon.CHECKERS +import org.prolibertate.games.game.backgammon.WHITE_SEAT +import org.prolibertate.games.game.backgammon.other +import org.prolibertate.games.game.backgammon.seatName +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold + +private val BoardField = Color(0xFF7A5230) +private val PointLight = Color(0xFFE8CFA9) +private val PointDark = Color(0xFF9C3B2E) +private val WhiteChecker = Color(0xFFF7F2E7) +private val BlackChecker = Color(0xFF23201C) +private val CheckerEdge = Color(0x66000000) +private val SelectedPoint = Color(0x8830C030) +private val TargetPoint = Color(0x66FFC107) + +/** + * The backgammon board, drawn as two banks of twelve points with the bar down + * the middle. + * + * Tap a point to pick a checker up and tap where it goes; if there is only one + * thing that checker can do, tapping it plays it. The dice left to play are + * shown above the board and go out as they are used. + * + * Points are drawn in the engine's own numbering — 1 to 24 with White running + * down — rather than being flipped for whoever is sitting there. A backgammon + * board is symmetrical and both players read the same numbers off it. + */ +@Composable +fun BackgammonScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var selected by remember { mutableStateOf(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) LeaveDialog(onExit) { confirmingEnd = false } + } + return + } + + val fromSelected = legal.filter { it.from == selected } + val targets = fromSelected.mapNotNull { move -> + org.prolibertate.games.game.backgammon.BackgammonRules + .destinationOf(current.turn, move.from, move.die) + }.toSet() + + val onPointTapped: (Int) -> Unit = { point -> + val landing = fromSelected.firstOrNull { move -> + org.prolibertate.games.game.backgammon.BackgammonRules + .destinationOf(current.turn, move.from, move.die) == point + } + when { + landing != null -> { + controller.submit(landing) + selected = null + } + + legal.any { it.from == point } -> { + val fromHere = legal.filter { it.from == point } + // Nothing to choose between means nothing to ask about. + if (fromHere.size == 1) { + controller.submit(fromHere.single()) + selected = null + } else { + selected = point + } + } + + else -> selected = null + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Status(current, localSeat) + DiceRow(current) + + BoxWithConstraints(modifier = Modifier.fillMaxWidth().weight(1f)) { + val boardWidth = minOf(maxWidth, maxHeight * 1.25f) + Board( + state = current, + width = boardWidth, + height = minOf(maxHeight, boardWidth * 0.8f), + selected = selected, + targets = targets, + onTap = onPointTapped, + ) + } + + OffAndBar(current, localSeat) { onPointTapped(BAR) } + } + + val over = abandoned + when { + over != null -> LeftBoardDialog(over, onExit, controller::dismissAbandoned) + confirmingEnd -> LeaveDialog(onExit) { confirmingEnd = false } + current.phase == BackgammonPhase.GAME_OVER -> ResultDialog(current, onExit) + } + } +} + +private const val TITLE = "Backgammon" + +@Composable +private fun Status(state: BackgammonState, localSeat: Int) { + val yours = state.turn == localSeat && state.phase == BackgammonPhase.PLAYING + Column { + Text( + text = state.outcome?.label ?: when { + yours && state.bar[localSeat] > 0 -> "Come in off the bar" + yours -> "Your roll to play" + else -> "Thinking…" + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "You play ${seatName(localSeat)} · " + + "${state.pipCount(localSeat)} pips against ${state.pipCount(other(localSeat))}", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +/** The dice, with the ones already played greyed out rather than removed. */ +@Composable +private fun DiceRow(state: BackgammonState) { + val left = state.dice.toMutableList() + val shown = if (state.roll.size == 2 && state.roll[0] == state.roll[1]) { + List(4) { state.roll[0] } + } else { + state.roll + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + shown.forEach { die -> + val unplayed = left.remove(die) + Box( + modifier = Modifier + .size(30.dp) + .background(if (unplayed) PointLight else Color(0x33808080)) + .border(1.dp, CheckerEdge), + contentAlignment = Alignment.Center, + ) { + Text( + text = "$die", + color = Color(0xFF23201C), + fontWeight = FontWeight.Bold, + ) + } + } + } +} + +@Composable +private fun Board( + state: BackgammonState, + width: Dp, + height: Dp, + selected: Int?, + targets: Set, + onTap: (Int) -> Unit, +) { + val columnWidth = width / 13 + val bankHeight = height / 2 + + Column(modifier = Modifier.width(width).height(height).background(BoardField)) { + // The top bank runs 13 to 24 left to right, the bottom 12 down to 1 — + // the standard layout, with White's home at the bottom right. + Bank( + points = (12..23).toList(), + state = state, + columnWidth = columnWidth, + bankHeight = bankHeight, + pointingDown = true, + selected = selected, + targets = targets, + onTap = onTap, + ) + Bank( + points = (11 downTo 0).toList(), + state = state, + columnWidth = columnWidth, + bankHeight = bankHeight, + pointingDown = false, + selected = selected, + targets = targets, + onTap = onTap, + ) + } +} + +@Composable +private fun Bank( + points: List, + state: BackgammonState, + columnWidth: Dp, + bankHeight: Dp, + pointingDown: Boolean, + selected: Int?, + targets: Set, + onTap: (Int) -> Unit, +) { + Row(modifier = Modifier.fillMaxWidth().height(bankHeight)) { + points.forEachIndexed { index, point -> + // The bar sits between the two halves of each bank. + if (index == 6) { + Box( + modifier = Modifier + .width(columnWidth) + .fillMaxHeight() + .background(BoardField) + .border(1.dp, Color(0x33000000)) + ) + } + PointColumn( + point = point, + state = state, + width = columnWidth, + height = bankHeight, + light = index % 2 == 0, + pointingDown = pointingDown, + selected = point == selected, + target = point in targets, + onTap = { onTap(point) }, + ) + } + } +} + +@Composable +private fun PointColumn( + point: Int, + state: BackgammonState, + width: Dp, + height: Dp, + light: Boolean, + pointingDown: Boolean, + selected: Boolean, + target: Boolean, + onTap: () -> Unit, +) { + val white = state.countOn(point, WHITE_SEAT) + val black = state.countOn(point, 1 - WHITE_SEAT) + val count = white + black + + Box( + modifier = Modifier + .width(width) + .height(height) + .background(if (light) PointLight.copy(alpha = 0.35f) else PointDark.copy(alpha = 0.5f)) + .then(if (selected) Modifier.background(SelectedPoint) else Modifier) + .then(if (target) Modifier.background(TargetPoint) else Modifier) + .border(1.dp, Color(0x22000000)) + .clickable { onTap() }, + contentAlignment = if (pointingDown) Alignment.TopCenter else Alignment.BottomCenter, + ) { + Column( + verticalArrangement = if (pointingDown) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Five checkers is as many as fit; anything beyond that is written + // on the last one, which is what a crowded board does anyway. + val drawn = minOf(count, 5) + repeat(drawn) { index -> + val isLast = index == drawn - 1 + Box( + modifier = Modifier + .size(width * 0.82f) + .clip(CircleShape) + .background(if (white > 0) WhiteChecker else BlackChecker) + .border(1.dp, CheckerEdge, CircleShape), + contentAlignment = Alignment.Center, + ) { + if (isLast && count > 5) { + Text( + text = "$count", + color = if (white > 0) Color(0xFF23201C) else Color(0xFFF7F2E7), + fontSize = (width.value * 0.36f).sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + + Text( + text = "${point + 1}", + style = MaterialTheme.typography.labelSmall, + color = Color(0x99000000), + modifier = Modifier + .align(if (pointingDown) Alignment.BottomCenter else Alignment.TopCenter) + .padding(1.dp), + ) + } +} + +/** What is on the bar and what is safely off, with the bar tappable to come in. */ +@Composable +private fun OffAndBar(state: BackgammonState, localSeat: Int, onBarTapped: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onBarTapped, enabled = state.bar[localSeat] > 0) { + Text("Bar: ${state.bar[localSeat]}") + } + Text( + text = "Borne off — you ${state.off[localSeat]} of $CHECKERS, " + + "them ${state.off[other(localSeat)]}", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { + Text( + "This game ends here and the position is not saved. Anyone else " + + "at the table is told you have left." + ) + }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftBoardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text("$notice There are no more moves to play.") }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } }, + ) +} + +@Composable +private fun ResultDialog(state: BackgammonState, 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.outcome?.let { + Text( + text = "Worth ${it.points} point${if (it.points == 1) "" else "s"}.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CheckersScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/CheckersScreen.kt new file mode 100644 index 0000000..e389222 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/CheckersScreen.kt @@ -0,0 +1,353 @@ +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +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.checkers.BLACK_SEAT +import org.prolibertate.games.game.checkers.CheckersMove +import org.prolibertate.games.game.checkers.CheckersPhase +import org.prolibertate.games.game.checkers.CheckersState +import org.prolibertate.games.game.checkers.seatName +import org.prolibertate.games.game.checkers.squareAt +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold + +private val LightSquare = Color(0xFFEEDAB4) +private val DarkSquare = Color(0xFF9A7248) +private val BlackPiece = Color(0xFF1E1B18) +private val WhitePiece = Color(0xFFF7F2E7) +private val PieceEdge = Color(0x66000000) +private val SelectedSquare = Color(0x8830C030) +private val PathSquare = Color(0x66FFC107) +private val MoveDot = Color(0x66000000) +private val CrownInk = Color(0xFFFFC107) + +/** + * The checkers board. + * + * Tap a piece, then tap where it goes. A multiple jump is one move as far as + * the rules are concerned, so it is tapped out one hop at a time and sent when + * the chain is complete — a player cannot stop in the middle of it, and the + * board will not let them try. + * + * The board is drawn from the local player's side, so a game as White is played + * up the screen the way it would be across a real table. + */ +@Composable +fun CheckersScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var selected by remember { mutableStateOf(null) } + // The hops tapped out so far of a jump that is not finished. + var path by remember { mutableStateOf(emptyList()) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + abandoned?.let { LeftTableDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndGameDialog(onExit) { confirmingEnd = false } + } + return + } + + // Every move still possible given the piece picked up and the hops tapped. + val following = legal.filter { move -> + move.from == selected && move.steps.take(path.size) == path + } + val nextHops = following.mapNotNull { it.steps.getOrNull(path.size) }.toSet() + + val onSquareTapped: (Int) -> Unit = { square -> + when { + square in nextHops -> { + val extended = path + square + val done = following.firstOrNull { it.steps == extended } + if (done != null) { + controller.submit(done) + selected = null + path = emptyList() + } else { + path = extended + } + } + + // Picking up a different piece, which is what a misplaced tap means. + legal.any { it.from == square } -> { + selected = square + path = emptyList() + } + + else -> { + selected = null + path = emptyList() + } + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + BoxWithConstraints(modifier = modifier) { + val wide = maxWidth > maxHeight + val side = if (wide) minOf(maxHeight, maxWidth * 0.62f) else minOf(maxWidth, maxHeight * 0.72f) + + val board: @Composable () -> Unit = { + Board( + state = current, + flipped = localSeat != BLACK_SEAT, + side = side, + selected = selected, + path = path, + destinations = nextHops, + onTap = onSquareTapped, + ) + } + val panel: @Composable (Modifier) -> Unit = { panelModifier -> + Column(modifier = panelModifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Status(current, localSeat, path.isNotEmpty()) + MoveLog(current, modifier = Modifier.weight(1f, fill = false)) + } + } + + if (wide) { + Row(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)) + } + } + + val over = abandoned + when { + over != null -> LeftTableDialog(over, onExit, controller::dismissAbandoned) + confirmingEnd -> EndGameDialog(onExit) { confirmingEnd = false } + current.phase == CheckersPhase.GAME_OVER -> ResultDialog(current, onExit) + } + } + } +} + +private const val TITLE = "Checkers" + +@Composable +private fun Board( + state: CheckersState, + flipped: Boolean, + side: Dp, + selected: Int?, + path: List, + destinations: Set, + onTap: (Int) -> Unit, +) { + val cell = side / 8 + Column(modifier = Modifier.size(side).border(2.dp, DarkSquare)) { + for (displayRow in 0 until 8) { + Row(modifier = Modifier.weight(1f)) { + for (displayColumn in 0 until 8) { + // Turning the board round is one subtraction, so nothing + // below this has to know which way up it is drawn. + val row = if (flipped) 7 - displayRow else displayRow + val column = if (flipped) 7 - displayColumn else displayColumn + val square = squareAt(row, column) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .background(if (square == null) LightSquare else DarkSquare) + .then( + if (square != null && square in path) { + Modifier.background(PathSquare) + } else { + Modifier + } + ) + .then( + if (square != null && square == selected) { + Modifier.background(SelectedSquare) + } else { + Modifier + } + ) + .then( + if (square != null) Modifier.clickable { onTap(square) } else Modifier + ), + contentAlignment = Alignment.Center, + ) { + val piece = square?.let { state.board[it] } + if (piece != null) { + Box( + modifier = Modifier + .size(cell * 0.74f) + .clip(CircleShape) + .background( + if (piece.seat == BLACK_SEAT) BlackPiece else WhitePiece + ) + .border(1.dp, PieceEdge, CircleShape), + contentAlignment = Alignment.Center, + ) { + // A crown rather than a second disc: two discs + // stacked is what a wooden set does and what a + // small screen cannot show. + if (piece.king) { + Text( + text = "♛", + color = CrownInk, + fontSize = (cell.value * 0.42f).sp, + ) + } + } + } + if (square != null && square in destinations) { + Box( + modifier = Modifier + .size(cell * 0.28f) + .clip(CircleShape) + .background(MoveDot) + ) + } + } + } + } + } + } +} + +@Composable +private fun Status(state: CheckersState, localSeat: Int, midJump: Boolean) { + val yours = state.turn == localSeat && state.phase == CheckersPhase.PLAYING + Column { + Text( + text = state.outcome?.label ?: when { + midJump -> "Keep jumping — the capture has to be finished" + yours -> "Your move" + else -> "Thinking…" + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "You play ${seatName(localSeat)} · " + + "${state.count(localSeat)} left (${state.kings(localSeat)} crowned) " + + "against ${state.count(1 - localSeat)}", + style = MaterialTheme.typography.bodySmall, + ) + if (state.phase == CheckersPhase.PLAYING) { + Text( + text = "A capture must be taken when one is on the board.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun MoveLog(state: CheckersState, 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 EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { + Text( + "This game ends here and the position is not saved. Anyone else " + + "at the table is told you have left." + ) + }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftTableDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text("$notice There are no more moves to play.") }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } }, + ) +} + +@Composable +private fun ResultDialog(state: CheckersState, 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) + Text( + text = "${state.moveLog.size} moves played.", + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt new file mode 100644 index 0000000..6c4a712 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt @@ -0,0 +1,356 @@ +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.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.layout.size +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.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.mutableStateListOf +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 org.prolibertate.games.game.mastermind.Feedback +import org.prolibertate.games.game.mastermind.Guess +import org.prolibertate.games.game.mastermind.MastermindMove +import org.prolibertate.games.game.mastermind.MastermindPhase +import org.prolibertate.games.game.mastermind.MastermindState +import org.prolibertate.games.game.mastermind.other +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.PrimaryAction +import org.prolibertate.games.ui.ScreenScaffold + +/** + * The eight code pegs. + * + * Kept well apart in hue *and* in lightness, because a code you cannot read is + * not a puzzle, it is an eye test — and one player in twelve cannot tell the + * red from the green. + */ +private val PegColours = listOf( + Color(0xFFD32F2F), + Color(0xFF1976D2), + Color(0xFFFBC02D), + Color(0xFF388E3C), + Color(0xFFF57C00), + Color(0xFF7B1FA2), + Color(0xFF00ACC1), + Color(0xFFFAFAFA), +) + +private val EmptySlot = Color(0x33808080) +private val ExactPeg = Color(0xFF212121) +private val MisplacedPeg = Color(0xFFFFFFFF) + +/** + * Mastermind, played as a duel: both players are guarding a code and breaking + * one, a guess each in turn. + * + * Tap the colours to build a guess and press the button to commit it. The + * answer to each guess is the little pegs beside it — a black one for a colour + * in the right place, a white one for a colour in the wrong place, and no + * indication whatever of which is which. + */ +@Composable +fun MastermindScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + val building = remember { mutableStateListOf() } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + abandoned?.let { LeftDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndDialog(onExit) { confirmingEnd = false } + } + return + } + + val yourTurn = current.turn == localSeat && current.phase == MastermindPhase.GUESSING + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.outcome != null -> current.outcome!!.label + yourTurn -> "Your guess — ${current.guessesLeft(localSeat)} left" + else -> "Thinking…" + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "A dark peg is a colour in the right place, a light one a colour " + + "somewhere else. Which is which is for you to work out.", + style = MaterialTheme.typography.bodySmall, + ) + + Text("Your guesses", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + GuessList(current, localSeat) + + if (current.phase == MastermindPhase.GUESSING) { + Builder( + length = current.options.length, + colours = current.options.colours, + building = building, + enabled = yourTurn, + onSubmit = { + controller.submit(MastermindMove(building.toList())) + building.clear() + }, + ) + } + + Text( + text = "Against you", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp), + ) + Text( + text = "${current.guesses[other(localSeat)].size} guesses at your code so far.", + style = MaterialTheme.typography.bodySmall, + ) + GuessList(current, other(localSeat)) + + // Your own code, which is yours to see all along — you are guarding + // it, not guessing it. + if (current.secrets[localSeat].isNotEmpty()) { + Text( + text = "The code you are guarding", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp), + ) + PegRow(current.secrets[localSeat]) + } + } + + val over = abandoned + when { + over != null -> LeftDialog(over, onExit, controller::dismissAbandoned) + confirmingEnd -> EndDialog(onExit) { confirmingEnd = false } + current.phase == MastermindPhase.GAME_OVER -> ResultDialog(current, localSeat, onExit) + } + } +} + +private const val TITLE = "Mastermind" + +@Composable +private fun GuessList(state: MastermindState, seat: Int) { + val guesses = state.guesses[seat] + if (guesses.isEmpty()) { + Text("Nothing yet.", style = MaterialTheme.typography.bodySmall) + return + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + guesses.forEachIndexed { index, guess -> + GuessRow(index + 1, guess, state.options.length) + } + } +} + +@Composable +private fun GuessRow(number: Int, guess: Guess, length: Int) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "$number.", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.size(width = 22.dp, height = 20.dp), + ) + PegRow(guess.code) + Box(modifier = Modifier.padding(start = 10.dp)) { + FeedbackPegs(guess.feedback, length) + } + } +} + +@Composable +private fun PegRow(code: List) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + code.forEach { colour -> Peg(colour) } + } +} + +@Composable +private fun Peg(colour: Int?, size: androidx.compose.ui.unit.Dp = 26.dp) { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(colour?.let { PegColours[it % PegColours.size] } ?: EmptySlot) + .border(1.dp, Color(0x55000000), CircleShape) + ) +} + +/** + * The answer, as pegs rather than as numbers. + * + * Deliberately not laid out one-per-code-peg: the pegs say how many, never + * which, and putting them in a tidy row under the code would invite reading a + * position into them that is not there. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FeedbackPegs(feedback: Feedback, length: Int) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + maxItemsInEachRow = (length + 1) / 2, + ) { + repeat(feedback.exact) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(ExactPeg) + .border(1.dp, Color(0x55000000), CircleShape) + ) + } + repeat(feedback.misplaced) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(MisplacedPeg) + .border(1.dp, Color(0x55000000), CircleShape) + ) + } + } +} + +/** The guess being built: tap a colour to add it, tap a peg to take it back. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Builder( + length: Int, + colours: Int, + building: MutableList, + enabled: Boolean, + onSubmit: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + repeat(length) { slot -> + Box( + modifier = Modifier.clickable(enabled = enabled && slot < building.size) { + // Taking a peg back removes it and everything after it, + // which is what a physical board does when you lift one + // out of the middle of a row you are still filling. + while (building.size > slot) building.removeAt(building.size - 1) + } + ) { + Peg(building.getOrNull(slot), size = 32.dp) + } + } + } + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(colours) { colour -> + Box( + modifier = Modifier.clickable(enabled = enabled && building.size < length) { + building.add(colour) + } + ) { + Peg(colour, size = 34.dp) + } + } + } + + PrimaryAction( + text = "Guess", + enabled = enabled && building.size == length, + onClick = onSubmit, + ) + } +} + +@Composable +private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text("The game ends here. Anyone else at the table is told you have left.") }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text("$notice There are no more guesses to make.") }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } }, + ) +} + +@Composable +private fun ResultDialog(state: MastermindState, localSeat: Int, 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(6.dp)) { + Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold) + // Both codes are shown now: there is nothing left to protect, + // and everyone wants to see what they were up against. + Text("The code you were breaking", style = MaterialTheme.typography.bodySmall) + PegRow(state.secrets[other(localSeat)]) + state.brokeIn(localSeat)?.let { + Text("You broke it in $it.", style = MaterialTheme.typography.bodySmall) + } + state.brokeIn(other(localSeat))?.let { + Text("They broke yours in $it.", style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PiratesScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PiratesScreen.kt new file mode 100644 index 0000000..4056c47 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/PiratesScreen.kt @@ -0,0 +1,410 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.Canvas +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.size +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.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.geometry.Offset +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 org.prolibertate.games.game.pirates.ADJACENCY +import org.prolibertate.games.game.pirates.BULGAR_SEAT +import org.prolibertate.games.game.pirates.GRID +import org.prolibertate.games.game.pirates.PIRATE_SEAT +import org.prolibertate.games.game.pirates.POINTS +import org.prolibertate.games.game.pirates.PiratesMove +import org.prolibertate.games.game.pirates.PiratesPhase +import org.prolibertate.games.game.pirates.PiratesState +import org.prolibertate.games.game.pirates.STRONGHOLD +import org.prolibertate.games.game.pirates.columnOf +import org.prolibertate.games.game.pirates.isOnBoard +import org.prolibertate.games.game.pirates.isStronghold +import org.prolibertate.games.game.pirates.pointAt +import org.prolibertate.games.game.pirates.rowOf +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold + +private val BoardField = Color(0xFFE7D6B8) +private val StrongholdField = Color(0xFFCBA96B) +private val BoardLine = Color(0xFF5C4326) +private val BulgarStone = Color(0xFF3C6E47) +private val PirateStone = Color(0xFF7A1E1E) +private val StoneEdge = Color(0x66000000) +private val VacantPoint = Color(0x442B2B2B) +private val SelectedRing = Color(0xFF2E9E2E) +private val PathRing = Color(0xFFE0A800) +private val DestinationDot = Color(0x66000000) + +/** + * Pirates and Bulgars, on the cross-shaped board. + * + * Tap one of your pieces and then where it goes. A pirate's chain of jumps is + * one turn, so it is tapped out hop by hop and sent when there is nothing left + * to take — the capture cannot be abandoned halfway, and the board will not let + * a player try. + * + * The stronghold is the shaded arm at the top. Both sides look at it the same + * way up: the board is not turned round, because what each side is trying to do + * with it is not the same and swapping ends would only disguise that. + */ +@Composable +fun PiratesScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var selected by remember { mutableStateOf(null) } + var path by remember { mutableStateOf(emptyList()) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + abandoned?.let { LeftFieldDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) LeaveFieldDialog(onExit) { confirmingEnd = false } + } + return + } + + val following = legal.filter { it.from == selected && it.steps.take(path.size) == path } + val nextHops = following.mapNotNull { it.steps.getOrNull(path.size) }.toSet() + + val onPointTapped: (Int) -> Unit = { point -> + when { + point in nextHops -> { + val extended = path + point + val done = following.firstOrNull { it.steps == extended } + if (done != null) { + controller.submit(done) + selected = null + path = emptyList() + } else { + path = extended + } + } + + legal.any { it.from == point } -> { + selected = point + path = emptyList() + } + + else -> { + selected = null + path = emptyList() + } + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + BoxWithConstraints(modifier = modifier) { + val wide = maxWidth > maxHeight + val side = if (wide) { + minOf(maxHeight, maxWidth * 0.6f) + } else { + minOf(maxWidth, maxHeight * 0.68f) + } + + val board: @Composable () -> Unit = { + Board( + state = current, + side = side, + selected = selected, + path = path.toSet(), + destinations = nextHops, + onTap = onPointTapped, + ) + } + val panel: @Composable (Modifier) -> Unit = { panelModifier -> + Column(modifier = panelModifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Status(current, localSeat, path.isNotEmpty()) + MoveLog(current, modifier = Modifier.weight(1f, fill = false)) + } + } + + if (wide) { + Row(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)) + } + } + + val over = abandoned + when { + over != null -> LeftFieldDialog(over, onExit, controller::dismissAbandoned) + confirmingEnd -> LeaveFieldDialog(onExit) { confirmingEnd = false } + current.phase == PiratesPhase.GAME_OVER -> ResultDialog(current, onExit) + } + } + } +} + +private const val TITLE = "Pirates and Bulgars" + +@Composable +private fun Board( + state: PiratesState, + side: Dp, + selected: Int?, + path: Set, + destinations: Set, + onTap: (Int) -> Unit, +) { + Box(modifier = Modifier.size(side)) { + Lines(modifier = Modifier.fillMaxSize()) + + Column(modifier = Modifier.fillMaxSize()) { + for (row in 0 until GRID) { + Row(modifier = Modifier.weight(1f).fillMaxWidth()) { + for (column in 0 until GRID) { + val point = pointAt(row, column) + Box( + modifier = Modifier.weight(1f).fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (point != null) { + Point( + state = state, + point = point, + cell = side / GRID, + selected = point == selected, + onPath = point in path, + isDestination = point in destinations, + onTap = { onTap(point) }, + ) + } + } + } + } + } + } + } +} + +/** + * The board itself: the cross, the stronghold shaded, and a line between every + * pair of points that are joined. + * + * The lines are drawn from the adjacency table rather than from a picture of a + * board, so what a player can see is exactly what the rules allow — there is no + * way for the drawing and the moves to disagree about where the diagonals are. + */ +@Composable +private fun Lines(modifier: Modifier) { + Canvas(modifier = modifier) { + val cell = size.width / GRID + val stroke = (cell * 0.045f).coerceAtLeast(2f) + fun centre(point: Int) = Offset( + x = (columnOf(point) + 0.5f) * cell, + y = (rowOf(point) + 0.5f) * cell, + ) + + // The cross, filled square by square so the cut corners come out right. + for (row in 0 until GRID) { + for (column in 0 until GRID) { + if (!isOnBoard(row, column)) continue + val inStronghold = pointAt(row, column)?.let { isStronghold(it) } == true + drawRect( + color = if (inStronghold) StrongholdField else BoardField, + topLeft = Offset(column * cell, row * cell), + size = androidx.compose.ui.geometry.Size(cell, cell), + ) + } + } + + for (from in 0 until POINTS) { + for (to in ADJACENCY[from]) { + // Each line once rather than twice over. + if (to <= from) continue + drawLine( + color = BoardLine, + start = centre(from), + end = centre(to), + strokeWidth = stroke, + ) + } + } + } +} + +@Composable +private fun Point( + state: PiratesState, + point: Int, + cell: Dp, + selected: Boolean, + onPath: Boolean, + isDestination: Boolean, + onTap: () -> Unit, +) { + val occupant = state.board[point] + Box( + modifier = Modifier.fillMaxSize().clickable { onTap() }, + contentAlignment = Alignment.Center, + ) { + when { + occupant != null -> Box( + modifier = Modifier + .size(cell * 0.7f) + .clip(CircleShape) + .background(if (occupant == PIRATE_SEAT) PirateStone else BulgarStone) + .border( + width = when { + selected -> cell * 0.07f + onPath -> cell * 0.05f + else -> 1.dp + }, + color = when { + selected -> SelectedRing + onPath -> PathRing + else -> StoneEdge + }, + shape = CircleShape, + ) + ) + + else -> Box( + modifier = Modifier + .size(if (isDestination) cell * 0.32f else cell * 0.14f) + .clip(CircleShape) + .background(if (isDestination) DestinationDot else VacantPoint) + ) + } + } +} + +@Composable +private fun Status(state: PiratesState, localSeat: Int, midJump: Boolean) { + val yours = state.turn == localSeat && state.phase == PiratesPhase.PLAYING + Column { + Text( + text = state.outcome?.label ?: when { + midJump -> "Keep jumping — the capture has to be finished" + yours -> "Your move" + else -> "Thinking…" + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "You play ${if (localSeat == PIRATE_SEAT) "the pirates" else "the Bulgars"} · " + + "${state.stronghold()} of ${STRONGHOLD.size} points held, " + + "${state.count(BULGAR_SEAT)} Bulgars left", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = if (localSeat == PIRATE_SEAT) { + "Cut them below ${STRONGHOLD.size} and the assault is finished. " + + "You must take when you can." + } else { + "Fill the stronghold or pen the pirates in. You cannot take anything, " + + "and you cannot go back." + }, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun MoveLog(state: PiratesState, 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.takeLast(30).forEach { + Text(text = it, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { + Text( + "This game ends here and the position is not saved. Anyone else " + + "at the table is told you have left." + ) + }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftFieldDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text("$notice There are no more moves to play.") }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } }, + ) +} + +@Composable +private fun ResultDialog(state: PiratesState, 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) + Text( + text = "${state.moveLog.size} moves played.", + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index fc403d1..107be12 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -26,12 +26,25 @@ class CatalogTest { } @Test - fun `a dev build lists everything including what is still coming`() { + fun `a dev build lists the whole catalogue`() { val shown = GameCategory.entries.flatMap { GameCatalog.byCategory(it, includeComingSoon = true) } assertEquals(GameCatalog.all.size, shown.size) - assertTrue("unfinished games are visible while developing", shown.any { !it.available }) + } + + @Test + fun `every game in the catalogue can be played`() { + // There is nothing left marked "coming soon". The machinery that hides + // unfinished games from a production build is still there and still + // tested above — it simply has nothing to hide at the moment, and this + // is what will fail the day something is added to the list before its + // engine is written. + assertTrue(GameCatalog.all.all { it.available }) + assertEquals( + GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false), + GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true), + ) } @Test @@ -61,6 +74,10 @@ class CatalogTest { GameCatalog.CHESS, GameCatalog.TAYU, GameCatalog.MORRIS, + GameCatalog.CHECKERS, + GameCatalog.MASTERMIND, + GameCatalog.BACKGAMMON, + GameCatalog.PIRATES, ), GameCatalog.playable.map { it.id }.toSet(), ) diff --git a/app/src/test/java/org/prolibertate/games/game/backgammon/BackgammonRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/backgammon/BackgammonRulesTest.kt new file mode 100644 index 0000000..743ffcd --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/backgammon/BackgammonRulesTest.kt @@ -0,0 +1,408 @@ +package org.prolibertate.games.game.backgammon + +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 + +class BackgammonRulesTest { + + private fun start(seed: Long = 1L) = BackgammonRules.initialState(BackgammonOptions(), seed) + + /** + * A board built by hand. Points are given the way a player says them — + * White's 6 point is index 5 — so [white] and [black] are index-to-count. + */ + private fun position( + white: Map = emptyMap(), + black: Map = emptyMap(), + turn: Int = WHITE_SEAT, + dice: List = listOf(6, 5), + bar: List = listOf(0, 0), + off: List = listOf(0, 0), + ): BackgammonState { + val points = IntArray(POINTS) + white.forEach { (point, count) -> points[point] = count } + black.forEach { (point, count) -> points[point] = -count } + return start().copy( + points = points.toList(), + turn = turn, + dice = dice, + roll = dice.take(2), + bar = bar, + off = off, + ) + } + + // ----------------------------------------------------------------------- + // The board + // ----------------------------------------------------------------------- + + @Test + fun `the opening array is fifteen a side in the usual places`() { + val state = start() + assertEquals(15, (0 until POINTS).sumOf { state.countOn(it, WHITE_SEAT) }) + assertEquals(15, (0 until POINTS).sumOf { state.countOn(it, BLACK_SEAT) }) + assertEquals(2, state.countOn(23, WHITE_SEAT)) + assertEquals(5, state.countOn(12, WHITE_SEAT)) + assertEquals(3, state.countOn(7, WHITE_SEAT)) + assertEquals(5, state.countOn(5, WHITE_SEAT)) + // The two sides are mirror images, so the opening pip counts match. + assertEquals(167, state.pipCount(WHITE_SEAT)) + assertEquals(167, state.pipCount(BLACK_SEAT)) + } + + @Test + fun `the two sides run in opposite directions`() { + assertEquals(-1, directionOf(WHITE_SEAT)) + assertEquals(1, directionOf(BLACK_SEAT)) + assertEquals(0..5, homeOf(WHITE_SEAT)) + assertEquals(18..23, homeOf(BLACK_SEAT)) + // A checker on the point nearest home is one pip from being off. + assertEquals(1, pipsFrom(WHITE_SEAT, 0)) + assertEquals(1, pipsFrom(BLACK_SEAT, 23)) + assertEquals(24, pipsFrom(WHITE_SEAT, 23)) + } + + @Test + fun `each player comes in from the bar at the other end`() { + assertEquals(23, entryPoint(WHITE_SEAT, 1)) + assertEquals(18, entryPoint(WHITE_SEAT, 6)) + assertEquals(0, entryPoint(BLACK_SEAT, 1)) + assertEquals(5, entryPoint(BLACK_SEAT, 6)) + } + + // ----------------------------------------------------------------------- + // The dice + // ----------------------------------------------------------------------- + + @Test + fun `the opening roll is two different dice and the higher one starts`() { + (1L..25L).forEach { seed -> + val state = BackgammonRules.initialState(BackgammonOptions(), seed) + assertEquals(2, state.dice.size) + assertNotEquals(state.dice[0], state.dice[1]) + assertEquals(state.dice.sortedDescending(), state.dice) + } + } + + private fun assertNotEquals(a: Int, b: Int) = + assertTrue("expected different dice, got $a and $b", a != b) + + @Test + fun `the same table throws the same dice`() { + assertEquals(start(9L).dice, start(9L).dice) + assertEquals(start(9L).seed, 9L) + // And a game replayed from its own state rolls on identically. + val first = start(9L) + val second = BackgammonRules.decodeState(BackgammonRules.encodeState(first)) + val move = BackgammonRules.legalMoves(first).first() + assertEquals( + BackgammonRules.applyMove(first, first.turn, move), + BackgammonRules.applyMove(second, second.turn, move), + ) + } + + @Test + fun `a double is four moves, not two`() { + val doubled = position( + white = mapOf(23 to 2), + black = mapOf(0 to 2), + dice = listOf(3, 3, 3, 3), + ) + assertEquals(4, doubled.dice.size) + var state = doubled + repeat(4) { + val moves = BackgammonRules.legalMoves(state) + assertTrue("ran out of moves after $it", moves.isNotEmpty()) + state = BackgammonRules.applyMove(state, WHITE_SEAT, moves.first()) + } + // Four threes is twelve pips off the count. + assertEquals(doubled.pipCount(WHITE_SEAT) - 12, state.pipCount(WHITE_SEAT)) + } + + // ----------------------------------------------------------------------- + // Moving + // ----------------------------------------------------------------------- + + @Test + fun `a point with two enemy checkers cannot be landed on`() { + val state = position(white = mapOf(23 to 2), black = mapOf(17 to 2), dice = listOf(6, 5)) + // 23 minus 6 is 17, which Black holds. + assertTrue(BackgammonRules.legalMoves(state).none { it.from == 23 && it.die == 6 }) + assertTrue(BackgammonRules.legalMoves(state).any { it.from == 23 && it.die == 5 }) + } + + @Test + fun `a lone enemy checker is hit and goes to the bar`() { + val state = position(white = mapOf(23 to 2), black = mapOf(17 to 1, 0 to 2)) + val hit = BackgammonRules.legalMoves(state).first { it.from == 23 && it.die == 6 } + val after = BackgammonRules.applyMove(state, WHITE_SEAT, hit) + assertEquals(1, after.bar[BLACK_SEAT]) + assertEquals(1, after.countOn(17, WHITE_SEAT)) + assertEquals(0, after.countOn(17, BLACK_SEAT)) + } + + @Test + fun `nothing else moves while a checker is on the bar`() { + val state = position( + white = mapOf(23 to 2, 12 to 3), + black = mapOf(0 to 2), + bar = listOf(1, 0), + ) + val moves = BackgammonRules.legalMoves(state) + assertTrue("everything must come in first", moves.all { it.from == BAR }) + } + + @Test + fun `a closed board leaves a barred player with nothing and the turn passes`() { + // Black holds every point White could come in on, so White cannot move + // at all and the dice go back across the table. + val state = position( + white = mapOf(12 to 3), + black = (18..23).associateWith { 2 }, + bar = listOf(1, 0), + turn = WHITE_SEAT, + dice = listOf(6, 5), + ) + val settled = BackgammonRules.applyMove( + state.copy(turn = BLACK_SEAT, dice = listOf(1, 2)), + BLACK_SEAT, + BackgammonRules.legalMoves(state.copy(turn = BLACK_SEAT, dice = listOf(1, 2))).first(), + ) + assertTrue(settled.moveLog.isNotEmpty()) + } + + // ----------------------------------------------------------------------- + // Using as much of the roll as possible + // ----------------------------------------------------------------------- + + @Test + fun `a move that would strand the other die is not offered`() { + // White has one checker on 23 and Black holds 17. Playing the 6 first + // is blocked outright; playing the 5 first lands on 18, and the 6 then + // carries on to 12. Only the order that plays the whole roll is a move, + // so the 6 is not offered even though six pips of it are playable + // further down the line. + val state = position( + white = mapOf(23 to 1), + black = mapOf(17 to 2, 0 to 2), + dice = listOf(6, 5), + ) + val moves = BackgammonRules.legalMoves(state) + assertEquals("only one first step plays the whole roll", 1, moves.size) + assertEquals(5, moves.single().die) + + // And playing it does leave the 6 to play. + val after = BackgammonRules.applyMove(state, WHITE_SEAT, moves.single()) + assertEquals(WHITE_SEAT, after.turn) + assertEquals(listOf(6), after.dice) + } + + @Test + fun `when only one die can be played it must be the higher`() { + // Either die can be played on its own — 23 to 17, or 23 to 18 — but + // never both, because 12 is shut and that is where eleven pips land + // whichever order they are played in. The rules say take the bigger. + val state = position( + white = mapOf(23 to 1), + black = mapOf(12 to 2, 11 to 2, 0 to 2), + dice = listOf(6, 5), + ) + val moves = BackgammonRules.legalMoves(state) + assertEquals(1, moves.size) + assertEquals("the higher die has to be the one played", 6, moves.single().die) + + // Playing it ends the turn, because the 5 has nowhere to go. + val after = BackgammonRules.applyMove(state, WHITE_SEAT, moves.single()) + assertEquals(BLACK_SEAT, after.turn) + } + + // ----------------------------------------------------------------------- + // Bearing off + // ----------------------------------------------------------------------- + + @Test + fun `nothing comes off until everything is home`() { + val notHome = position(white = mapOf(5 to 2, 12 to 1), black = mapOf(0 to 2)) + assertFalse(notHome.canBearOff(WHITE_SEAT)) + + val home = position(white = mapOf(5 to 2, 3 to 1), black = mapOf(23 to 2)) + assertTrue(home.canBearOff(WHITE_SEAT)) + } + + @Test + fun `an exact die takes a checker off`() { + val state = position( + white = mapOf(5 to 2), + black = mapOf(23 to 2), + dice = listOf(6, 1), + ) + val off = BackgammonRules.legalMoves(state).first { it.from == 5 && it.die == 6 } + val after = BackgammonRules.applyMove(state, WHITE_SEAT, off) + assertEquals(1, after.off[WHITE_SEAT]) + assertEquals(1, after.countOn(5, WHITE_SEAT)) + } + + @Test + fun `a bigger die only takes the furthest checker`() { + // Something on the 6 point, so a 6 must take that and not the one on + // the 3; with the 6 point empty, the 6 may take the furthest instead. + val blocked = position( + white = mapOf(5 to 1, 2 to 1), + black = mapOf(23 to 2), + dice = listOf(6, 6, 6, 6), + ) + assertTrue(BackgammonRules.legalMoves(blocked).none { it.from == 2 }) + + val free = position( + white = mapOf(2 to 2), + black = mapOf(23 to 2), + dice = listOf(6, 6, 6, 6), + ) + assertTrue(BackgammonRules.legalMoves(free).any { it.from == 2 }) + } + + @Test + fun `fifteen off wins the game`() { + val state = position( + white = mapOf(0 to 1), + black = mapOf(23 to 2), + dice = listOf(1, 2), + off = listOf(14, 0), + ) + val last = BackgammonRules.legalMoves(state).first { it.from == 0 } + val after = BackgammonRules.applyMove(state, WHITE_SEAT, last) + assertEquals(BackgammonPhase.GAME_OVER, after.phase) + assertTrue(BackgammonRules.isFinished(after)) + assertNull(BackgammonRules.currentSeat(after)) + } + + @Test + fun `a loser who bore nothing off has lost a gammon`() { + val state = position( + white = mapOf(0 to 1), + black = mapOf(23 to 2), + dice = listOf(1, 2), + off = listOf(14, 0), + ) + val after = BackgammonRules.applyMove( + state, + WHITE_SEAT, + BackgammonRules.legalMoves(state).first { it.from == 0 }, + ) + assertEquals(BackgammonOutcome.WHITE_GAMMON, after.outcome) + assertEquals(2, after.outcome!!.points) + } + + @Test + fun `a loser still on the bar has lost a backgammon`() { + val state = position( + white = mapOf(0 to 1), + black = mapOf(23 to 1), + dice = listOf(1, 2), + off = listOf(14, 0), + bar = listOf(0, 1), + ) + val after = BackgammonRules.applyMove( + state, + WHITE_SEAT, + BackgammonRules.legalMoves(state).first { it.from == 0 }, + ) + assertEquals(BackgammonOutcome.WHITE_BACKGAMMON, after.outcome) + assertEquals(3, after.outcome!!.points) + } + + @Test + fun `a loser who bore one off has only lost a single game`() { + val state = position( + white = mapOf(0 to 1), + black = mapOf(23 to 2), + dice = listOf(1, 2), + off = listOf(14, 1), + ) + val after = BackgammonRules.applyMove( + state, + WHITE_SEAT, + BackgammonRules.legalMoves(state).first { it.from == 0 }, + ) + assertEquals(BackgammonOutcome.WHITE_SINGLE, after.outcome) + assertEquals(1, after.outcome!!.points) + } + + // ----------------------------------------------------------------------- + // Turns + // ----------------------------------------------------------------------- + + @Test + fun `the turn passes when the dice run out`() { + var state = position( + white = mapOf(23 to 2, 12 to 2), + black = mapOf(0 to 2, 11 to 2), + dice = listOf(3, 4), + ) + state = BackgammonRules.applyMove(state, WHITE_SEAT, BackgammonRules.legalMoves(state).first()) + assertEquals("still White's roll", WHITE_SEAT, state.turn) + state = BackgammonRules.applyMove(state, WHITE_SEAT, BackgammonRules.legalMoves(state).first()) + assertEquals(BLACK_SEAT, state.turn) + assertEquals("the new player has dice to play", 2, state.roll.size) + assertTrue(state.dice.isNotEmpty()) + } + + @Test + fun `a whole game can be played out without getting stuck`() { + var state = start(3L) + var plies = 0 + while (!BackgammonRules.isFinished(state) && plies < 4000) { + val moves = BackgammonRules.legalMoves(state) + assertTrue("nobody can move at ply $plies", moves.isNotEmpty()) + state = BackgammonRules.applyMove(state, state.turn, moves.last()) + plies++ + } + assertTrue("the game never finished in $plies plies", BackgammonRules.isFinished(state)) + assertNotNull(state.outcome) + assertEquals(CHECKERS, state.off.max()) + } + + @Test + fun `an illegal move is refused rather than played`() { + val state = start(2L) + assertTrue( + runCatching { + BackgammonRules.applyMove(state, state.turn, BackgammonMove(from = 3, die = 6)) + }.exceptionOrNull() is IllegalArgumentException + ) + assertTrue( + "the other player cannot move on this roll", + runCatching { + BackgammonRules.applyMove( + state, + other(state.turn), + BackgammonRules.legalMoves(state).first(), + ) + }.exceptionOrNull() is IllegalArgumentException + ) + } + + @Test + fun `a state survives the round trip over the wire`() { + val state = start(5L) + assertEquals(state, BackgammonRules.decodeState(BackgammonRules.encodeState(state))) + val move = BackgammonMove(from = BAR, die = 4) + assertEquals(move, BackgammonRules.decodeMove(BackgammonRules.encodeMove(move))) + } + + @Test + fun `nothing is hidden from either seat`() { + val state = start(5L) + assertEquals(state, BackgammonRules.viewFor(state, BLACK_SEAT)) + } + + @Test + fun `the summary says whose roll it is and what they threw`() { + val state = position(white = mapOf(23 to 2), black = mapOf(0 to 2), dice = listOf(6, 5)) + assertTrue(BackgammonRules.summary(state).startsWith("White to play 6-5")) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/checkers/CheckersRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/checkers/CheckersRulesTest.kt new file mode 100644 index 0000000..f92843a --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/checkers/CheckersRulesTest.kt @@ -0,0 +1,350 @@ +package org.prolibertate.games.game.checkers + +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 + +class CheckersRulesTest { + + private fun start(options: CheckersOptions = CheckersOptions()) = + CheckersRules.initialState(options) + + /** A board built by hand, for positions a game would take an hour to reach. */ + private fun position( + black: List = emptyList(), + blackKings: List = emptyList(), + white: List = emptyList(), + whiteKings: List = emptyList(), + turn: Int = BLACK_SEAT, + options: CheckersOptions = CheckersOptions(), + ): CheckersState { + val board = arrayOfNulls(SQUARES) + black.forEach { board[it - 1] = Piece(BLACK_SEAT) } + blackKings.forEach { board[it - 1] = Piece(BLACK_SEAT, king = true) } + white.forEach { board[it - 1] = Piece(WHITE_SEAT) } + whiteKings.forEach { board[it - 1] = Piece(WHITE_SEAT, king = true) } + return start(options).copy(board = board.toList(), turn = turn) + } + + /** Squares are written 1–32 in the notation and 0–31 in the code. */ + private fun square(number: Int) = number - 1 + + // ----------------------------------------------------------------------- + // The board + // ----------------------------------------------------------------------- + + @Test + fun `the thirty-two dark squares map to the board and back`() { + (0 until SQUARES).forEach { square -> + val row = rowOf(square) + val column = columnOf(square) + assertTrue("$square is off the board", row in 0..7 && column in 0..7) + assertEquals("square $square", square, squareAt(row, column)) + // Dark squares are the ones where row and column have different parity. + assertEquals(1, (row + column) % 2) + } + } + + @Test + fun `light squares are not squares at all`() { + assertNull(squareAt(0, 0)) + assertNull(squareAt(7, 7)) + assertNull(squareAt(-1, 0)) + assertNull(squareAt(0, 8)) + // The corners of the playable diagonal. + assertEquals(0, squareAt(0, 1)) + assertEquals(SQUARES - 1, squareAt(7, 6)) + } + + @Test + fun `the opening array is twelve a side on the three nearest rows`() { + val state = start() + assertEquals(12, state.count(BLACK_SEAT)) + assertEquals(12, state.count(WHITE_SEAT)) + assertEquals(0, state.kings(BLACK_SEAT)) + assertEquals(BLACK_SEAT, state.turn) + // The two middle rows are empty, which is what makes an opening move + // possible at all. + (12 until 20).forEach { assertNull(state.board[it]) } + } + + // ----------------------------------------------------------------------- + // Moving + // ----------------------------------------------------------------------- + + @Test + fun `the opening position has seven moves, as every rulebook says`() { + assertEquals(7, CheckersRules.legalMoves(start()).size) + } + + @Test + fun `the move generator counts what it should, four ply down`() { + // The published perft numbers for English draughts. A generator that + // matches them this far has very little room left to be wrong: they + // count compulsory captures, crowning and the men's forward-only rule + // all at once. + assertEquals(7L, CheckersRules.perft(start(), 1)) + assertEquals(49L, CheckersRules.perft(start(), 2)) + assertEquals(302L, CheckersRules.perft(start(), 3)) + assertEquals(1469L, CheckersRules.perft(start(), 4)) + } + + @Test + fun `a man moves forward only, and a king moves both ways`() { + val man = position(black = listOf(14), white = listOf(30)) + assertEquals( + setOf(square(17), square(18)), + CheckersRules.legalMoves(man).map { it.to }.toSet(), + ) + + val king = position(blackKings = listOf(14), white = listOf(30)) + assertEquals( + setOf(square(9), square(10), square(17), square(18)), + CheckersRules.legalMoves(king).map { it.to }.toSet(), + ) + } + + // ----------------------------------------------------------------------- + // Taking + // ----------------------------------------------------------------------- + + @Test + fun `a capture is compulsory`() { + // Black has a jump on the board, so the quiet moves elsewhere are not + // moves at all. + val state = position(black = listOf(14, 12), white = listOf(18, 30)) + val moves = CheckersRules.legalMoves(state) + assertTrue("only the jump may be played", moves.all { it.isJump }) + assertEquals(1, moves.size) + assertEquals(square(23), moves.single().to) + } + + @Test + fun `a double jump is one move, not two`() { + // Two white men lined up for Black to hop over in a single turn. + val state = position(black = listOf(14), white = listOf(18, 27)) + val moves = CheckersRules.legalMoves(state) + assertEquals(1, moves.size) + val jump = moves.single() + assertEquals(2, jump.steps.size) + assertEquals(square(32), jump.to) + + val after = CheckersRules.applyMove(state, BLACK_SEAT, jump) + assertEquals(0, after.count(WHITE_SEAT)) + assertEquals(1, after.count(BLACK_SEAT)) + // One move, so the turn has passed exactly once. + assertEquals(WHITE_SEAT, after.turn) + } + + @Test + fun `stopping halfway through a capture is not offered`() { + val state = position(black = listOf(14), white = listOf(18, 27)) + // The single hop that stops on 23 is a prefix of the double, and the + // rules do not allow it to be played on its own. + assertTrue( + CheckersRules.legalMoves(state).none { it.steps == listOf(square(23)) } + ) + } + + @Test + fun `the same piece is never taken twice in one turn`() { + val state = position(blackKings = listOf(15), white = listOf(18, 19)) + CheckersRules.legalMoves(state).forEach { move -> + val taken = mutableListOf() + var at = move.from + move.steps.forEach { landing -> + jumped(at, landing)?.let { taken += it } + at = landing + } + assertEquals("a piece was jumped twice: $move", taken.size, taken.distinct().size) + } + } + + @Test + fun `a man may not jump backwards`() { + // A white man sits behind the black man; nothing can be taken. + val state = position(black = listOf(18), white = listOf(14, 30)) + assertTrue(CheckersRules.legalMoves(state).none { it.isJump }) + } + + // ----------------------------------------------------------------------- + // Crowning + // ----------------------------------------------------------------------- + + @Test + fun `a man reaching the far row is crowned`() { + val state = position(black = listOf(25), white = listOf(1)) + val move = CheckersRules.legalMoves(state).first() + val after = CheckersRules.applyMove(state, BLACK_SEAT, move) + assertTrue("it should be crowned", after.board[move.to]!!.king) + assertEquals(1, after.kings(BLACK_SEAT)) + // Crowning is progress, so the draw clock goes back to nothing. + assertEquals(0, after.pliesSinceProgress) + } + + @Test + fun `crowning ends the turn under the English rule`() { + // A black man can jump into the crown row with another jump available + // beyond it. Under the English rule it stops and is crowned. + val state = position(black = listOf(22), white = listOf(26, 19), turn = BLACK_SEAT) + val english = CheckersRules.legalMoves(state) + assertTrue(english.isNotEmpty()) + english.filter { rowOf(it.to) == crownRowOf(BLACK_SEAT) }.forEach { + assertEquals("the turn stops on crowning", 1, it.steps.size) + } + } + + // ----------------------------------------------------------------------- + // Kings that fly + // ----------------------------------------------------------------------- + + @Test + fun `a flying king slides the length of the diagonal`() { + val short = position(blackKings = listOf(15), white = listOf(1)) + val long = position( + blackKings = listOf(15), + white = listOf(1), + options = CheckersOptions(flyingKings = true), + ) + assertTrue( + "a flying king reaches further than a walking one", + CheckersRules.legalMoves(long).size > CheckersRules.legalMoves(short).size, + ) + assertTrue(CheckersRules.legalMoves(short).all { it.steps.size == 1 }) + } + + @Test + fun `a flying king takes from a distance and lands where it likes`() { + // The king on 1 and the man on 10 share a diagonal with open board + // beyond it, so the king may take it and carry on to any of the empty + // squares past it. + val state = position( + blackKings = listOf(1), + white = listOf(10), + turn = BLACK_SEAT, + options = CheckersOptions(flyingKings = true), + ) + val takes = CheckersRules.legalMoves(state).filter { move -> + CheckersRules.applyMove(state, BLACK_SEAT, move).count(WHITE_SEAT) == 0 + } + assertTrue("the far man should be takeable", takes.isNotEmpty()) + assertTrue( + "and the king may stop on any square beyond it", + takes.map { it.to }.distinct().size >= 2, + ) + } + + // ----------------------------------------------------------------------- + // Ending + // ----------------------------------------------------------------------- + + @Test + fun `taking the last piece wins`() { + val state = position(black = listOf(14), white = listOf(18)) + val move = CheckersRules.legalMoves(state).single() + val after = CheckersRules.applyMove(state, BLACK_SEAT, move) + assertEquals(CheckersPhase.GAME_OVER, after.phase) + assertEquals(CheckersOutcome.BLACK_WINS, after.outcome) + assertTrue(CheckersRules.isFinished(after)) + } + + @Test + fun `a player with no move loses, pieces or not`() { + // White's single man is in the corner with a black man in front of it + // and a black man behind the landing square, so it cannot move. + val state = position( + black = listOf(23, 27, 26), + white = listOf(32), + turn = BLACK_SEAT, + ) + val quiet = CheckersRules.legalMoves(state).firstOrNull { !it.isJump } + assertNotNull("black needs a quiet move for this test", quiet) + val after = CheckersRules.applyMove(state, BLACK_SEAT, quiet!!) + if (CheckersRules.legalMoves(after, WHITE_SEAT).isEmpty()) { + assertEquals(CheckersOutcome.BLACK_WINS, after.outcome) + } + } + + @Test + fun `shuffling kings about is eventually a draw`() { + val state = position( + blackKings = listOf(15), + whiteKings = listOf(32), + options = CheckersOptions(plyLimitWithoutProgress = 4, threefoldRepetition = false), + ).copy(pliesSinceProgress = 3) + val quiet = CheckersRules.legalMoves(state).first { !it.isJump } + val after = CheckersRules.applyMove(state, BLACK_SEAT, quiet) + assertEquals(CheckersOutcome.DRAW_NO_PROGRESS, after.outcome) + } + + @Test + fun `the same position three times over is a draw`() { + val state = position(blackKings = listOf(15), whiteKings = listOf(32)) + val move = CheckersRules.legalMoves(state).first { !it.isJump } + // Where that move lands, seen twice already: playing it makes three. + val key = CheckersRules.advanced(state, move).positionKey() + val primed = state.copy(repetitionKeys = listOf(key, key)) + + val after = CheckersRules.applyMove(primed, BLACK_SEAT, move) + assertEquals(CheckersOutcome.DRAW_REPETITION, after.outcome) + } + + // ----------------------------------------------------------------------- + // The engine contract + // ----------------------------------------------------------------------- + + @Test + fun `an illegal move is refused rather than played`() { + val state = start() + assertTrue( + runCatching { + CheckersRules.applyMove(state, BLACK_SEAT, CheckersMove(0, listOf(20))) + }.exceptionOrNull() is IllegalArgumentException + ) + assertTrue( + "white cannot move first", + runCatching { + CheckersRules.applyMove(state, WHITE_SEAT, CheckersRules.legalMoves(state).first()) + }.exceptionOrNull() is IllegalArgumentException + ) + } + + @Test + fun `a state survives the round trip over the wire`() { + var state = start() + state = CheckersRules.applyMove(state, BLACK_SEAT, CheckersRules.legalMoves(state).first()) + assertEquals(state, CheckersRules.decodeState(CheckersRules.encodeState(state))) + val move = CheckersMove(from = 10, steps = listOf(14, 21)) + assertEquals(move, CheckersRules.decodeMove(CheckersRules.encodeMove(move))) + } + + @Test + fun `nothing is hidden from either seat`() { + val state = start() + assertEquals(state, CheckersRules.viewFor(state, WHITE_SEAT)) + } + + @Test + fun `a move reads as the notation the game is written in`() { + assertEquals("11-15", CheckersMove(from = square(11), steps = listOf(square(15))).toString()) + assertEquals( + "11x18x25", + CheckersMove(from = square(11), steps = listOf(square(18), square(25))).toString(), + ) + } + + @Test + fun `the summary says whose move it is and how it ended`() { + assertEquals("Black to move", CheckersRules.summary(start())) + val over = start().copy( + phase = CheckersPhase.GAME_OVER, + outcome = CheckersOutcome.WHITE_WINS, + ) + assertEquals(CheckersOutcome.WHITE_WINS.label, CheckersRules.summary(over)) + assertNull(CheckersRules.currentSeat(over)) + assertFalse(CheckersRules.isFinished(start())) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindRulesTest.kt new file mode 100644 index 0000000..77c57dd --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindRulesTest.kt @@ -0,0 +1,285 @@ +package org.prolibertate.games.game.mastermind + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MastermindRulesTest { + + private fun start( + options: MastermindOptions = MastermindOptions(), + seed: Long = 42L, + ) = MastermindRules.initialState(options, seed) + + /** Sets both codes by hand, which is the only way to write a test about them. */ + private fun withSecrets( + first: List, + second: List, + options: MastermindOptions = MastermindOptions(), + ) = start(options).copy(secrets = listOf(first, second)) + + private fun guess(state: MastermindState, code: List) = + MastermindRules.applyMove(state, state.turn, MastermindMove(code)) + + // ----------------------------------------------------------------------- + // Scoring + // ----------------------------------------------------------------------- + + @Test + fun `a peg in the right place is exact and one in the wrong place is not`() { + assertEquals(Feedback(4, 0), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 1, 2, 3))) + assertEquals(Feedback(0, 4), scoreGuess(listOf(0, 1, 2, 3), listOf(3, 2, 1, 0))) + assertEquals(Feedback(2, 2), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 2, 1, 3))) + assertEquals(Feedback(0, 0), scoreGuess(listOf(0, 0, 0, 0), listOf(1, 1, 1, 1))) + } + + @Test + fun `a colour is never counted twice`() { + // Three reds guessed against one red is worth one peg, however they sit. + assertEquals(Feedback(1, 0), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 0, 0, 0))) + assertEquals(Feedback(0, 1), scoreGuess(listOf(0, 1, 2, 3), listOf(4, 4, 4, 0))) + // And one red guessed against three is worth one as well. + assertEquals(Feedback(1, 0), scoreGuess(listOf(0, 0, 0, 1), listOf(0, 2, 3, 4))) + assertEquals(Feedback(2, 2), scoreGuess(listOf(0, 0, 1, 1), listOf(0, 1, 0, 1))) + } + + @Test + fun `scoring reads the same either way round`() { + // Which is what lets the solver test a candidate against a past guess + // rather than having to hold on to the code it was scored against. + val codes = allCodes(MastermindOptions(colours = 4, length = 3)) + codes.take(30).forEach { a -> + codes.take(30).forEach { b -> + assertEquals(scoreGuess(a, b), scoreGuess(b, a)) + } + } + } + + // ----------------------------------------------------------------------- + // Setting up + // ----------------------------------------------------------------------- + + @Test + fun `both players are set a code of the right shape`() { + val state = start() + assertEquals(2, state.secrets.size) + state.secrets.forEach { secret -> + assertEquals(4, secret.size) + assertTrue(secret.all { it in 0 until 6 }) + } + assertEquals(FIRST_SEAT, state.turn) + assertEquals(MastermindPhase.GUESSING, state.phase) + } + + @Test + fun `the same seed sets the same codes, and a different one does not`() { + assertEquals(start(seed = 7L).secrets, start(seed = 7L).secrets) + assertNotEquals(start(seed = 7L).secrets, start(seed = 8L).secrets) + } + + @Test + fun `a game without duplicates never repeats a colour`() { + val options = MastermindOptions(colours = 6, length = 4, allowDuplicates = false) + (0L until 20L).forEach { seed -> + MastermindRules.initialState(options, seed).secrets.forEach { secret -> + assertEquals(secret.size, secret.distinct().size) + } + } + // And the code space is the falling factorial rather than a power. + assertEquals(6 * 5 * 4 * 3, options.codeSpace()) + assertEquals(6 * 5 * 4 * 3, allCodes(options).size) + } + + @Test + fun `every code is offered once and only once`() { + val options = MastermindOptions(colours = 4, length = 3) + val codes = allCodes(options) + assertEquals(4 * 4 * 4, codes.size) + assertEquals(codes.size, codes.distinct().size) + assertEquals(options.codeSpace(), codes.size) + } + + // ----------------------------------------------------------------------- + // Keeping the codes secret + // ----------------------------------------------------------------------- + + @Test + fun `a player is shown their own code and not the one they are breaking`() { + val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1)) + val asFirst = MastermindRules.viewFor(state, FIRST_SEAT) + assertEquals(listOf(0, 1, 2, 3), asFirst.secrets[FIRST_SEAT]) + assertTrue("the code being broken must not cross the wire", asFirst.isHidden(SECOND_SEAT)) + + val asSecond = MastermindRules.viewFor(state, SECOND_SEAT) + assertEquals(listOf(4, 5, 0, 1), asSecond.secrets[SECOND_SEAT]) + assertTrue(asSecond.isHidden(FIRST_SEAT)) + } + + @Test + fun `both codes are shown once the game is over`() { + val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1)) + .copy(phase = MastermindPhase.GAME_OVER, outcome = MastermindOutcome.DRAW_NEITHER) + val seen = MastermindRules.viewFor(state, FIRST_SEAT) + assertFalse(seen.isHidden(SECOND_SEAT)) + assertEquals(state, seen) + } + + @Test + fun `a device that was never told the code cannot score a guess against it`() { + // Which is what stops a client trying to run the host's job locally and + // silently scoring every guess as nothing. + val redacted = MastermindRules.viewFor( + withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1)), + FIRST_SEAT, + ) + val attempt = runCatching { + MastermindRules.applyMove(redacted, FIRST_SEAT, MastermindMove(listOf(0, 0, 0, 0))) + } + assertTrue(attempt.exceptionOrNull() is IllegalArgumentException) + } + + // ----------------------------------------------------------------------- + // Playing + // ----------------------------------------------------------------------- + + @Test + fun `a guess is answered and the turn passes`() { + var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5)) + state = guess(state, listOf(0, 1, 3, 2)) + assertEquals(SECOND_SEAT, state.turn) + assertEquals(1, state.guesses[FIRST_SEAT].size) + // The first player is guessing at the second player's code. + assertEquals(Feedback(0, 0), state.guesses[FIRST_SEAT].single().feedback) + + state = guess(state, listOf(0, 1, 2, 3)) + assertEquals(Feedback(4, 0), state.guesses[SECOND_SEAT].single().feedback) + } + + @Test + fun `the same guess is not offered twice`() { + var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5)) + val opening = listOf(0, 0, 1, 1) + val before = MastermindRules.legalMoves(state, FIRST_SEAT).size + state = guess(state, opening) + state = guess(state, listOf(3, 3, 3, 3)) + val after = MastermindRules.legalMoves(state, FIRST_SEAT) + assertEquals(before - 1, after.size) + assertTrue(after.none { it.code == opening }) + + val repeated = runCatching { + MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(opening)) + } + assertTrue(repeated.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `a guess of the wrong shape is refused`() { + val state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5)) + assertTrue( + runCatching { + MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2))) + }.exceptionOrNull() is IllegalArgumentException + ) + assertTrue( + "a colour outside the game is not a guess", + runCatching { + MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 9))) + }.exceptionOrNull() is IllegalArgumentException + ) + assertTrue( + runCatching { + MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(0, 1, 2, 3))) + }.exceptionOrNull() is IllegalArgumentException + ) + } + + // ----------------------------------------------------------------------- + // Ending + // ----------------------------------------------------------------------- + + @Test + fun `the player who replies always gets an equal number of guesses`() { + var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5)) + // The first player breaks it immediately. The game does not stop + // there — the second player is owed a guess of their own. + state = guess(state, listOf(4, 5, 4, 5)) + assertEquals(MastermindPhase.GUESSING, state.phase) + assertEquals(SECOND_SEAT, state.turn) + + state = guess(state, listOf(0, 0, 0, 0)) + assertEquals(MastermindPhase.GAME_OVER, state.phase) + assertEquals(MastermindOutcome.FIRST_WINS, state.outcome) + assertNull(MastermindRules.currentSeat(state)) + } + + @Test + fun `breaking it in the same round is a draw`() { + var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5)) + state = guess(state, listOf(4, 5, 4, 5)) + state = guess(state, listOf(0, 1, 2, 3)) + assertEquals(MastermindOutcome.DRAW_BOTH, state.outcome) + assertEquals(1, state.brokeIn(FIRST_SEAT)) + assertEquals(1, state.brokeIn(SECOND_SEAT)) + } + + @Test + fun `the player who replies can win it outright`() { + var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5)) + state = guess(state, listOf(0, 0, 0, 0)) + state = guess(state, listOf(0, 1, 2, 3)) + assertEquals(MastermindOutcome.SECOND_WINS, state.outcome) + assertNull(state.brokeIn(FIRST_SEAT)) + } + + @Test + fun `running out of guesses with neither code broken is a draw`() { + val options = MastermindOptions(colours = 6, length = 4, maxGuesses = 2) + var state = withSecrets(listOf(0, 0, 0, 0), listOf(1, 1, 1, 1), options) + repeat(2) { + state = guess(state, listOf(2, 2, 2, it)) + state = guess(state, listOf(3, 3, 3, it)) + } + assertEquals(MastermindOutcome.DRAW_NEITHER, state.outcome) + assertEquals(0, state.guessesLeft(FIRST_SEAT)) + assertTrue(MastermindRules.legalMoves(state, FIRST_SEAT).isEmpty()) + } + + @Test + fun `a state survives the round trip over the wire`() { + var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5)) + state = guess(state, listOf(0, 1, 2, 3)) + assertEquals(state, MastermindRules.decodeState(MastermindRules.encodeState(state))) + val move = MastermindMove(listOf(1, 2, 3, 4)) + assertEquals(move, MastermindRules.decodeMove(MastermindRules.encodeMove(move))) + } + + @Test + fun `the summary counts guesses without ever naming a colour`() { + val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5)) + assertEquals("Guess 1 of 10", MastermindRules.summary(state)) + val over = state.copy( + phase = MastermindPhase.GAME_OVER, + outcome = MastermindOutcome.FIRST_WINS, + ) + assertEquals(MastermindOutcome.FIRST_WINS.label, MastermindRules.summary(over)) + } + + @Test + fun `the codes still standing are exactly those that fit every answer`() { + var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 4, 3, 2)) + state = guess(state, listOf(0, 0, 1, 1)) + state = guess(state, listOf(0, 0, 0, 0)) + state = guess(state, listOf(2, 3, 4, 5)) + + val standing = MastermindRules.consistentCodes(state, FIRST_SEAT) + assertTrue("the real code must survive", listOf(5, 4, 3, 2) in standing) + standing.forEach { candidate -> + state.guesses[FIRST_SEAT].forEach { answered -> + assertEquals(answered.feedback, scoreGuess(candidate, answered.code)) + } + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/pirates/PiratesRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/pirates/PiratesRulesTest.kt new file mode 100644 index 0000000..423099d --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/pirates/PiratesRulesTest.kt @@ -0,0 +1,326 @@ +package org.prolibertate.games.game.pirates + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PiratesRulesTest { + + private fun start(options: PiratesOptions = PiratesOptions()) = + PiratesRules.initialState(options) + + private fun point(name: String) = requireNotNull(pointFromName(name)) { "no point $name" } + + private fun position( + bulgars: List, + pirates: List, + turn: Int = BULGAR_SEAT, + options: PiratesOptions = PiratesOptions(), + ): PiratesState { + val board = arrayOfNulls(POINTS) + bulgars.forEach { board[point(it)] = BULGAR_SEAT } + pirates.forEach { board[point(it)] = PIRATE_SEAT } + return start(options).copy(board = board.toList(), turn = turn) + } + + // ----------------------------------------------------------------------- + // The board + // ----------------------------------------------------------------------- + + @Test + fun `the cross has thirty-three points and a nine-point stronghold`() { + assertEquals(33, POINTS) + assertEquals(9, STRONGHOLD.size) + // Which leaves exactly twenty-four points outside it — the number of + // Bulgars, and not a coincidence. + assertEquals(24, POINTS - STRONGHOLD.size) + assertFalse(isOnBoard(0, 0)) + assertFalse(isOnBoard(6, 6)) + assertTrue(isOnBoard(3, 0)) + assertTrue(isOnBoard(0, 3)) + } + + @Test + fun `lines run orthogonally everywhere and diagonally on the even points`() { + // The centre of the board has all eight; the point beside it has four. + assertEquals(8, ADJACENCY[point("d4")].size) + assertEquals(4, ADJACENCY[point("d5")].size) + // And every line is drawn from both ends. + (0 until POINTS).forEach { from -> + ADJACENCY[from].forEach { to -> + assertTrue("${pointName(from)}–${pointName(to)} is one-way", from in ADJACENCY[to]) + } + } + } + + @Test + fun `points are named as a grid, and the names round-trip`() { + // Reading order, so the stronghold starts at its top-left corner. + assertEquals("c7", pointName(STRONGHOLD.first())) + assertEquals("e5", pointName(STRONGHOLD.last())) + (0 until POINTS).forEach { assertEquals(it, pointFromName(pointName(it))) } + assertNull(pointFromName("a7")) + } + + @Test + fun `the opening array fills the board but the stronghold`() { + val state = start() + assertEquals(24, state.count(BULGAR_SEAT)) + assertEquals(2, state.count(PIRATE_SEAT)) + assertEquals(0, state.stronghold()) + assertEquals(BULGAR_SEAT, state.turn) + // Every point outside the stronghold is occupied by a Bulgar. + (0 until POINTS).filterNot { isStronghold(it) }.forEach { + assertEquals(BULGAR_SEAT, state.board[it]) + } + } + + // ----------------------------------------------------------------------- + // The Bulgars + // ----------------------------------------------------------------------- + + @Test + fun `Bulgars move one step and never backwards`() { + val state = position(bulgars = listOf("d4"), pirates = listOf("c7", "e7")) + val moves = PiratesRules.legalMoves(state) + assertTrue("every Bulgar move is a single step", moves.all { it.steps.size == 1 }) + moves.forEach { + assertTrue( + "${pointName(it.to)} is behind ${pointName(it.from)}", + rowOf(it.to) <= rowOf(it.from), + ) + } + // d4 is the centre, so eight lines out of it and three of them go back. + assertEquals(5, moves.size) + } + + @Test + fun `Bulgars may retreat when the table says so`() { + val penned = position( + bulgars = listOf("d4"), + pirates = listOf("c7", "e7"), + options = PiratesOptions(bulgarsMayNotRetreat = false), + ) + assertEquals(8, PiratesRules.legalMoves(penned).size) + } + + @Test + fun `Bulgars cannot take anything`() { + // A pirate stands next to a Bulgar with an empty point beyond it, which + // would be a capture if the Bulgars could capture. + val state = position(bulgars = listOf("d5"), pirates = listOf("d6", "c7")) + assertTrue(PiratesRules.legalMoves(state).all { it.steps.size == 1 }) + assertTrue(PiratesRules.legalMoves(state).none { isJumpFrom(it.from, it.to) }) + } + + // ----------------------------------------------------------------------- + // The pirates + // ----------------------------------------------------------------------- + + @Test + fun `a pirate takes by jumping and must take when it can`() { + val state = position( + bulgars = listOf("d5", "d3", "b4", "f4", "c1", "e1", "d1", "a3", "g3"), + pirates = listOf("d6", "c7"), + turn = PIRATE_SEAT, + ) + val moves = PiratesRules.legalMoves(state) + assertTrue("with a jump on the board nothing else is a move", moves.isNotEmpty()) + assertTrue(moves.all { isJumpFrom(it.from, it.steps.first()) }) + + val jump = moves.first { it.from == point("d6") } + val after = PiratesRules.applyMove(state, PIRATE_SEAT, jump) + // One Bulgar per hop — and the hops carry on while there is another to + // take, so this one takes two. + assertEquals(9 - jump.steps.size, after.count(BULGAR_SEAT)) + assertTrue(jump.steps.size >= 1) + assertEquals(0, after.pliesSinceProgress) + } + + @Test + fun `a chain of jumps is one turn`() { + // Two Bulgars in a line with a gap past each, so the pirate takes both. + val state = position( + bulgars = listOf("d6", "d4", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2"), + pirates = listOf("d7", "c7"), + turn = PIRATE_SEAT, + ) + val chains = PiratesRules.legalMoves(state).filter { it.steps.size > 1 } + assertTrue("the double jump should be there", chains.isNotEmpty()) + val chain = chains.first() + val after = PiratesRules.applyMove(state, PIRATE_SEAT, chain) + assertEquals(10 - chain.steps.size, after.count(BULGAR_SEAT)) + // One move, so the turn has passed exactly once. + assertEquals(BULGAR_SEAT, after.turn) + } + + @Test + fun `a pirate that cannot take may walk`() { + val state = position( + bulgars = listOf("a3", "g3", "d1", "c1", "e1", "c2", "e2", "a4", "g4"), + pirates = listOf("c7", "e7"), + turn = PIRATE_SEAT, + ) + val moves = PiratesRules.legalMoves(state) + assertTrue(moves.isNotEmpty()) + assertTrue(moves.all { it.steps.size == 1 }) + } + + @Test + fun `the same Bulgar is never taken twice in one chain`() { + val state = position( + bulgars = listOf("d6", "d4", "c5", "b4", "c1", "e1", "d1", "a3", "g3", "f4"), + pirates = listOf("d7", "e7"), + turn = PIRATE_SEAT, + ) + PiratesRules.legalMoves(state).forEach { move -> + val taken = mutableListOf() + var at = move.from + move.steps.forEach { landing -> + jumpedBetween(at, landing)?.let { taken += it } + at = landing + } + assertEquals("a Bulgar was jumped twice: $move", taken.size, taken.distinct().size) + } + } + + // ----------------------------------------------------------------------- + // Ending + // ----------------------------------------------------------------------- + + @Test + fun `filling the stronghold wins it for the Bulgars`() { + // Eight of the nine points held, the ninth empty at c5, and a Bulgar on + // b5 one step to its left. The pirates have been driven out of it + // entirely, which is the only way this position arises. + val held = STRONGHOLD.map { pointName(it) }.filterNot { it == "c5" } + val state = position( + bulgars = held + listOf("b5"), + pirates = listOf("a4", "g4"), + turn = BULGAR_SEAT, + ) + assertEquals(8, state.stronghold()) + + val into = PiratesRules.legalMoves(state) + .single { it.from == point("b5") && it.to == point("c5") } + val after = PiratesRules.applyMove(state, BULGAR_SEAT, into) + + assertEquals(9, after.stronghold()) + assertEquals(PiratesOutcome.BULGARS_STORM, after.outcome) + assertTrue(PiratesRules.isFinished(after)) + } + + @Test + fun `cutting the Bulgars below nine wins it for the pirates`() { + // Nine Bulgars, one of them about to be jumped: eight cannot fill a + // nine-point stronghold, so the assault is over. + val state = position( + bulgars = listOf("d5", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2"), + pirates = listOf("d6", "c7"), + turn = PIRATE_SEAT, + ) + val jump = PiratesRules.legalMoves(state).first { isJumpFrom(it.from, it.steps.first()) } + val after = PiratesRules.applyMove(state, PIRATE_SEAT, jump) + assertEquals(PiratesOutcome.PIRATES_CUT_DOWN, after.outcome) + } + + @Test + fun `pirates with nowhere to go have lost`() { + // Both pirates in the stronghold's back corners with Bulgars filling + // every point they could step or jump to. + val state = position( + bulgars = STRONGHOLD.map { pointName(it) }.filterNot { it == "c7" || it == "e7" } + + listOf("c4", "d4", "e4", "b4", "f4", "a3", "g3", "d1"), + pirates = listOf("c7", "e7"), + turn = BULGAR_SEAT, + ) + // Whatever the Bulgars play, if the pirates end up stuck they lose. + val move = PiratesRules.legalMoves(state).firstOrNull() + if (move != null) { + val after = PiratesRules.applyMove(state, BULGAR_SEAT, move) + if (after.phase == PiratesPhase.GAME_OVER) { + assertTrue( + after.outcome == PiratesOutcome.BULGARS_PEN_IN || + after.outcome == PiratesOutcome.BULGARS_STORM + ) + } + } + } + + @Test + fun `a game that gets nowhere for a long time is a draw`() { + val state = position( + bulgars = listOf("d5", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2", "e2"), + pirates = listOf("c7", "e7"), + turn = BULGAR_SEAT, + options = PiratesOptions(plyLimitWithoutProgress = 2), + ).copy(pliesSinceProgress = 1) + val quiet = PiratesRules.legalMoves(state).first { !isStronghold(it.to) } + val after = PiratesRules.applyMove(state, BULGAR_SEAT, quiet) + assertEquals(PiratesOutcome.DRAW_STALEMATE, after.outcome) + } + + // ----------------------------------------------------------------------- + // The engine contract + // ----------------------------------------------------------------------- + + @Test + fun `the opening has a move for the crowd and the generator counts straight`() { + val state = start() + val moves = PiratesRules.legalMoves(state) + assertTrue(moves.isNotEmpty()) + assertTrue(moves.all { state.board[it.from] == BULGAR_SEAT }) + assertEquals(moves.size.toLong(), PiratesRules.perft(state, 1)) + assertTrue(PiratesRules.perft(state, 2) > moves.size.toLong()) + } + + @Test + fun `an illegal move is refused rather than played`() { + val state = start() + assertTrue( + runCatching { + PiratesRules.applyMove(state, PIRATE_SEAT, PiratesRules.legalMoves(state).first()) + }.exceptionOrNull() is IllegalArgumentException + ) + assertTrue( + "a Bulgar cannot walk onto an occupied point", + runCatching { + PiratesRules.applyMove( + state, + BULGAR_SEAT, + PiratesMove(from = point("d1"), steps = listOf(point("d2"))), + ) + }.exceptionOrNull() is IllegalArgumentException + ) + } + + @Test + fun `a state survives the round trip over the wire`() { + var state = start() + state = PiratesRules.applyMove(state, BULGAR_SEAT, PiratesRules.legalMoves(state).first()) + assertEquals(state, PiratesRules.decodeState(PiratesRules.encodeState(state))) + val move = PiratesMove(from = 0, steps = listOf(1)) + assertEquals(move, PiratesRules.decodeMove(PiratesRules.encodeMove(move))) + } + + @Test + fun `nothing is hidden from either seat`() { + val state = start() + assertEquals(state, PiratesRules.viewFor(state, PIRATE_SEAT)) + } + + @Test + fun `the summary counts the stronghold and the crowd`() { + val summary = PiratesRules.summary(start()) + assertTrue(summary.contains("0 of 9")) + assertTrue(summary.contains("24 Bulgars")) + val over = start().copy( + phase = PiratesPhase.GAME_OVER, + outcome = PiratesOutcome.PIRATES_CUT_DOWN, + ) + assertEquals(PiratesOutcome.PIRATES_CUT_DOWN.label, PiratesRules.summary(over)) + assertNull(PiratesRules.currentSeat(over)) + } +} diff --git a/docs/RULES-backgammon.md b/docs/RULES-backgammon.md new file mode 100644 index 0000000..637f836 --- /dev/null +++ b/docs/RULES-backgammon.md @@ -0,0 +1,96 @@ +# Backgammon + +Twenty-four points, fifteen checkers a side, and two dice. Bring everything +round to your home board and bear it off before your opponent does. + +## The board + +Points are numbered 1 to 24. **White runs down the numbers and bears off past +1; Black runs up them and bears off past 24.** White's home board is points 1–6, +Black's is 19–24. + +A point belongs to whoever has two or more checkers on it, and is closed to the +other player. A point with exactly one checker on it is a **blot**, and can be +hit. + +## The opening + +Each player throws one die and the higher number starts, playing both dice as +thrown. A tie is thrown again, so the game never opens on a double. + +## A turn + +Throw two dice and move one checker for each. **A double is four moves**, not +two. + +The rule that decides most disputes is this one: **you must use as much of the +roll as you can.** If there is a way to play both dice, you must play both, even +when playing one would suit you better. If only one can be played, it must be +the higher one. The app enforces this by working out every way the whole roll +could be played before offering you anything, so a move that would strand the +other die is simply never on the board. That is why a legal-looking move +sometimes cannot be tapped: playing it would waste half your roll. + +## Hitting and the bar + +Landing on a blot sends it to the bar. A player with a checker on the bar must +bring it back on before moving anything else, entering in the opponent's home +board: White enters on 24 minus the die, Black on the die itself. If every entry +point is closed the turn is lost, and it can be lost several times over. + +## Bearing off + +Once all fifteen checkers are in your home board you may start bearing off. An +exact die takes a checker off the point that needs it. A bigger die may take one +off your highest occupied point, but only when nothing is standing further back — +an overshooting die may never take a checker that a smaller die could have +reached. + +## Winning + +Fifteen off wins. How much it is worth depends on how badly the loser lost: + +| | | +| --- | --- | +| **Single**, 1 point | the loser has borne at least one checker off | +| **Gammon**, 2 points | the loser has borne nothing off | +| **Backgammon**, 3 points | and still has a checker on the bar or in the winner's home | + +Counting gammons is an option. It changes nothing about the moves and everything +about whether a hopeless game is worth playing out. + +## Not implemented + +**There is no doubling cube.** A single game is played rather than a match to a +number of points, and with nothing being kept between games there is nothing for +a cube to double. It is the largest thing missing from this implementation and +the one a serious player will notice first. + +## The dice + +Every die in the game comes from the table's own seed and a count of how many +rolls have been made, so a game is reproducible from the state it started in and +a host and a client can never disagree about what was thrown. The seed travels +with the state, which is safe: backgammon has nothing hidden, and the host is +authoritative about every roll in any case. + +## What the computer does + +Not a search in the sense the chess and checkers players are one — there is no +tree to walk, because the opponent's reply depends on dice nobody has thrown +yet. Instead it weighs **whole turns**: every distinct way the roll can be +played, scored by the board it leaves behind. + +Weighing turns rather than checkers is the point. A backgammon move is only good +in company — splitting the back checkers is right or wrong depending on what the +other die did — and a player choosing one checker at a time would never see it. + +At the strongest level it also averages what the dice might do to it next: all +twenty-one distinct rolls, weighted properly (6-5 comes up twice as often as +5-5), taking the opponent's best answer to each. + +The evaluation is built on the pip count, because backgammon is a race. Around +that: a blot is only a liability if it can be hit, so exposure is weighted by +how far away the nearest enemy checker is; a made point in your own home board +is worth having because it is one more number your opponent cannot come in on; +and a long run of consecutive points is what traps a checker behind it. diff --git a/docs/RULES-checkers.md b/docs/RULES-checkers.md new file mode 100644 index 0000000..41d9fe3 --- /dev/null +++ b/docs/RULES-checkers.md @@ -0,0 +1,95 @@ +# Checkers + +English draughts, on the thirty-two dark squares of an eight-by-eight board. +Twelve pieces a side, Black moves first. + +## The board + +Squares are numbered 1 to 32 the way the game has always been written down: +square 1 at the top left, running along each row and down the board to 32. Black +starts on 1–12, White on 21–32, and the two middle rows are empty. Only the dark +squares are ever played on, so a move is two numbers — `11-15` for a quiet move, +`11x18` for a jump. + +## Moving + +A man moves one square diagonally **forwards**. Black moves down the board, +White up it. + +A man that reaches the far row is **crowned** and becomes a king, which moves +one square diagonally in either direction. + +## Taking + +You take by jumping an enemy piece and landing on the empty square directly +beyond it. Three rules follow, and all three are enforced by the engine rather +than left to the players: + +- **A capture is compulsory.** If anything can be taken anywhere on the board, + a quiet move is not a legal move at all. The app simply will not offer one. +- **A multiple jump is one turn.** Where a jump can be followed by another with + the same piece, it must be — and the whole chain is a single move. A shorter + prefix of a longer capture is never offered, so there is no way to stop + halfway. +- **A man may not jump backwards.** Only kings take in both directions. + +You do *not* have to take the longest capture available: any complete jump will +do. That is the English rule; international draughts requires the maximum. + +## Crowning mid-jump + +A man that jumps into the far row is crowned and **the turn ends there**, even +with another jump available. This is the English rule and it is on by default. +Turn *Crowning ends the turn* off and the man carries on jumping as a man, +crowned only if it finishes on the back row — which is the international rule. + +## Winning + +A player who has no pieces, or who has pieces but no legal move, has lost. The +two are the same condition as far as the engine is concerned: no moves is a +loss, whatever the reason. + +## Draws + +- **Threefold repetition** — the same position with the same player to move, + three times over. +- **Eighty plies without progress** — forty moves each with nothing taken and + nothing crowned. Both are options. + +## Flying kings + +Off by default. Turned on, a king slides any distance along a diagonal and takes +an enemy piece from a distance, landing on any empty square beyond it — the +international king. + +**One honest deviation:** a piece taken by a flying king is lifted immediately. +International draughts leaves captured pieces standing until the turn ends, so +they block the rest of the sequence, which occasionally forbids a chain this +implementation allows. Fixing it properly means tracking captured-but-present +pieces through the whole search, and it is written down here rather than +pretended away. + +## What the computer does + +Negamax with alpha-beta pruning and iterative deepening, to seven ply at the +strongest level — deeper than the chess player reaches, because compulsory +capture keeps the branching factor low. + +There is no quiescence search and it does not need one: the usual failure of a +fixed-depth search is stopping in the middle of an exchange, and compulsory +capture handles that by itself, since a position where a piece has just been +taken usually has the recapture as its only legal move. + +Its evaluation counts material first, with kings worth well over a man, and then +two things beginners underrate: **men are worth more the closer they are to +being crowned**, and **the back row is worth holding**, because an empty back +row is a road to a crown for the other side. The two pull against each other, +which is about right. + +## The move generator is checked against published numbers + +`CheckersRulesTest` counts the leaf nodes below the opening position — 7, 49, +302 and 1469 at one to four ply — and those are the published figures for +English draughts. A generator that matches them that far has very little room +left to be wrong: the counts fold in compulsory capture, crowning and the +men's forward-only rule all at once. diff --git a/docs/RULES-mastermind.md b/docs/RULES-mastermind.md new file mode 100644 index 0000000..d208949 --- /dev/null +++ b/docs/RULES-mastermind.md @@ -0,0 +1,79 @@ +# Mastermind + +A hidden code of coloured pegs, and a fixed number of guesses to break it. + +## Played as a duel + +The pencil-and-paper game is one person setting a code and another breaking it, +which makes a poor two-handed game: one player would sit and watch. + +Here **both players are set a code and both are breaking one**, a guess each in +turn. Whoever cracks theirs first wins. + +That symmetry is why the game ends at the foot of a round rather than the moment +somebody gets it. If the player who went first breaks the code on their sixth +guess, the player who replied still gets a sixth guess of their own — and +equalling it is a draw. Ending it any earlier would make going first worth half +a guess. + +## The answer + +Each guess is answered with pegs: + +- a **dark** peg for a colour in the right place; +- a **light** peg for a colour that is in the code but somewhere else. + +**A colour is never counted twice.** Three reds guessed against a code with one +red is worth exactly one peg, however they are arranged. The pegs say how many, +never which, and they are deliberately not lined up under the code they answer — +a tidy row would invite reading a position into them that is not there. + +## Options + +| | | +| --- | --- | +| **Colours** | 6 or 8 | +| **Pegs** | 3, 4 or 5 | +| **Guesses each** | 8, 10 or 12 | +| **Repeated colours** | on by default; off makes every peg a different colour | +| **Opponent** | Casual, Club or Strong | + +The setup screen shows how many codes are possible, which is the honest measure +of how hard a game will be: 1,296 for the standard four pegs and six colours, +32,768 for five pegs and eight. + +## Ending + +- One player breaks it and the other does not — that player wins. +- Both break it in the same round — a draw. +- Neither breaks it before the guesses run out — a draw. + +## Keeping the codes secret + +This is the only game here with anything to hide, and it is the only one whose +`viewFor` does real work. The host holds both codes and sends each device **only +its own**: the code you are trying to break is never on your device in the first +place, so there is nothing to find by digging through what arrived over the +network. + +A guess is scored by the host, which is the only machine that can score it. A +device asked to score a guess against a code it was never given refuses rather +than quietly answering "nothing" — tested, because that failure would look like +a very hard code rather than like a bug. + +Both codes are revealed when the game ends. There is nothing left to protect, +and a code breaker wants to see what they were up against. + +## What the computer does + +There is no tree to search — a guess cannot be answered badly, and the opponent +makes no reply — so the whole game is in choosing what to try next. + +- **Casual** guesses anything it has not tried, ruled out or not. +- **Club** never guesses a code the answers have already eliminated. That one + rule is most of the strength, and it cracks a standard code in about five. +- **Strong** is Knuth's idea one ply deep: among the codes still standing, it + plays the one whose *worst* possible answer leaves the fewest candidates + behind, so it cannot be handed a reply that tells it little. Both lists are + sampled when they are large — the full calculation on a fresh game is over a + million scorings for a choice a sample makes just as well. diff --git a/docs/RULES-pirates.md b/docs/RULES-pirates.md new file mode 100644 index 0000000..4fd9c98 --- /dev/null +++ b/docs/RULES-pirates.md @@ -0,0 +1,105 @@ +# Pirates and Bulgars + +Two pirates hold a stronghold at the head of a cross-shaped board. Twenty-four +Bulgars come at it from the other three arms. Only one side can take pieces, and +it is not the big one. + +## Note the caveats about the reconstruction + +**"Dalmatian Pirates and the Volga Bulgars" is Sid Sackson's theming, in *The +Book of Classic Board Games*, of the game usually called Asalto** — itself a +nineteenth-century descendant of Fox and Geese. That much is attested. + +The book could not be obtained, so **what is implemented here is Asalto**, and +where Asalto's own sources disagree a choice had to be made. What follows says +which is which, in the same spirit as the Ta Yü document: the game is playable +and coherent, and it is not a transcription of a rulebook nobody here has read. + +**Attested** — the shape of the board and its thirty-three points; the +stronghold being one nine-point arm; two hunters against twenty-four; the +hunters taking by jumping and the crowd being unable to take at all; the crowd +winning by filling the stronghold or by penning the hunters in; the hunters +winning by cutting the crowd below the number needed to fill it. + +**Chosen here** — + +- **Which side is which.** Sackson's theme names two peoples; nothing available + said which of them is the pair and which the crowd. The pirates are the two, + on the reasoning that a raiding party is small and a stronghold is what you + raid. +- **The diagonals.** Boards of this family are printed with diagonals in a + pattern rather than everywhere. The pattern used is the usual one: a diagonal + exists where the row and column of a point add to an even number, which gives + the centre eight lines and its neighbours four. +- **Compulsory capture instead of huffing.** Traditionally a hunter who fails to + take when it could is *huffed* — removed by the other player. That is the same + rule with an extra step and a way to forget to use it, so a pirate that can + take simply must. It is an option, and turning it off restores the choice + without restoring the punishment. +- **Bulgars may not retreat.** Standard for the soldiers in Asalto, and stated + here as an option because it is the single rule that most changes how the game + feels. +- **The draw rule.** No source gives one. Sixty plies with nothing taken and no + ground gained is called a draw, because two sides that can each hold a + position otherwise have no reason ever to stop. + +## The board + +``` + c7 d7 e7 ← the stronghold, nine points + c6 d6 e6 + c5 d5 e5 +a4 b4 c4 d4 e4 f4 g4 +a3 b3 c3 d3 e3 f3 g3 (the middle band is seven wide) +a2 b2 c2 d2 e2 f2 g2 + c1 d1 e1 +``` + +Thirty-three points: a seven-by-seven grid with the four corners cut away. The +top arm — nine points — is the stronghold, which leaves exactly twenty-four +points outside it. That is not a coincidence: the board and the pieces are cut +to each other, and the game opens with every point occupied except the seven the +pirates are not standing on. + +Lines run orthogonally between all neighbouring points, and diagonally where the +row and column add to an even number. **The lines the screen draws come from the +same table the moves come from**, so what you can see and what you may play +cannot disagree. + +## The two sides + +**The Bulgars** — twenty-four. Move one step along a line, towards the +stronghold or across it, **never back down the board**. They cannot take +anything at all. + +**The pirates** — two. Move one step along a line in any direction, and take by +jumping an adjacent Bulgar to the empty point directly beyond. A jump may be +followed by another with the same pirate, and then it must be: a chain is one +turn, and it cannot be abandoned halfway. + +The Bulgars move first. The pirates are already where they want to be. + +## Winning + +**The Bulgars win** by occupying all nine points of the stronghold, or by +leaving the pirates with no legal move. + +**The pirates win** by cutting the Bulgars below nine, at which point there are +not enough of them left to fill the stronghold however long the game runs. + +## What the computer does + +Negamax with alpha-beta pruning and iterative deepening, over the same rules +engine the game runs on. + +The interesting part of a hunt game is that the two sides want opposite shapes +out of the same board, and one evaluation has to speak for both. The terms are +written from the Bulgars' point of view and negated for the pirates, so the two +cannot drift apart and start valuing the same position differently. + +What it counts: how many Bulgars are left and how far up the board they have +come; how much of the stronghold they hold; how much room the pirates have to +work in; and — the term that does the most work — **how many Bulgars are +standing where a pirate could jump them**. A Bulgar with an empty point behind +it on a line is a Bulgar about to be taken, and not being jumpable is most of +staying alive in this game.