From 71e58668834ab7ece706ac6a7ef6899cfeb40aef Mon Sep 17 00:00:00 2001 From: adrianoftyriel Date: Thu, 6 Aug 2026 21:56:09 -0600 Subject: [PATCH 1/2] Add Nine Men's Morris and Pirates and Bulgars to unimplemented list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e475e13..ac1b245 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The app shell is complete and nine games are playable end to end: | | | | --- | --- | | **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess, Ta Yü | -| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers | +| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Nine Men's Morris, Pirates and Bulgars | 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, From 94b51347c3d3b94793c87ff3160534bfb89f26e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 05:52:26 +0000 Subject: [PATCH 2/2] Add Nine Men's Morris MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine pieces each on twenty-four points: place them, then move them, and every line of three takes an enemy piece off the board. Two pieces left, or no legal move, and the game is over. See docs/RULES-morris.md. The board is numbered ring * 8 + index, so its geometry falls out arithmetically rather than being typed in as a table of twenty-four rows: neighbours around a square are the index either side, the ladders between squares run through the odd indices alone, and a mill is three consecutive points centred on an odd one. A table would say the same thing and could be wrong in one row. A turn is one move object — the piece placed or stepped, and the enemy piece taken when that closes a mill. Generating the removal as part of the move rather than as a turn of its own means there is no moment where a player is halfway through a turn, so a device that drops off the network between closing a mill and choosing what to take cannot leave the table stuck, and the search counts one ply for what is one decision. Three rules about mills are easy to get backwards and all three are tested: the piece that arrives is what closes a mill, so stepping out takes nothing and stepping back in pays again; a slide along the line being completed is not a mill, because the third piece it needs is the one that just moved; and a piece in a mill is spared unless every enemy piece is in one, without which a player could never be reduced and the game would not end. The AI is negamax with alpha-beta and iterative deepening over the same rules engine, with a node budget so the placing phase does not stall a phone. It has no quiescence tail and does not need one: there are no exchanges in Morris. Its evaluation knows that being down to three is dangerous even with flying on — without that term the search reads three pieces going anywhere as freedom and starts steering towards losing pieces to get there, which is a real game it would throw away. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud --- README.md | 8 +- .../org/prolibertate/games/game/Catalog.kt | 11 + .../games/game/morris/MorrisAi.kt | 234 ++++++++ .../games/game/morris/MorrisModel.kt | 242 ++++++++ .../games/game/morris/MorrisRules.kt | 257 +++++++++ .../prolibertate/games/ui/GameSetupScreen.kt | 56 +- .../org/prolibertate/games/ui/PlayScreen.kt | 26 + .../games/ui/game/MorrisScreen.kt | 530 ++++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 1 + .../games/game/morris/MorrisAiTest.kt | 149 +++++ .../games/game/morris/MorrisRulesTest.kt | 476 ++++++++++++++++ docs/RULES-morris.md | 130 +++++ 12 files changed, 2116 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/morris/MorrisAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/morris/MorrisModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/morris/MorrisRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/MorrisScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/morris/MorrisAiTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/morris/MorrisRulesTest.kt create mode 100644 docs/RULES-morris.md diff --git a/README.md b/README.md index b3d32df..9e0e83c 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN). ## What works today -The app shell is complete and nine games are playable end to end: +The app shell is complete and ten games are playable end to end: | | | | --- | --- | -| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess, Ta Yü | -| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Nine Men's Morris, Pirates and Bulgars | +| **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 | 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, @@ -269,6 +269,7 @@ game: - [Wizard](docs/RULES-wizard.md) - [Crazy 8s](docs/RULES-crazy8s.md) - [Chess](docs/RULES-chess.md) +- [Nine Men's Morris](docs/RULES-morris.md) - [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 @@ -287,6 +288,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested wizard/ Wizard model, rules, AI crazyeights/ Crazy 8s model, rules, AI chess/ Chess model, FEN, move generation, search + morris/ Nine Men's Morris board geometry, mills, rules, 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 c543f10..3e93c67 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -39,6 +39,7 @@ object GameCatalog { const val CHESS = "chess" const val CHECKERS = "checkers" const val TAYU = "tayu" + const val MORRIS = "morris" val all: List = listOf( GameDescriptor( @@ -151,6 +152,16 @@ object GameCatalog { blurb = "Forced captures, kings, and the long diagonal.", available = false, ), + GameDescriptor( + id = MORRIS, + title = "Nine Men's Morris", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.", + available = true, + ), GameDescriptor( id = TAYU, title = "Ta Yü", diff --git a/app/src/main/java/org/prolibertate/games/game/morris/MorrisAi.kt b/app/src/main/java/org/prolibertate/games/game/morris/MorrisAi.kt new file mode 100644 index 0000000..fbc65ae --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/morris/MorrisAi.kt @@ -0,0 +1,234 @@ +package org.prolibertate.games.game.morris + +import kotlin.random.Random +import org.prolibertate.games.game.engine.GameAi + +/** + * A negamax search with alpha-beta pruning and iterative deepening. + * + * There is no quiescence tail here, and there does not need to be one: the + * thing a fixed-depth search gets wrong in chess is stopping halfway through an + * exchange, and in Morris there are no exchanges. A piece is taken for closing + * a mill and nothing is taken back, so a position is as quiet as it looks. + * + * What does need care is the opening. Placing gives twenty-four choices a ply + * and they are nearly all equal on material, so the evaluation has to say + * something about shape — mills half-built, and the mid-edge points that carry + * the ladders between squares — or the search spends its depth shuffling + * indistinguishable moves. + * + * Strength comes from the table's own options unless a level is passed in. + */ +class MorrisAi(private val level: MorrisLevel? = null) : GameAi { + + /** + * Ceiling on how much thinking any one move gets, so a phone does not stall + * in the placing phase where the branching factor is at its widest. The + * search stops where it is and plays the best move from the last depth it + * managed to finish. + */ + private var nodeBudget = 0 + private var nodesUsed = 0 + + override fun chooseMove(state: MorrisState, seat: Int, legal: List): MorrisMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val setting = level ?: state.options.level + if (legal.size == 1) return legal.first() + + nodeBudget = NODE_BUDGET + nodesUsed = 0 + + // Deterministic for a given position, so a host and a client replaying + // the same game land on the same move, but varied enough that two games + // from an empty board do not run identically. + 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) { + // A full window for every root move rather than a narrowing + // one: it prunes less, but it is what makes the scores + // comparable, and the weaker levels pick from among the + // near-best rather than from the best alone. + val score = -negamax( + state = MorrisRules.advanced(state, move), + depth = depth - 1, + alpha = -INFINITY, + beta = INFINITY, + ply = 1, + ) + results += move to score + if (nodesUsed >= nodeBudget) break + } + // A half-finished iteration has scored only some of the moves, so + // it is thrown away rather than compared against a complete one. + 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 + } + + /** How far below best a move may score and still be considered. */ + private fun MorrisLevel.slack(): Int = when (this) { + MorrisLevel.CASUAL -> 60 + MorrisLevel.CLUB -> 15 + MorrisLevel.STRONG -> 0 + } + + // ----------------------------------------------------------------------- + // Search + // ----------------------------------------------------------------------- + + private fun negamax(state: MorrisState, depth: Int, alpha: Int, beta: Int, ply: Int): Int { + nodesUsed++ + + terminalScore(state, ply)?.let { return it } + if (depth <= 0) return evaluate(state) + + val moves = MorrisRules.legalMoves(state) + // The terminal check above has already caught a player with no move, so + // an empty list here would mean the rules and the search disagree. + if (moves.isEmpty()) return evaluate(state) + + var localAlpha = alpha + for (move in order(moves, preferred = null)) { + val score = -negamax( + state = MorrisRules.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 + } + + /** + * A finished game, scored from the side to move. + * + * The rules leave the loser on the clock when they end the game, so in a + * finished position the side to move is always the one that lost. Distance + * is subtracted so the search prefers winning sooner and, when it is + * losing, holding out as long as it can. + */ + private fun terminalScore(state: MorrisState, ply: Int): Int? { + if (state.phase != MorrisPhase.GAME_OVER) return null + return when (state.outcome) { + MorrisOutcome.DRAW_REPETITION, MorrisOutcome.DRAW_NO_MILL, null -> 0 + else -> -(WIN - ply) + } + } + + /** + * Mills first, then moves that leave a mill half-built. + * + * Ordering is worth more here than a cleverer evaluation would be: a mill + * takes a piece, and searching the move that takes one first gives every + * quieter move below it a bound to be cut against. + */ + private fun order(moves: List, preferred: MorrisMove?): List = + moves.sortedByDescending { move -> + when { + move == preferred -> 1_000 + move.remove != null -> 100 + // A move onto a mid-edge point has three ways off it rather + // than two, which is worth looking at before a corner. + isMidEdge(move.to) -> 10 + else -> 0 + } + } + + // ----------------------------------------------------------------------- + // Evaluation + // ----------------------------------------------------------------------- + + /** The position from the point of view of the side to move. */ + private fun evaluate(state: MorrisState): Int { + val us = state.turn + val them = other(us) + + var score = PIECE * (state.remaining(us) - state.remaining(them)) + score += MILL * (state.millCount(us) - state.millCount(them)) + score += ALMOST * (almostMills(state, us) - almostMills(state, them)) + score += MOBILITY * (mobility(state, us) - mobility(state, them)) + score += LADDER * (midEdgeCount(state, us) - midEdgeCount(state, them)) + score -= DANGER * (onThree(state, us) - onThree(state, them)) + return score + } + + /** + * Being down to three is a bad place to be, whatever the freedom to fly is + * worth: one more piece and the game is over. Saying so costs a term, and + * not saying it is worse than it sounds — with flying on, the search would + * otherwise notice that three pieces can go anywhere and start treating + * losing one as a way of getting there. + */ + private fun onThree(state: MorrisState, seat: Int): Int = + if (state.phase != MorrisPhase.PLACING && state.onBoard(seat) == 3) 1 else 0 + + /** + * Lines holding two of a player's pieces with the third point empty — the + * mills that are one move away. Two of them sharing that empty point is + * what a fork looks like a move before it happens. + */ + private fun almostMills(state: MorrisState, seat: Int): Int = MILLS.count { mill -> + mill.count { state.board[it] == seat } == 2 && mill.any { state.board[it] == null } + } + + /** + * Somewhere to go, counted as piece-to-empty-point steps. + * + * Deliberately not the legal move list: that would multiply out every mill + * by every piece it could take, and a position is not more mobile for + * having a choice of which enemy piece to remove. While pieces are still + * being placed everyone can go anywhere, so it says nothing and is skipped. + */ + private fun mobility(state: MorrisState, seat: Int): Int { + if (state.phase == MorrisPhase.PLACING) return 0 + // A player who can fly is never short of somewhere to go, so they get + // the cap rather than a count of the empty board. Counting it would + // have the search reading "fewer pieces" as "more freedom", which is + // how an engine talks itself into being reduced to three. + if (state.isFlying(seat)) return MOBILITY_CAP + return (0 until POINTS) + .filter { state.board[it] == seat } + .sumOf { point -> ADJACENCY[point].count { state.board[it] == null } } + .coerceAtMost(MOBILITY_CAP) + } + + /** Pieces on the mid-edge points, which are the junctions worth holding. */ + private fun midEdgeCount(state: MorrisState, seat: Int): Int = + (0 until POINTS).count { isMidEdge(it) && state.board[it] == seat } + + private companion object { + const val INFINITY = 1_000_000 + const val WIN = 100_000 + const val NODE_BUDGET = 120_000 + + const val PIECE = 120 + const val MILL = 26 + const val ALMOST = 9 + const val MOBILITY = 3 + const val LADDER = 3 + const val DANGER = 40 + + /** + * Mobility saturates: the difference between nowhere to go and one + * square is the game, and the difference between eight squares and + * twelve is nothing. Capping it also keeps it well under the value of + * a piece, which is what stops shape talking over material. + */ + const val MOBILITY_CAP = 12 + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/morris/MorrisModel.kt b/app/src/main/java/org/prolibertate/games/game/morris/MorrisModel.kt new file mode 100644 index 0000000..1b998ea --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/morris/MorrisModel.kt @@ -0,0 +1,242 @@ +package org.prolibertate.games.game.morris + +import kotlinx.serialization.Serializable + +/** + * The board is twenty-four points on three concentric squares, numbered + * `ring * 8 + index`: ring 0 is the outer square, ring 2 the inner one, and the + * index runs clockwise from the top-left corner, so odd indices are the four + * mid-edge points and even indices the four corners. + * + * Numbering it that way is what makes the geometry fall out arithmetically + * rather than being typed in as a table: neighbours around a square are the + * index either side, the ladders between squares run through the odd points + * alone, and a mill is three consecutive points centred on an odd one. A table + * of twenty-four rows would say the same thing and could be wrong in one row. + */ +const val POINTS = 24 + +const val WHITE_SEAT = 0 +const val BLACK_SEAT = 1 + +fun other(seat: Int): Int = 1 - seat + +fun ringOf(point: Int): Int = point / 8 + +/** 0 is the top-left corner, running clockwise. Odd indices are mid-edge points. */ +fun indexOf(point: Int): Int = point % 8 + +fun pointOf(ring: Int, index: Int): Int = ring * 8 + ((index % 8) + 8) % 8 + +/** True for the four mid-edge points of a square — the only ones with ladders. */ +fun isMidEdge(point: Int): Boolean = indexOf(point) % 2 == 1 + +/** + * Where a point sits on the seven-by-seven grid the board is drawn on. The + * outer square runs along rows and columns 0, 3 and 6; the middle square 1, 3 + * and 5; the inner square 2, 3 and 4. + */ +fun rowOf(point: Int): Int { + val ring = ringOf(point) + return when (indexOf(point)) { + 0, 1, 2 -> ring + 3, 7 -> 3 + else -> 6 - ring + } +} + +fun columnOf(point: Int): Int { + val ring = ringOf(point) + return when (indexOf(point)) { + 0, 6, 7 -> ring + 1, 5 -> 3 + else -> 6 - ring + } +} + +/** + * The point's name in the notation the game is normally recorded in: files a–g + * left to right, ranks 7–1 top to bottom, exactly as the grid is drawn. The + * twenty-four points are the intersections that exist; the rest of the + * seven-by-seven grid is blank board. + */ +fun pointName(point: Int): String = "${'a' + columnOf(point)}${7 - rowOf(point)}" + +fun pointFromName(name: String): Int? { + val column = name[0] - 'a' + val row = 7 - (name[1] - '0') + return (0 until POINTS).firstOrNull { rowOf(it) == row && columnOf(it) == column } +} + +/** + * Which points a piece may step to. Around a square, the index either side; + * through the mid-edge points, the same index on the neighbouring squares. + * Corners have no ladder, which is why the middle of an edge is worth more than + * the corner beside it. + */ +val ADJACENCY: List> = (0 until POINTS).map { point -> + val ring = ringOf(point) + val index = indexOf(point) + buildList { + add(pointOf(ring, index + 1)) + add(pointOf(ring, index - 1)) + if (isMidEdge(point)) { + if (ring > 0) add(pointOf(ring - 1, index)) + if (ring < 2) add(pointOf(ring + 1, index)) + } + }.sorted() +} + +/** + * The sixteen mills: four to a square, centred on each mid-edge point, and four + * more running through the mid-edge points across all three squares. + */ +val MILLS: List> = buildList { + for (ring in 0..2) { + for (start in intArrayOf(0, 2, 4, 6)) { + add(listOf(pointOf(ring, start), pointOf(ring, start + 1), pointOf(ring, start + 2))) + } + } + for (index in intArrayOf(1, 3, 5, 7)) { + add(listOf(pointOf(0, index), pointOf(1, index), pointOf(2, index))) + } +} + +/** The mills each point belongs to. Every point is in exactly two of them. */ +val MILLS_THROUGH: List>> = + (0 until POINTS).map { point -> MILLS.filter { point in it } } + +/** + * A move. + * + * Placing a piece and moving one are the same shape — [from] is null while + * there are still pieces in hand — and the piece taken for closing a mill + * travels with the move that closed it rather than being a move of its own. + * That keeps a turn atomic: the host validates one message, the search counts + * one ply, and there is no half-finished turn to be left holding if a device + * drops off the network mid-mill. + */ +@Serializable +data class MorrisMove( + val to: Int, + val from: Int? = null, + val remove: Int? = null, +) { + override fun toString(): String = buildString { + from?.let { append(pointName(it)).append('-') } + append(pointName(to)) + remove?.let { append('x').append(pointName(it)) } + } +} + +@Serializable +enum class MorrisPhase { + /** Pieces are still being put on the board from the hand. */ + PLACING, + + /** Every piece is on the board; they move along the lines now. */ + MOVING, + GAME_OVER, +} + +@Serializable +enum class MorrisOutcome(val label: String) { + WHITE_WINS_REDUCED("White wins — Black is down to two pieces"), + BLACK_WINS_REDUCED("Black wins — White is down to two pieces"), + WHITE_WINS_BLOCKED("White wins — Black has no move left"), + BLACK_WINS_BLOCKED("Black wins — White has no move left"), + DRAW_REPETITION("Draw by threefold repetition"), + DRAW_NO_MILL("Draw — no mill closed in a long time"), +} + +/** How hard the computer thinks about it. */ +@Serializable +enum class MorrisLevel(val label: String, val depth: Int) { + CASUAL("Casual", depth = 2), + CLUB("Club", depth = 4), + STRONG("Strong", depth = 6), +} + +@Serializable +data class MorrisOptions( + val level: MorrisLevel = MorrisLevel.CLUB, + /** Nine each is the game's name; three and six men are the smaller cousins. */ + val piecesEach: Int = 9, + /** + * A player down to their last three pieces may jump anywhere rather than + * step along a line. It is the usual rule and it is what stops a losing + * position being a slow squeeze with no counterplay, but it is often + * dropped to make the game harder to draw. + */ + val flyingWithThree: Boolean = true, + /** The same position three times over is a draw. */ + val threefoldRepetition: Boolean = true, + /** + * Plies allowed to pass with no mill closed before the game is a draw. + * Fifty moves each is the usual figure, and something like it is needed: + * two players who can each hold a position have no reason ever to stop. + */ + val plyLimitWithoutMill: Int = 100, +) + +@Serializable +data class MorrisState( + val options: MorrisOptions, + /** Twenty-four points; null is empty, otherwise the seat holding it. */ + val board: List, + val turn: Int, + /** How many pieces each seat has put on the board, in total, ever. */ + val placed: List, + val phase: MorrisPhase, + val outcome: MorrisOutcome?, + val lastMove: MorrisMove?, + /** Plies since a mill was last closed, which is what the draw rule counts. */ + val pliesSinceMill: Int, + /** One entry per position reached, for the repetition rule. */ + val repetitionKeys: List, + /** The game record, one entry per ply. */ + val moveLog: List, +) { + fun inHand(seat: Int): Int = options.piecesEach - placed[seat] + + fun onBoard(seat: Int): Int = board.count { it == seat } + + /** Pieces still to come plus pieces still standing: what a seat has left. */ + fun remaining(seat: Int): Int = inHand(seat) + onBoard(seat) + + /** True once this seat is down to three pieces and allowed to jump. */ + fun isFlying(seat: Int): Boolean = + options.flyingWithThree && phase != MorrisPhase.PLACING && onBoard(seat) == 3 + + /** Whether the three points of [mill] are all held by [seat]. */ + fun holdsMill(mill: List, seat: Int): Boolean = mill.all { board[it] == seat } + + /** Whether the piece standing on [point] is part of a completed mill. */ + fun isInMill(point: Int): Boolean { + val seat = board[point] ?: return false + return MILLS_THROUGH[point].any { holdsMill(it, seat) } + } + + fun millCount(seat: Int): Int = MILLS.count { holdsMill(it, seat) } + + /** The position, as the repetition rule sees it: the board and who is to move. */ + fun positionKey(): String = buildString { + for (point in 0 until POINTS) { + append( + when (board[point]) { + WHITE_SEAT -> 'W' + BLACK_SEAT -> 'B' + else -> '.' + } + ) + } + append(' ').append(turn) + // Two positions that look alike but have different numbers of pieces + // still to come are not the same position. + append(' ').append(placed[WHITE_SEAT]).append(',').append(placed[BLACK_SEAT]) + } +} + +fun seatIsWhite(seat: Int): Boolean = seat == WHITE_SEAT + +fun seatName(seat: Int): String = if (seatIsWhite(seat)) "White" else "Black" diff --git a/app/src/main/java/org/prolibertate/games/game/morris/MorrisRules.kt b/app/src/main/java/org/prolibertate/games/game/morris/MorrisRules.kt new file mode 100644 index 0000000..a273860 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/morris/MorrisRules.kt @@ -0,0 +1,257 @@ +package org.prolibertate.games.game.morris + +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 + +/** + * Nine Men's Morris as a pure state machine. See RULES-morris.md. + * + * A turn is one move object: the piece placed or stepped, and — when that + * closes a mill — the enemy piece taken for it. Generating the removal as part + * of the move rather than as a turn of its own is what keeps the state machine + * honest. There is no moment where a player is halfway through a turn, so a + * client that disconnects between closing a mill and choosing what to take + * cannot leave the table stuck, and the search counts one ply for what is one + * decision. + */ +object MorrisRules : GameRules { + + override val gameId: String = GameCatalog.MORRIS + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): MorrisState { + require(config.seats.size == 2) { "Nine Men's Morris is a two-handed game" } + val options = json.decodeFromString(config.optionsJson) + return initialState(options) + } + + fun initialState(options: MorrisOptions = MorrisOptions()): MorrisState = MorrisState( + options = options, + board = List(POINTS) { null }, + turn = WHITE_SEAT, + placed = listOf(0, 0), + phase = MorrisPhase.PLACING, + outcome = null, + lastMove = null, + pliesSinceMill = 0, + repetitionKeys = emptyList(), + moveLog = emptyList(), + ).let { start -> start.copy(repetitionKeys = listOf(start.positionKey())) } + + override fun currentSeat(state: MorrisState): Int? = + if (state.phase == MorrisPhase.GAME_OVER) null else state.turn + + // ----------------------------------------------------------------------- + // Moves + // ----------------------------------------------------------------------- + + fun legalMoves(state: MorrisState): List = legalMoves(state, state.turn) + + override fun legalMoves(state: MorrisState, seat: Int): List { + if (state.phase == MorrisPhase.GAME_OVER || state.turn != seat) return emptyList() + + val steps: List> = if (state.phase == MorrisPhase.PLACING) { + if (state.inHand(seat) <= 0) { + emptyList() + } else { + (0 until POINTS).filter { state.board[it] == null }.map { null to it } + } + } else { + // Down to three pieces, a player jumps to any empty point rather + // than stepping along a line — if the table is playing that way. + val flying = state.isFlying(seat) + (0 until POINTS).filter { state.board[it] == seat }.flatMap { from -> + val reachable: Iterable = if (flying) 0 until POINTS else ADJACENCY[from] + reachable.filter { state.board[it] == null }.map { from to it } + } + } + + return steps.flatMap { (from, to) -> + if (!closesMill(state, from, to, seat)) { + listOf(MorrisMove(to = to, from = from)) + } else { + val takeable = removablePoints(state, other(seat)) + // Nothing left to take is not a reason to forbid the move; the + // mill simply goes unpaid. + if (takeable.isEmpty()) { + listOf(MorrisMove(to = to, from = from)) + } else { + takeable.map { MorrisMove(to = to, from = from, remove = it) } + } + } + } + } + + /** + * Whether landing on [to] — having left [from], if this is a step rather + * than a placement — completes a line of three. + * + * The vacated point is excluded deliberately: sliding a piece along its own + * mill does not re-close that mill, because the piece it needs is the one + * that just left. + */ + fun closesMill(state: MorrisState, from: Int?, to: Int, seat: Int): Boolean = + MILLS_THROUGH[to].any { mill -> + mill.all { point -> point == to || (point != from && state.board[point] == seat) } + } + + /** + * Which of [victim]'s pieces may be taken: any that is not standing in a + * mill, or — when every one of them is — any at all. Without that second + * clause a player whose pieces all sat in mills could never be taken from + * again, and the game would not end. + * + * The mover's own move cannot change the victim's mills, so this reads the + * board as it stands rather than as it will be. + */ + fun removablePoints(state: MorrisState, victim: Int): List { + val theirs = (0 until POINTS).filter { state.board[it] == victim } + return theirs.filterNot { state.isInMill(it) }.ifEmpty { theirs } + } + + override fun applyMove(state: MorrisState, seat: Int, move: MorrisMove): MorrisState { + require(state.phase != MorrisPhase.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. + * + * The search walks far more positions than it keeps, and neither the + * repetition list nor the game record affects which move is best from here. + * Wins and losses are still detected: a search that could not see them + * would play into a loss it had the moves to avoid. + */ + fun advanced(state: MorrisState, move: MorrisMove): MorrisState = + make(state, state.turn, move).withTerminalCheck(includeDraws = false) + + /** Applies the move to the board without deciding whether the game is over. */ + private fun make(state: MorrisState, seat: Int, move: MorrisMove): MorrisState { + val board = state.board.toMutableList() + move.from?.let { board[it] = null } + board[move.to] = seat + move.remove?.let { board[it] = null } + + val placed = state.placed.toMutableList() + if (move.from == null) placed[seat] = placed[seat] + 1 + + val everythingPlaced = placed.sum() >= state.options.piecesEach * 2 + + return state.copy( + board = board, + turn = other(seat), + placed = placed, + phase = if (everythingPlaced) MorrisPhase.MOVING else MorrisPhase.PLACING, + lastMove = move, + // A mill closed is progress; anything else is the clock running + // towards a drawn game. + pliesSinceMill = if (move.remove != null) 0 else state.pliesSinceMill + 1, + ) + } + + /** + * Decides 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 a + * piece or a move on the ply just played. + * + * A win is looked for before a draw: a position that both ends the game and + * runs out the draw clock is a win, not a draw. + */ + private fun MorrisState.withTerminalCheck(includeDraws: Boolean): MorrisState { + val loser = turn + + // Two pieces cannot make a line, so a player reduced to two has lost. + // Only once their hand is empty: a player mid-placement has more coming. + if (phase != MorrisPhase.PLACING && onBoard(loser) < 3) { + return copy( + phase = MorrisPhase.GAME_OVER, + outcome = if (loser == BLACK_SEAT) { + MorrisOutcome.WHITE_WINS_REDUCED + } else { + MorrisOutcome.BLACK_WINS_REDUCED + }, + ) + } + + // Penned in with nowhere to go is the other way to lose. There is + // always somewhere to place, so this can only bite once pieces move. + if (legalMoves(this, loser).isEmpty()) { + return copy( + phase = MorrisPhase.GAME_OVER, + outcome = if (loser == BLACK_SEAT) { + MorrisOutcome.WHITE_WINS_BLOCKED + } else { + MorrisOutcome.BLACK_WINS_BLOCKED + }, + ) + } + + if (!includeDraws) return this + + if (options.plyLimitWithoutMill > 0 && pliesSinceMill >= options.plyLimitWithoutMill) { + return copy(phase = MorrisPhase.GAME_OVER, outcome = MorrisOutcome.DRAW_NO_MILL) + } + + if (options.threefoldRepetition) { + val here = repetitionKeys.lastOrNull() + if (here != null && repetitionKeys.count { it == here } >= 3) { + return copy(phase = MorrisPhase.GAME_OVER, outcome = MorrisOutcome.DRAW_REPETITION) + } + } + + return this + } + + // ----------------------------------------------------------------------- + // Engine contract + // ----------------------------------------------------------------------- + + override fun isFinished(state: MorrisState): Boolean = state.phase == MorrisPhase.GAME_OVER + + override fun summary(state: MorrisState): String = state.outcome?.label ?: when (state.phase) { + MorrisPhase.PLACING -> + "${seatName(state.turn)} to place — ${state.inHand(state.turn)} left in hand" + + else -> "${seatName(state.turn)} to move" + } + + /** Nothing is hidden: both players are looking at the same board. */ + override fun viewFor(state: MorrisState, seat: Int): MorrisState = state + + override fun encodeState(state: MorrisState): String = json.encodeToString(state) + override fun decodeState(json: String): MorrisState = this.json.decodeFromString(json) + override fun encodeMove(move: MorrisMove): String = json.encodeToString(move) + override fun decodeMove(json: String): MorrisMove = this.json.decodeFromString(json) + + /** + * Counts the leaf nodes at [depth] — the same measure a chess move + * generator is checked against, and the most useful single thing that can + * be said about this one. It walks every move to the bottom, so a rule that + * is wrong anywhere shows up as a different number. + */ + fun perft(state: MorrisState, 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 == MorrisPhase.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 314f536..032cc33 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -36,6 +36,8 @@ 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.morris.MorrisLevel +import org.prolibertate.games.game.morris.MorrisOptions import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.tayu.TayuLevel @@ -71,6 +73,7 @@ fun GameSetupScreen( var wizard by remember { mutableStateOf(WizardOptions()) } var chess by remember { mutableStateOf(ChessOptions()) } var tayu by remember { mutableStateOf(TayuOptions()) } + var morris by remember { mutableStateOf(MorrisOptions()) } // 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) } @@ -85,6 +88,7 @@ fun GameSetupScreen( GameCatalog.WIZARD -> setupJson.encodeToString(wizard) GameCatalog.CHESS -> setupJson.encodeToString(chess) GameCatalog.TAYU -> setupJson.encodeToString(tayu) + GameCatalog.MORRIS -> setupJson.encodeToString(morris) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -119,6 +123,8 @@ fun GameSetupScreen( GameCatalog.TAYU -> TayuOptionsEditor(tayu) { tayu = it } + GameCatalog.MORRIS -> MorrisOptionsEditor(morris) { morris = it } + else -> Text("No options yet for this game.") } @@ -196,7 +202,7 @@ fun seatCountFor(gameId: String, optionsJson: String): Int { GameCatalog.WIZARD -> setupJson.decodeFromString(optionsJson).playerCount - GameCatalog.CHESS -> 2 + GameCatalog.CHESS, GameCatalog.MORRIS -> 2 GameCatalog.TAYU -> setupJson.decodeFromString(optionsJson).playerCount @@ -531,6 +537,54 @@ private fun ChessOptionsEditor( } } +@Composable +private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Opponent", + values = MorrisLevel.entries.toList(), + selected = options.level, + display = { it.label }, + onSelect = { onChange(options.copy(level = it)) }, + ) + ChipRow( + label = "Pieces each", + values = listOf(3, 6, 9), + selected = options.piecesEach, + display = { "$it" }, + onSelect = { onChange(options.copy(piecesEach = it)) }, + ) + ToggleRow( + title = "Flying on three", + subtitle = "A player down to three pieces may jump anywhere rather than " + + "step along a line", + checked = options.flyingWithThree, + onChange = { onChange(options.copy(flyingWithThree = it)) }, + ) + ToggleRow( + title = "Threefold repetition", + subtitle = "The same position three times over is a draw", + checked = options.threefoldRepetition, + onChange = { onChange(options.copy(threefoldRepetition = it)) }, + ) + ChipRow( + label = "Draw with no mill for", + values = listOf(0, 50, 100, 200), + selected = options.plyLimitWithoutMill, + display = { if (it == 0) "never" else "$it moves" }, + onSelect = { onChange(options.copy(plyLimitWithoutMill = it)) }, + ) + Text( + text = "Nine each is the game proper; fewer is a shorter game on the same " + + "board rather than the smaller boards Three and Six Men's Morris are " + + "really played on. Flying is the usual rule — without it a player " + + "down to three is squeezed out, and with it they can be very hard " + + "to finish off.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + @Composable private fun TayuOptionsEditor(options: TayuOptions, onChange: (TayuOptions) -> 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 28bb1d1..16a9b3a 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -31,6 +31,10 @@ 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.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.president.PresidentAi import org.prolibertate.games.game.president.PresidentMove import org.prolibertate.games.game.president.PresidentPhase @@ -56,6 +60,7 @@ 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.MorrisScreen import org.prolibertate.games.ui.game.PresidentScreen import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS import org.prolibertate.games.ui.game.SequenceScreen @@ -302,6 +307,27 @@ fun PlayScreen( TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.MORRIS -> { + val controller = remember(route) { + MatchController( + rules = MorrisRules, + // As with chess, the strength is part of the table's own + // options, so this does not have to decode anything. + ai = MorrisAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // The search is the thinking time; anything added on top of + // it would only make the computer look slower than it is. + aiThinkingMillis = { settings.scaled(250L) }, + ) + } + StartMatch(env, controller, route) + MorrisScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + GameCatalog.CHESS -> { val controller = remember(route) { MatchController( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MorrisScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MorrisScreen.kt new file mode 100644 index 0000000..03b979c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/MorrisScreen.kt @@ -0,0 +1,530 @@ +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.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.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.morris.BLACK_SEAT +import org.prolibertate.games.game.morris.MorrisMove +import org.prolibertate.games.game.morris.MorrisPhase +import org.prolibertate.games.game.morris.MorrisState +import org.prolibertate.games.game.morris.POINTS +import org.prolibertate.games.game.morris.WHITE_SEAT +import org.prolibertate.games.game.morris.columnOf +import org.prolibertate.games.game.morris.pointName +import org.prolibertate.games.game.morris.rowOf +import org.prolibertate.games.game.morris.seatIsWhite +import org.prolibertate.games.game.morris.seatName +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold + +// Board colours, kept off the theme so the board reads the same either way up. +private val BoardField = Color(0xFFEEDAB4) +private val BoardLine = Color(0xFF6B4E2E) +private val WhiteStone = Color(0xFFFAF6EC) +private val BlackStone = Color(0xFF23201C) +private val StoneEdge = Color(0x66000000) +private val VacantPoint = Color(0x552B2B2B) +private val SelectedRing = Color(0xFF2E9E2E) +private val TakeableRing = Color(0xFFD32F2F) +private val LastMoveRing = Color(0xFFE0A800) +private val DestinationDot = Color(0x66000000) + +/** The seven-by-seven grid the twenty-four points are drawn on. */ +private const val GRID = 7 + +/** + * Nine Men's Morris. + * + * Tap an empty point to place a piece; once every piece is on the board, tap + * one of your own and then where it goes. Closing a line of three asks which + * enemy piece to take — unless only one may be taken, in which case there is + * nothing to ask. + * + * The board is not turned round for Black. It is symmetrical, both players see + * the same lines, and there is no "your side" of it to sit behind. + */ +@Composable +fun MorrisScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + // The piece picked up, once pieces are being moved rather than placed. + var selected by remember { mutableStateOf(null) } + // A move that has been chosen but not sent, because closing a mill leaves a + // second decision: which enemy piece it takes. + var millFrom by remember { mutableStateOf(null) } + var millTo 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 { + LeftTheBoardDialog(it, onExit = onExit, onStay = controller::dismissAbandoned) + } + if (confirmingEnd) { + LeaveBoardDialog(onEnd = onExit, onKeepPlaying = { confirmingEnd = false }) + } + } + return + } + + // While a mill is waiting to be paid, the only points that do anything are + // the enemy pieces it may take. + val takeable = if (millTo == null) { + emptyList() + } else { + legal.filter { it.from == millFrom && it.to == millTo }.mapNotNull { it.remove } + } + + val placing = current.phase == MorrisPhase.PLACING + val movingFrom = if (placing) null else selected + val destinations = if (millTo != null) { + emptyList() + } else { + legal.filter { it.from == movingFrom }.map { it.to } + } + + val onPointTapped: (Int) -> Unit = { point -> + if (millTo != null) { + // Paying for the mill. Anything that is not a piece it may take + // puts the move back down rather than sending something unmeant. + val move = legal.firstOrNull { + it.from == millFrom && it.to == millTo && it.remove == point + } + if (move != null) { + controller.submit(move) + selected = null + } + millFrom = null + millTo = null + } else { + val candidates = legal.filter { it.from == movingFrom && it.to == point } + when { + // One way to play it, mill or no mill: no question to ask. + candidates.size == 1 -> { + controller.submit(candidates.single()) + selected = null + } + + candidates.size > 1 -> { + millFrom = movingFrom + millTo = point + } + + // Tapping another of your own pieces picks that one up instead, + // which is what a player expects after a misplaced tap. + !placing && legal.any { it.from == point } -> selected = point + + else -> selected = null + } + } + } + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + BoxWithConstraints(modifier = modifier) { + val wide = maxWidth > maxHeight + val boardSide = if (wide) { + minOf(maxHeight, maxWidth * 0.62f) + } else { + minOf(maxWidth, maxHeight * 0.68f) + } + + val board: @Composable () -> Unit = { + Board( + state = current, + side = boardSide, + selected = selected, + destinations = destinations.toSet(), + takeable = takeable.toSet(), + onTap = onPointTapped, + ) + } + val panel: @Composable (Modifier) -> Unit = { panelModifier -> + Column( + modifier = panelModifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatusLine( + state = current, + localSeat = localSeat, + choosingWhatToTake = millTo != null, + ) + Hands(current) + MoveList(current, modifier = Modifier.weight(1f, fill = false)) + } + } + + if (wide) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + board() + panel(Modifier.weight(1f).fillMaxSize()) + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + board() + panel(Modifier.fillMaxWidth().weight(1f)) + } + } + + // One dialog at a time, most pressing first: a table nobody can + // finish comes before the result of one that finished. + val over = abandoned + when { + over != null -> + LeftTheBoardDialog(over, onExit = onExit, onStay = controller::dismissAbandoned) + + confirmingEnd -> + LeaveBoardDialog(onEnd = onExit, onKeepPlaying = { confirmingEnd = false }) + + current.phase == MorrisPhase.GAME_OVER -> ResultDialog(current, onExit) + } + } + } +} + +private const val TITLE = "Nine Men's Morris" + +// --------------------------------------------------------------------------- +// The board +// --------------------------------------------------------------------------- + +@Composable +private fun Board( + state: MorrisState, + side: Dp, + selected: Int?, + destinations: Set, + takeable: Set, + onTap: (Int) -> Unit, +) { + Box( + modifier = Modifier + .size(side) + .background(BoardField) + .border(2.dp, BoardLine), + ) { + Lines(modifier = Modifier.fillMaxSize()) + + // The points sit on the grid the lines were drawn from, so a stone + // lands on the intersection rather than near it. + 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, + isDestination = point in destinations, + isTakeable = point in takeable, + onTap = { onTap(point) }, + ) + } + } + } + } + } + } + } +} + +/** + * The three squares and the four lines joining them. + * + * Drawn from the same grid the points are laid out on — a line from the centre + * of one cell to the centre of another — so the board cannot drift out of step + * with the stones standing on it. + */ +@Composable +private fun Lines(modifier: Modifier) { + Canvas(modifier = modifier) { + val cell = size.width / GRID + val stroke = (cell * 0.05f).coerceAtLeast(2f) + fun centre(row: Int, column: Int) = + Offset(x = (column + 0.5f) * cell, y = (row + 0.5f) * cell) + + for (ring in 0..2) { + val near = ring + val far = 6 - ring + val corners = listOf( + centre(near, near), + centre(near, far), + centre(far, far), + centre(far, near), + ) + corners.forEachIndexed { index, corner -> + drawLine( + color = BoardLine, + start = corner, + end = corners[(index + 1) % corners.size], + strokeWidth = stroke, + ) + } + } + + // The ladders, out from the inner square to the outer one along the + // middle of each edge. + listOf( + centre(0, 3) to centre(2, 3), + centre(3, 6) to centre(3, 4), + centre(6, 3) to centre(4, 3), + centre(3, 0) to centre(3, 2), + ).forEach { (from, to) -> + drawLine(color = BoardLine, start = from, end = to, strokeWidth = stroke) + } + } +} + +@Composable +private fun Point( + state: MorrisState, + point: Int, + cell: Dp, + selected: Boolean, + isDestination: Boolean, + isTakeable: Boolean, + onTap: () -> Unit, +) { + val occupant = state.board[point] + val touchedByLastMove = state.lastMove?.let { + point == it.to || point == it.from || point == it.remove + } == true + + Box( + // The whole cell takes the tap, not just the stone: an intersection on + // a phone is smaller than a fingertip. + modifier = Modifier.fillMaxSize().clickable { onTap() }, + contentAlignment = Alignment.Center, + ) { + when { + occupant != null -> Box( + modifier = Modifier + .size(cell * 0.72f) + .clip(CircleShape) + .background(if (occupant == WHITE_SEAT) WhiteStone else BlackStone) + .border( + width = when { + selected || isTakeable -> cell * 0.07f + touchedByLastMove -> cell * 0.05f + else -> 1.dp + }, + color = when { + selected -> SelectedRing + isTakeable -> TakeableRing + touchedByLastMove -> LastMoveRing + else -> StoneEdge + }, + shape = CircleShape, + ) + ) + + else -> Box( + modifier = Modifier + .size(if (isDestination) cell * 0.34f else cell * 0.16f) + .clip(CircleShape) + .background(if (isDestination) DestinationDot else VacantPoint) + ) + } + } +} + +/** The point drawn at a grid square, or null where the board is blank. */ +private fun pointAt(row: Int, column: Int): Int? = + (0 until POINTS).firstOrNull { rowOf(it) == row && columnOf(it) == column } + +// --------------------------------------------------------------------------- +// The panel beside it +// --------------------------------------------------------------------------- + +@Composable +private fun StatusLine(state: MorrisState, localSeat: Int, choosingWhatToTake: Boolean) { + val yours = state.turn == localSeat && state.phase != MorrisPhase.GAME_OVER + Column { + Text( + text = state.outcome?.label ?: when { + choosingWhatToTake -> "A mill — take one of their pieces" + yours && state.phase == MorrisPhase.PLACING -> + "Your turn — place a piece (${state.inHand(localSeat)} left)" + + yours -> "Your move" + else -> "Thinking…" + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "You play ${seatName(localSeat)} · " + + "${state.onBoard(localSeat)} on the board, " + + "${state.onBoard(1 - localSeat)} against you", + style = MaterialTheme.typography.bodySmall, + ) + if (state.isFlying(state.turn)) { + Text( + text = "${seatName(state.turn)} is down to three and may jump anywhere.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +/** What is still waiting to be put on the board, for each side. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Hands(state: MorrisState) { + if (state.phase != MorrisPhase.PLACING) return + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + listOf(WHITE_SEAT, BLACK_SEAT).forEach { seat -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${seatName(seat)}: ", + style = MaterialTheme.typography.bodySmall, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + repeat(state.inHand(seat)) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(if (seatIsWhite(seat)) WhiteStone else BlackStone) + .border(1.dp, StoneEdge, CircleShape) + ) + } + } + } + } + } +} + +/** The game record, most recent at the bottom. */ +@Composable +private fun MoveList(state: MorrisState, 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, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Dialogs +// --------------------------------------------------------------------------- + +@Composable +private fun LeaveBoardDialog(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 LeftTheBoardDialog(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: MorrisState, onExit: () -> Unit) { + var dismissed by remember(state.outcome) { mutableStateOf(false) } + if (dismissed) return + + AlertDialog( + onDismissRequest = { dismissed = true }, + title = { Text("Game over") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold) + state.lastMove?.let { move -> + Text( + text = "Last move ${move.from?.let { "${pointName(it)} to " }.orEmpty()}" + + pointName(move.to) + + (move.remove?.let { ", taking ${pointName(it)}" } ?: ""), + style = MaterialTheme.typography.bodySmall, + ) + } + 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 f9e49e6..fc403d1 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -60,6 +60,7 @@ class CatalogTest { GameCatalog.WIZARD, GameCatalog.CHESS, GameCatalog.TAYU, + GameCatalog.MORRIS, ), GameCatalog.playable.map { it.id }.toSet(), ) diff --git a/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiTest.kt b/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiTest.kt new file mode 100644 index 0000000..ae99e2b --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiTest.kt @@ -0,0 +1,149 @@ +package org.prolibertate.games.game.morris + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MorrisAiTest { + + private fun point(name: String) = requireNotNull(pointFromName(name)) { "no point $name" } + + private fun boardOf(white: List, black: List): List { + val board = arrayOfNulls(POINTS) + white.forEach { board[point(it)] = WHITE_SEAT } + black.forEach { board[point(it)] = BLACK_SEAT } + return board.toList() + } + + private fun position( + white: List, + black: List, + turn: Int = WHITE_SEAT, + options: MorrisOptions = MorrisOptions(level = MorrisLevel.CLUB), + ) = MorrisRules.initialState(options).copy( + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + turn = turn, + board = boardOf(white, black), + ) + + private fun choose(state: MorrisState, level: MorrisLevel = MorrisLevel.CLUB): MorrisMove = + MorrisAi(level).chooseMove(state, state.turn, MorrisRules.legalMoves(state)) + + @Test + fun `it always returns a move it is allowed to play`() { + var state = MorrisRules.initialState(MorrisOptions(level = MorrisLevel.CASUAL)) + // A whole game, both sides played by the computer, which is the + // cheapest way to find a position the search mishandles. + var plies = 0 + while (!MorrisRules.isFinished(state) && plies < 300) { + val legal = MorrisRules.legalMoves(state) + assertTrue("stuck with no move at ply $plies", legal.isNotEmpty()) + val move = choose(state, MorrisLevel.CASUAL) + assertTrue("the AI picked a move that is not legal: $move", move in legal) + state = MorrisRules.applyMove(state, state.turn, move) + plies++ + } + // Whatever happened, it has to have been a game rather than a hang. + assertTrue("the game never got anywhere in $plies plies", plies > 10) + } + + @Test + fun `it takes a mill that is there for the taking`() { + // White holds both ends of the top edge and steps up from d6 to close + // it. Black is scattered — no two of their pieces share a line — so + // there is nothing to weigh against a free piece. + val state = position( + white = listOf("a7", "g7", "d6", "f6"), + black = listOf("a4", "b2", "d3", "e4", "g1"), + ) + val move = choose(state, MorrisLevel.STRONG) + assertEquals(point("d7"), move.to) + assertEquals(point("d6"), move.from) + assertNotNull("closing the mill has to take something", move.remove) + } + + @Test + fun `it takes a piece it is allowed to take, not one standing in a mill`() { + // Black's a-file mill is protected; three loose pieces are not. Black + // keeps five afterwards, so this does not hand them a flying mill. + val state = position( + white = listOf("d7", "d6", "c5", "b6"), + black = listOf("a7", "a4", "a1", "g1", "b2", "f2"), + ) + val move = choose(state, MorrisLevel.STRONG) + assertEquals(point("c5"), move.from) + assertEquals(point("d5"), move.to) + assertTrue( + "a piece in a mill cannot be taken while a loose one is going spare", + move.remove in setOf(point("g1"), point("b2"), point("f2")), + ) + } + + @Test + fun `it will not hand the opponent a flying mill for the sake of a piece`() { + // Taking here would leave Black on three, flying, with their three + // pieces already in a line — they would step out and back in for a + // piece every second move, and White would lose the game for the sake + // of winning one. The search has to see past the free piece. + val state = position( + white = listOf("d7", "d6", "c5", "b6"), + black = listOf("a7", "a4", "a1", "g1"), + ) + val move = choose(state, MorrisLevel.STRONG) + assertTrue( + "closing the mill here loses; expected anything else, got $move", + move.remove == null, + ) + } + + @Test + fun `it finishes a won game rather than playing on`() { + // Black is down to three, all of them takeable, and White can close a + // mill: that is the game, and the search should see it. + val state = position( + white = listOf("d7", "d6", "c5", "a7"), + black = listOf("g7", "g4", "g1"), + ) + val move = choose(state, MorrisLevel.STRONG) + val next = MorrisRules.applyMove(state, WHITE_SEAT, move) + assertEquals(MorrisPhase.GAME_OVER, next.phase) + assertEquals(MorrisOutcome.WHITE_WINS_REDUCED, next.outcome) + } + + @Test + fun `it blocks a mill about to be closed against it`() { + // Black holds d7 and d5 and will step b6 into d6 for the mill. Neither + // of the two pieces already on that line can close it themselves, so + // d6 is the whole game — and f6 is the only White piece that can reach + // it. White has no mill of its own to play instead. + val state = position( + white = listOf("f6", "d2", "a4", "g4"), + black = listOf("d7", "d5", "b6", "e3", "c3", "g1"), + ) + val move = choose(state, MorrisLevel.STRONG) + assertEquals("d6 is the only square that matters", point("d6"), move.to) + } + + @Test + fun `the same position gives the same move twice over`() { + // The host and a client both replay a game; if the AI wandered they + // would end up looking at different boards. + val state = MorrisRules.initialState(MorrisOptions(level = MorrisLevel.CLUB)) + val first = choose(state) + val second = choose(state) + assertEquals(first, second) + } + + @Test + fun `the opening does not take forever to think about`() { + // Twenty-four choices a ply with nothing to tell them apart is the + // widest this game ever gets. If the node budget is not doing its job + // this is where it shows. + val started = System.nanoTime() + choose(MorrisRules.initialState(MorrisOptions(level = MorrisLevel.STRONG))) + val millis = (System.nanoTime() - started) / 1_000_000 + assertTrue("the opening move took ${millis}ms", millis < 20_000) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/morris/MorrisRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/morris/MorrisRulesTest.kt new file mode 100644 index 0000000..ac6cb69 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/morris/MorrisRulesTest.kt @@ -0,0 +1,476 @@ +package org.prolibertate.games.game.morris + +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 MorrisRulesTest { + + private fun start(options: MorrisOptions = MorrisOptions()) = MorrisRules.initialState(options) + + /** Plays the given moves in turn, requiring each to be legal. */ + private fun play(state: MorrisState, vararg moves: MorrisMove): MorrisState = + moves.fold(state) { current, move -> MorrisRules.applyMove(current, current.turn, move) } + + private fun place(name: String) = MorrisMove(to = point(name)) + + private fun place(name: String, takes: String) = + MorrisMove(to = point(name), remove = point(takes)) + + private fun step(from: String, to: String) = + MorrisMove(to = point(to), from = point(from)) + + private fun step(from: String, to: String, takes: String) = + MorrisMove(to = point(to), from = point(from), remove = point(takes)) + + private fun point(name: String) = requireNotNull(pointFromName(name)) { "no point $name" } + + // ----------------------------------------------------------------------- + // The board + // ----------------------------------------------------------------------- + + @Test + fun `the board is twenty-four points and sixteen mills`() { + assertEquals(24, POINTS) + assertEquals(16, MILLS.size) + assertEquals(24, ADJACENCY.size) + // Every mill is three distinct points, and every point is in two mills. + MILLS.forEach { assertEquals(3, it.distinct().size) } + (0 until POINTS).forEach { assertEquals("point $it", 2, MILLS_THROUGH[it].size) } + } + + @Test + fun `corners have two neighbours and mid-edge points three or four`() { + (0 until POINTS).forEach { point -> + val expected = when { + !isMidEdge(point) -> 2 + // The middle square's mid-edge points have a ladder each way. + ringOf(point) == 1 -> 4 + else -> 3 + } + assertEquals(pointName(point), expected, ADJACENCY[point].size) + } + } + + @Test + fun `adjacency is mutual`() { + (0 until POINTS).forEach { point -> + ADJACENCY[point].forEach { neighbour -> + assertTrue( + "${pointName(point)} sees ${pointName(neighbour)} but not the other way", + point in ADJACENCY[neighbour], + ) + } + } + } + + @Test + fun `points are named as the game is written down`() { + // The four corners of the outer square, and the centre of the top edge. + assertEquals("a7", pointName(pointOf(0, 0))) + assertEquals("d7", pointName(pointOf(0, 1))) + assertEquals("g7", pointName(pointOf(0, 2))) + assertEquals("g1", pointName(pointOf(0, 4))) + assertEquals("a1", pointName(pointOf(0, 6))) + // The middle of the board is a gap, not a point: the inner square's + // mid-edge points are the nearest thing to it. + assertEquals("d6", pointName(pointOf(1, 1))) + assertEquals("d5", pointName(pointOf(2, 1))) + assertNull(pointFromName("d4")) + (0 until POINTS).forEach { assertEquals(it, pointFromName(pointName(it))) } + } + + // ----------------------------------------------------------------------- + // Placing + // ----------------------------------------------------------------------- + + @Test + fun `the game opens with twenty-four placements and white to move`() { + val state = start() + assertEquals(MorrisPhase.PLACING, state.phase) + assertEquals(WHITE_SEAT, state.turn) + assertEquals(24, MorrisRules.legalMoves(state).size) + assertEquals(9, state.inHand(WHITE_SEAT)) + assertEquals(0, state.onBoard(WHITE_SEAT)) + } + + @Test + fun `placing takes a piece from the hand and passes the turn`() { + val state = play(start(), place("a7")) + assertEquals(BLACK_SEAT, state.turn) + assertEquals(8, state.inHand(WHITE_SEAT)) + assertEquals(1, state.onBoard(WHITE_SEAT)) + assertEquals(23, MorrisRules.legalMoves(state).size) + } + + @Test + fun `placing continues until every piece is on the board`() { + // Nine each, placed alternately down opposite edges of the board. + val white = listOf("a7", "d7", "g4", "g1", "d3", "b6", "f6", "c5", "e5") + val black = listOf("a4", "a1", "d1", "g7", "b4", "f4", "d2", "c3", "e3") + var state = start() + for (index in 0 until 9) { + state = play(state, place(white[index])) + state = play(state, place(black[index])) + } + assertEquals(MorrisPhase.MOVING, state.phase) + assertEquals(0, state.inHand(WHITE_SEAT)) + assertEquals(0, state.inHand(BLACK_SEAT)) + // Nobody built a mill, so all eighteen are still standing. + assertEquals(9, state.onBoard(WHITE_SEAT)) + assertEquals(9, state.onBoard(BLACK_SEAT)) + assertTrue(MorrisRules.legalMoves(state).all { it.from != null }) + } + + // ----------------------------------------------------------------------- + // Mills + // ----------------------------------------------------------------------- + + @Test + fun `closing a mill takes an enemy piece`() { + val state = play( + start(), + place("a7"), place("a4"), + place("d7"), place("a1"), + // a7-d7-g7 is a mill, so this placement carries a removal. + place("g7", takes = "a4"), + ) + assertEquals(3, state.onBoard(WHITE_SEAT)) + assertEquals(1, state.onBoard(BLACK_SEAT)) + assertEquals(0, state.pliesSinceMill) + assertTrue(state.isInMill(point("d7"))) + } + + @Test + fun `a mill must take something when there is anything to take`() { + val opening = play( + start(), + place("a7"), place("a4"), + place("d7"), place("a1"), + ) + val millMoves = MorrisRules.legalMoves(opening).filter { it.to == point("g7") } + assertEquals(2, millMoves.size) + assertTrue("the mill has to be paid for", millMoves.all { it.remove != null }) + assertEquals(setOf(point("a4"), point("a1")), millMoves.mapNotNull { it.remove }.toSet()) + } + + @Test + fun `a piece standing in a mill is spared while another is available`() { + // Black holds a mill down the a-file and one loose piece on g1. White + // steps c5 to d5 to close d7-d6-d5, and only the loose piece may be + // taken for it. + val state = start().copy( + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + board = boardOf( + white = listOf("d7", "d6", "c5", "b6"), + black = listOf("a7", "a4", "a1", "g1"), + ), + ) + // Only the step from c5 closes the mill; d6 can reach d5 too, but the + // piece it would need is the one making the move. + val choices = MorrisRules.legalMoves(state) + .filter { it.from == point("c5") && it.to == point("d5") } + assertEquals(1, choices.size) + assertEquals(point("g1"), choices.single().remove) + } + + @Test + fun `when every enemy piece is in a mill any of them may be taken`() { + // Black's only three pieces are the a-file mill, so the rule that + // spares a milled piece has to give way — otherwise nothing could ever + // be taken and the game could not end. + val state = start().copy( + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + board = boardOf( + white = listOf("d7", "d6", "c5", "b6"), + black = listOf("a7", "a4", "a1"), + ), + ) + val choices = MorrisRules.legalMoves(state) + .filter { it.from == point("c5") && it.to == point("d5") } + assertEquals(3, choices.size) + assertEquals( + setOf(point("a7"), point("a4"), point("a1")), + choices.mapNotNull { it.remove }.toSet(), + ) + } + + @Test + fun `a mill needs three pieces, and the one that just left is not one of them`() { + // White holds two of the top edge and slides one of them along that + // same line. a7 to d7 leaves a7 empty, so it is two in a row, not a + // mill — which is the whole reason the vacated point is excluded. + val state = start().copy( + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + board = boardOf( + white = listOf("a7", "g7", "b6"), + black = listOf("g4", "g1", "d1"), + ), + ) + val slide = MorrisRules.legalMoves(state).single { + it.from == point("a7") && it.to == point("d7") + } + assertNull(slide.remove) + } + + @Test + fun `stepping out of a mill takes nothing, and stepping back in pays again`() { + var state = start().copy( + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + board = boardOf( + white = listOf("a7", "d7", "g7", "b6"), + black = listOf("g4", "g1", "d1", "b2"), + ), + ) + + val out = MorrisRules.legalMoves(state).filter { it.from == point("d7") } + assertTrue(out.isNotEmpty()) + assertTrue("breaking your own mill is not a mill", out.all { it.remove == null }) + + state = play(state, step("d7", "d6")) + state = play(state, step("b2", "b4")) + + val back = MorrisRules.legalMoves(state).filter { + it.from == point("d6") && it.to == point("d7") + } + // Re-forming the same mill pays again — that is the standard rule, and + // the piece that closes it is one that was not there a moment ago. + assertTrue(back.isNotEmpty()) + assertTrue(back.all { it.remove != null }) + assertEquals( + "any of Black's four, none of which is in a mill", + 4, + back.size, + ) + } + + // ----------------------------------------------------------------------- + // Moving, flying, and the end of the game + // ----------------------------------------------------------------------- + + @Test + fun `a piece steps along a line and not across the board`() { + val state = start().copy( + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + board = boardOf( + white = listOf("a7"), + black = listOf("g7", "g4", "g1", "d1"), + ), + ) + val moves = MorrisRules.legalMoves(state) + // a7 touches d7 and a4 only. + assertEquals(setOf(point("d7"), point("a4")), moves.map { it.to }.toSet()) + } + + @Test + fun `three pieces may fly, unless the table says otherwise`() { + val board = boardOf(white = listOf("a7", "a4", "a1"), black = listOf("g7", "g4", "g1")) + val flying = start().copy(phase = MorrisPhase.MOVING, placed = listOf(9, 9), board = board) + // Eighteen empty points, three pieces, and no line to follow. + assertEquals(18 * 3, MorrisRules.legalMoves(flying).size) + + val grounded = flying.copy(options = flying.options.copy(flyingWithThree = false)) + assertTrue(MorrisRules.legalMoves(grounded).size < 18 * 3) + assertTrue( + "without flying a piece still only steps to a neighbour", + MorrisRules.legalMoves(grounded).all { it.to in ADJACENCY[it.from!!] }, + ) + } + + @Test + fun `taking a third piece ends the game`() { + // White closes a mill and takes Black's fourth piece, leaving two. + val state = start().copy( + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + board = boardOf( + white = listOf("d7", "d6", "c5", "a7"), + black = listOf("g7", "g4", "g1"), + ), + ) + val mill = MorrisRules.legalMoves(state).first { it.remove != null } + val ended = MorrisRules.applyMove(state, WHITE_SEAT, mill) + assertEquals(MorrisPhase.GAME_OVER, ended.phase) + assertEquals(MorrisOutcome.WHITE_WINS_REDUCED, ended.outcome) + assertTrue(MorrisRules.isFinished(ended)) + assertTrue(MorrisRules.legalMoves(ended).isEmpty()) + } + + @Test + fun `a player with nowhere to go loses`() { + // Black holds the three top-left corners, one to a square, and White + // stands on every point they touch. Flying is off, so Black has no + // move at all once the turn comes round. + val state = start().copy( + options = MorrisOptions(flyingWithThree = false), + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + turn = WHITE_SEAT, + board = boardOf( + white = listOf("d7", "a4", "d6", "b4", "d5", "c4", "g1"), + black = listOf("a7", "b6", "c5"), + ), + ) + // White plays a quiet move away on the far side; Black is then on the + // clock with nothing to play. + val quiet = MorrisRules.legalMoves(state).first { it.from == point("g1") } + val ended = MorrisRules.applyMove(state, WHITE_SEAT, quiet) + assertEquals(MorrisOutcome.WHITE_WINS_BLOCKED, ended.outcome) + } + + @Test + fun `the game is drawn when no mill has been closed for a long time`() { + val state = start().copy( + options = MorrisOptions(plyLimitWithoutMill = 4, flyingWithThree = false), + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + pliesSinceMill = 3, + board = boardOf(white = listOf("a7", "a4", "g4"), black = listOf("g7", "g1", "d1")), + ) + val shuffle = MorrisRules.legalMoves(state).first { it.remove == null } + val ended = MorrisRules.applyMove(state, WHITE_SEAT, shuffle) + assertEquals(MorrisOutcome.DRAW_NO_MILL, ended.outcome) + } + + @Test + fun `the same position three times over is a draw`() { + var state = start().copy( + phase = MorrisPhase.MOVING, + placed = listOf(9, 9), + board = boardOf( + white = listOf("a7", "a4", "g4", "f4"), + black = listOf("g7", "g1", "d1", "b2"), + ), + ) + state = state.copy(repetitionKeys = listOf(state.positionKey())) + + // Both sides walk a piece out and back twice over, which returns the + // position to the board it started from for the third time. + val cycle = listOf( + step("f4", "f6"), step("b2", "b4"), + step("f6", "f4"), step("b4", "b2"), + ) + var moves = 0 + for (round in 0 until 2) { + for (move in cycle) { + if (state.phase == MorrisPhase.GAME_OVER) break + state = MorrisRules.applyMove(state, state.turn, move) + moves++ + } + } + assertEquals(MorrisOutcome.DRAW_REPETITION, state.outcome) + assertTrue("it should not take more than two full cycles", moves <= 8) + } + + // ----------------------------------------------------------------------- + // The engine contract + // ----------------------------------------------------------------------- + + @Test + fun `an illegal move is refused rather than played`() { + val state = play(start(), place("a7")) + // Black tries to place on a point White has taken. + val onTop = runCatching { MorrisRules.applyMove(state, BLACK_SEAT, place("a7")) } + assertTrue(onTop.exceptionOrNull() is IllegalArgumentException) + // White tries to move out of turn. + val outOfTurn = runCatching { MorrisRules.applyMove(state, WHITE_SEAT, place("d7")) } + assertTrue(outOfTurn.exceptionOrNull() is IllegalArgumentException) + // A mill claimed where there is none. + val unearned = runCatching { + MorrisRules.applyMove(state, BLACK_SEAT, place("d7", takes = "a7")) + } + assertTrue(unearned.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun `a state survives the round trip over the wire`() { + val state = play( + start(), + place("a7"), place("a4"), + place("d7"), place("a1"), + place("g7", takes = "a4"), + ) + val restored = MorrisRules.decodeState(MorrisRules.encodeState(state)) + assertEquals(state, restored) + + val move = step("d7", "d6", takes = "a1") + assertEquals(move, MorrisRules.decodeMove(MorrisRules.encodeMove(move))) + } + + @Test + fun `nothing is hidden from either seat`() { + val state = play(start(), place("a7")) + assertEquals(state, MorrisRules.viewFor(state, BLACK_SEAT)) + assertEquals(state, MorrisRules.viewFor(state, WHITE_SEAT)) + } + + @Test + fun `the summary says whose turn it is and how the game ended`() { + assertTrue(MorrisRules.summary(start()).startsWith("White to place")) + val moved = start().copy(phase = MorrisPhase.MOVING, placed = listOf(9, 9)) + assertEquals("White to move", MorrisRules.summary(moved)) + val over = moved.copy( + phase = MorrisPhase.GAME_OVER, + outcome = MorrisOutcome.BLACK_WINS_BLOCKED, + ) + assertEquals(MorrisOutcome.BLACK_WINS_BLOCKED.label, MorrisRules.summary(over)) + assertNull(MorrisRules.currentSeat(over)) + assertNotNull(MorrisRules.currentSeat(moved)) + } + + @Test + fun `every generated move is legal and every legal move can be played`() { + // Two plies of the placing phase, exhaustively: the opening is where + // the move count is widest and a mistake would hide the longest. + var checked = 0 + val first = start() + for (opening in MorrisRules.legalMoves(first)) { + val afterFirst = MorrisRules.applyMove(first, WHITE_SEAT, opening) + assertEquals(23, MorrisRules.legalMoves(afterFirst).size) + for (reply in MorrisRules.legalMoves(afterFirst)) { + MorrisRules.applyMove(afterFirst, BLACK_SEAT, reply) + checked++ + } + } + assertEquals(24 * 23, checked) + } + + @Test + fun `the move generator counts what it should three plies down`() { + // 24 openings, 23 replies each, and 22 for the third — no mill can be + // closed inside three plies, so every count is exact and independent of + // the rest of the rules. + assertEquals(24L, MorrisRules.perft(start(), 1)) + assertEquals(24L * 23, MorrisRules.perft(start(), 2)) + assertEquals(24L * 23 * 22, MorrisRules.perft(start(), 3)) + } + + /** Builds a board from point names, for positions no sane game would reach quickly. */ + private fun boardOf(white: List, black: List): List { + val board = arrayOfNulls(POINTS) + white.forEach { board[point(it)] = WHITE_SEAT } + black.forEach { board[point(it)] = BLACK_SEAT } + return board.toList() + } + + @Test + fun `a shorter game can be dealt out with fewer pieces`() { + var state = start(MorrisOptions(piecesEach = 3)) + assertEquals(3, state.inHand(WHITE_SEAT)) + state = play(state, place("a7"), place("g7"), place("a4"), place("g4")) + assertEquals(MorrisPhase.PLACING, state.phase) + state = play(state, place("b6")) + assertEquals(MorrisPhase.PLACING, state.phase) + state = play(state, place("f6")) + assertEquals(MorrisPhase.MOVING, state.phase) + assertFalse(MorrisRules.legalMoves(state).any { it.from == null }) + } +} diff --git a/docs/RULES-morris.md b/docs/RULES-morris.md new file mode 100644 index 0000000..9af3ed5 --- /dev/null +++ b/docs/RULES-morris.md @@ -0,0 +1,130 @@ +# Nine Men's Morris + +Two players, nine pieces each, on twenty-four points. Make a line of three and +you take one of theirs. Take them down to two and the game is yours. + +## The board + +``` +a7 ────────── d7 ────────── g7 +│ │ │ +│ b6 ─────── d6 ─────── f6 │ +│ │ │ │ │ +│ │ c5 ─── d5 ─── e5 │ │ +│ │ │ │ │ │ +a4 ─ b4 ─ c4 e4 ─ f4 ─ g4 +│ │ │ │ │ │ +│ │ c3 ─── d3 ─── e3 │ │ +│ │ │ │ │ +│ b2 ─────── d2 ─────── f2 │ +│ │ │ +a1 ────────── d1 ────────── g1 +``` + +Twenty-four points on three squares. A piece moves along a drawn line to the +next point on it: around a square, or between squares along one of the four +ladders that run out through the middle of each edge. **The corners have no +ladder**, which is why the middle of an edge is worth more than the corner +beside it — four ways off the middle square's mid-edge points, two off a corner. + +There is no point at the centre of the board, and the diagonals some boards are +printed with are not part of this game. + +Points are named as the game is normally recorded: files **a**–**g** left to +right, ranks **7**–**1** top to bottom. Only the intersections listed above +exist; `d4` is the hole in the middle. + +## A game + +**Placing.** Starting with White, each player puts one piece at a time on any +empty point until all eighteen are down. + +**Moving.** After that, a turn is one piece stepping along a line to an adjacent +empty point. + +**Flying.** A player reduced to three pieces may move a piece to *any* empty +point instead of stepping. This is on by default and can be turned off. + +## Mills + +Three of your pieces on one of the sixteen marked lines is a **mill**, and +closing one takes an enemy piece off the board. Four mills to a square, twelve +in all, plus the four that run out along the ladders. + +Three things about mills are easy to get wrong, and all three are implemented as +written here: + +- **A mill is closed by the piece that arrives.** Moving a piece *out* of a mill + takes nothing. Moving it back in closes the mill again and takes another + piece — the same mill, opened and closed repeatedly, is a perfectly good + way to win, and it is the strongest thing in the game. +- **Sliding along the line does not count.** If the point you left is part of + the line you have just completed, it is not a mill: the third piece it needs + is the one that made the move. +- **A piece standing in a mill cannot be taken** — unless every enemy piece is + standing in one, in which case any of them may be taken. Without that + exception a player whose pieces were all in mills could never be reduced, and + the game would not end. + +Closing a mill is not optional, and neither is taking for it: a move that +completes a line always carries the piece it removes. The only exception is the +case where there is nothing left to take, when the mill simply goes unpaid. + +## Winning + +- **Reduced to two.** A player with two pieces left cannot make a line and has + lost. This only applies once their hand is empty — a player mid-placement with + two on the board has seven more coming. +- **Nowhere to go.** A player with no legal move has lost. Since there is always + an empty point to place on, this can only happen once pieces are being moved. + +## Draws + +Two players who can each hold a position have no reason ever to stop, so: + +- **Threefold repetition.** The same board with the same player to move, three + times over, is a draw. +- **No mill for a hundred plies.** Fifty moves each with nothing taken is a + draw. Both figures are options, and the second can be turned off. + +## Options + +| | | +| --- | --- | +| **Opponent** | Casual, Club or Strong — how deep the computer searches | +| **Pieces each** | 3, 6 or 9 | +| **Flying on three** | on by default | +| **Threefold repetition** | on by default | +| **Draw with no mill for** | never, 50, 100 or 200 plies | + +Fewer than nine pieces is a shorter game on the same board. It is *not* Three or +Six Men's Morris, which are played on smaller boards of their own; calling it +that would be claiming more than the code does. + +## Playing it here + +Tap an empty point to place a piece. Once everything is on the board, tap one of +your own pieces and then tap where it goes; tapping a different piece of yours +picks that one up instead. When a move closes a mill, the pieces you may take +are ringed in red — tap one. If only one piece may be taken there is nothing to +choose and the move goes straight through. + +The board is not turned round for Black. It is symmetrical and both players are +looking at the same lines. + +## What the computer does + +A negamax search with alpha-beta pruning and iterative deepening, over the same +rules engine the game itself runs on, with a node budget so a phone does not +stall on the placing phase. + +It has no quiescence search and does not need one: in chess a fixed-depth search +goes wrong by stopping halfway through an exchange, and in Morris there are no +exchanges — a piece is taken for a mill and nothing is taken back. + +Its evaluation counts pieces first, then mills, lines that are one piece short +of a mill, how much room each side has to move, and how many of the mid-edge +junctions each side holds. It also knows that **being down to three is +dangerous even with flying on** — without that, the search notices that three +pieces can go anywhere, reads it as freedom, and starts steering towards losing +pieces to get there.