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.