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.