Merge dev: Nine Men's Morris
Verified on dev at 2328609, the same tree: build, unit tests and Android
lint green, and it published v1.0.49-dev.
This commit is contained in:
@@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN).
|
|||||||
|
|
||||||
## What works today
|
## 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ü |
|
| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess, Ta Yü, Nine Men's Morris |
|
||||||
| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Nine Men's Morris, Pirates and Bulgars |
|
| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Pirates and Bulgars |
|
||||||
|
|
||||||
Unimplemented games appear in the menu marked *Coming soon* **on dev builds
|
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,
|
only** — a production release lists just the games that can actually be played,
|
||||||
@@ -269,6 +269,7 @@ game:
|
|||||||
- [Wizard](docs/RULES-wizard.md)
|
- [Wizard](docs/RULES-wizard.md)
|
||||||
- [Crazy 8s](docs/RULES-crazy8s.md)
|
- [Crazy 8s](docs/RULES-crazy8s.md)
|
||||||
- [Chess](docs/RULES-chess.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**:
|
- [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
|
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
|
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
|
wizard/ Wizard model, rules, AI
|
||||||
crazyeights/ Crazy 8s model, rules, AI
|
crazyeights/ Crazy 8s model, rules, AI
|
||||||
chess/ Chess model, FEN, move generation, search
|
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
|
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||||
net/ wire protocol, LAN transport, lobby, match driver
|
net/ wire protocol, LAN transport, lobby, match driver
|
||||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ object GameCatalog {
|
|||||||
const val CHESS = "chess"
|
const val CHESS = "chess"
|
||||||
const val CHECKERS = "checkers"
|
const val CHECKERS = "checkers"
|
||||||
const val TAYU = "tayu"
|
const val TAYU = "tayu"
|
||||||
|
const val MORRIS = "morris"
|
||||||
|
|
||||||
val all: List<GameDescriptor> = listOf(
|
val all: List<GameDescriptor> = listOf(
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
@@ -151,6 +152,16 @@ object GameCatalog {
|
|||||||
blurb = "Forced captures, kings, and the long diagonal.",
|
blurb = "Forced captures, kings, and the long diagonal.",
|
||||||
available = false,
|
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(
|
GameDescriptor(
|
||||||
id = TAYU,
|
id = TAYU,
|
||||||
title = "Ta Yü",
|
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.euchre.EuchreOptions
|
||||||
import org.prolibertate.games.game.golf.GolfOptions
|
import org.prolibertate.games.game.golf.GolfOptions
|
||||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
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.president.PresidentOptions
|
||||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||||
import org.prolibertate.games.game.tayu.TayuLevel
|
import org.prolibertate.games.game.tayu.TayuLevel
|
||||||
@@ -71,6 +73,7 @@ fun GameSetupScreen(
|
|||||||
var wizard by remember { mutableStateOf(WizardOptions()) }
|
var wizard by remember { mutableStateOf(WizardOptions()) }
|
||||||
var chess by remember { mutableStateOf(ChessOptions()) }
|
var chess by remember { mutableStateOf(ChessOptions()) }
|
||||||
var tayu by remember { mutableStateOf(TayuOptions()) }
|
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
|
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||||
// always White, so choosing Black means sitting in seat 1.
|
// always White, so choosing Black means sitting in seat 1.
|
||||||
var playWhite by remember { mutableStateOf(true) }
|
var playWhite by remember { mutableStateOf(true) }
|
||||||
@@ -85,6 +88,7 @@ fun GameSetupScreen(
|
|||||||
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
|
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
|
||||||
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
||||||
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
|
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
|
||||||
|
GameCatalog.MORRIS -> setupJson.encodeToString(morris)
|
||||||
else -> "{}"
|
else -> "{}"
|
||||||
}
|
}
|
||||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||||
@@ -119,6 +123,8 @@ fun GameSetupScreen(
|
|||||||
|
|
||||||
GameCatalog.TAYU -> TayuOptionsEditor(tayu) { tayu = it }
|
GameCatalog.TAYU -> TayuOptionsEditor(tayu) { tayu = it }
|
||||||
|
|
||||||
|
GameCatalog.MORRIS -> MorrisOptionsEditor(morris) { morris = it }
|
||||||
|
|
||||||
else -> Text("No options yet for this game.")
|
else -> Text("No options yet for this game.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +202,7 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
|||||||
GameCatalog.WIZARD ->
|
GameCatalog.WIZARD ->
|
||||||
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
||||||
|
|
||||||
GameCatalog.CHESS -> 2
|
GameCatalog.CHESS, GameCatalog.MORRIS -> 2
|
||||||
|
|
||||||
GameCatalog.TAYU ->
|
GameCatalog.TAYU ->
|
||||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
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
|
@Composable
|
||||||
private fun TayuOptionsEditor(options: TayuOptions, onChange: (TayuOptions) -> Unit) {
|
private fun TayuOptionsEditor(options: TayuOptions, onChange: (TayuOptions) -> Unit) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
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.KaiserPhase
|
||||||
import org.prolibertate.games.game.kaiser.KaiserRules
|
import org.prolibertate.games.game.kaiser.KaiserRules
|
||||||
import org.prolibertate.games.game.kaiser.KaiserState
|
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.PresidentAi
|
||||||
import org.prolibertate.games.game.president.PresidentMove
|
import org.prolibertate.games.game.president.PresidentMove
|
||||||
import org.prolibertate.games.game.president.PresidentPhase
|
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.EuchreScreen
|
||||||
import org.prolibertate.games.ui.game.GolfScreen
|
import org.prolibertate.games.ui.game.GolfScreen
|
||||||
import org.prolibertate.games.ui.game.KaiserScreen
|
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.PresidentScreen
|
||||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||||
import org.prolibertate.games.ui.game.SequenceScreen
|
import org.prolibertate.games.ui.game.SequenceScreen
|
||||||
@@ -302,6 +307,27 @@ fun PlayScreen(
|
|||||||
TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
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 -> {
|
GameCatalog.CHESS -> {
|
||||||
val controller = remember(route) {
|
val controller = remember(route) {
|
||||||
MatchController<ChessState, ChessMove>(
|
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.WIZARD,
|
||||||
GameCatalog.CHESS,
|
GameCatalog.CHESS,
|
||||||
GameCatalog.TAYU,
|
GameCatalog.TAYU,
|
||||||
|
GameCatalog.MORRIS,
|
||||||
),
|
),
|
||||||
GameCatalog.playable.map { it.id }.toSet(),
|
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