Merge pull request #7: Nine Men's Morris
Verified green on the feature branch before merging: build, unit tests and Android lint (ci.yml run 46). Also brings dev back in step with main.
This commit is contained in:
@@ -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 |
|
||||
| **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
|
||||
|
||||
@@ -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<GameDescriptor> = 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ü",
|
||||
|
||||
@@ -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<MorrisState, MorrisMove> {
|
||||
|
||||
/**
|
||||
* 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>): 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<Pair<MorrisMove, Int>>()
|
||||
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<MorrisMove>, preferred: MorrisMove?): List<MorrisMove> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<List<Int>> = (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<List<Int>> = 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<List<List<Int>>> =
|
||||
(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<Int?>,
|
||||
val turn: Int,
|
||||
/** How many pieces each seat has put on the board, in total, ever. */
|
||||
val placed: List<Int>,
|
||||
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<String>,
|
||||
/** The game record, one entry per ply. */
|
||||
val moveLog: List<String>,
|
||||
) {
|
||||
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<Int>, 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"
|
||||
@@ -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<MorrisState, MorrisMove> {
|
||||
|
||||
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<MorrisOptions>(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<MorrisMove> = legalMoves(state, state.turn)
|
||||
|
||||
override fun legalMoves(state: MorrisState, seat: Int): List<MorrisMove> {
|
||||
if (state.phase == MorrisPhase.GAME_OVER || state.turn != seat) return emptyList()
|
||||
|
||||
val steps: List<Pair<Int?, Int>> = 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<Int> = 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<Int> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WizardOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CHESS -> 2
|
||||
GameCatalog.CHESS, GameCatalog.MORRIS -> 2
|
||||
|
||||
GameCatalog.TAYU ->
|
||||
setupJson.decodeFromString<TayuOptions>(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)) {
|
||||
|
||||
@@ -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<MorrisState, MorrisMove>(
|
||||
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<ChessState, ChessMove>(
|
||||
|
||||
@@ -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<MorrisState, MorrisMove>,
|
||||
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<Int?>(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<Int?>(null) }
|
||||
var millTo by remember { mutableStateOf<Int?>(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<Int>,
|
||||
takeable: Set<Int>,
|
||||
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") } },
|
||||
)
|
||||
}
|
||||
@@ -60,6 +60,7 @@ class CatalogTest {
|
||||
GameCatalog.WIZARD,
|
||||
GameCatalog.CHESS,
|
||||
GameCatalog.TAYU,
|
||||
GameCatalog.MORRIS,
|
||||
),
|
||||
GameCatalog.playable.map { it.id }.toSet(),
|
||||
)
|
||||
|
||||
@@ -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<String>, black: List<String>): List<Int?> {
|
||||
val board = arrayOfNulls<Int>(POINTS)
|
||||
white.forEach { board[point(it)] = WHITE_SEAT }
|
||||
black.forEach { board[point(it)] = BLACK_SEAT }
|
||||
return board.toList()
|
||||
}
|
||||
|
||||
private fun position(
|
||||
white: List<String>,
|
||||
black: List<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<String>, black: List<String>): List<Int?> {
|
||||
val board = arrayOfNulls<Int>(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 })
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user