Add Nine Men's Morris

Nine pieces each on twenty-four points: place them, then move them, and
every line of three takes an enemy piece off the board. Two pieces left,
or no legal move, and the game is over. See docs/RULES-morris.md.

The board is numbered ring * 8 + index, so its geometry falls out
arithmetically rather than being typed in as a table of twenty-four
rows: neighbours around a square are the index either side, the ladders
between squares run through the odd indices alone, and a mill is three
consecutive points centred on an odd one. A table would say the same
thing and could be wrong in one row.

A turn is one move object — the piece placed or stepped, and the enemy
piece taken when that closes a mill. Generating the removal as part of
the move rather than as a turn of its own means there is no moment where
a player is halfway through a turn, so a device that drops off the
network between closing a mill and choosing what to take cannot leave
the table stuck, and the search counts one ply for what is one decision.

Three rules about mills are easy to get backwards and all three are
tested: the piece that arrives is what closes a mill, so stepping out
takes nothing and stepping back in pays again; a slide along the line
being completed is not a mill, because the third piece it needs is the
one that just moved; and a piece in a mill is spared unless every enemy
piece is in one, without which a player could never be reduced and the
game would not end.

The AI is negamax with alpha-beta and iterative deepening over the same
rules engine, with a node budget so the placing phase does not stall a
phone. It has no quiescence tail and does not need one: there are no
exchanges in Morris. Its evaluation knows that being down to three is
dangerous even with flying on — without that term the search reads three
pieces going anywhere as freedom and starts steering towards losing
pieces to get there, which is a real game it would throw away.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
This commit is contained in:
Claude
2026-08-07 05:52:26 +00:00
parent e3c311f8b4
commit 94b51347c3
12 changed files with 2116 additions and 4 deletions
@@ -60,6 +60,7 @@ class CatalogTest {
GameCatalog.WIZARD,
GameCatalog.CHESS,
GameCatalog.TAYU,
GameCatalog.MORRIS,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -0,0 +1,149 @@
package org.prolibertate.games.game.morris
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MorrisAiTest {
private fun point(name: String) = requireNotNull(pointFromName(name)) { "no point $name" }
private fun boardOf(white: List<String>, black: List<String>): List<Int?> {
val board = arrayOfNulls<Int>(POINTS)
white.forEach { board[point(it)] = WHITE_SEAT }
black.forEach { board[point(it)] = BLACK_SEAT }
return board.toList()
}
private fun position(
white: List<String>,
black: List<String>,
turn: Int = WHITE_SEAT,
options: MorrisOptions = MorrisOptions(level = MorrisLevel.CLUB),
) = MorrisRules.initialState(options).copy(
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
turn = turn,
board = boardOf(white, black),
)
private fun choose(state: MorrisState, level: MorrisLevel = MorrisLevel.CLUB): MorrisMove =
MorrisAi(level).chooseMove(state, state.turn, MorrisRules.legalMoves(state))
@Test
fun `it always returns a move it is allowed to play`() {
var state = MorrisRules.initialState(MorrisOptions(level = MorrisLevel.CASUAL))
// A whole game, both sides played by the computer, which is the
// cheapest way to find a position the search mishandles.
var plies = 0
while (!MorrisRules.isFinished(state) && plies < 300) {
val legal = MorrisRules.legalMoves(state)
assertTrue("stuck with no move at ply $plies", legal.isNotEmpty())
val move = choose(state, MorrisLevel.CASUAL)
assertTrue("the AI picked a move that is not legal: $move", move in legal)
state = MorrisRules.applyMove(state, state.turn, move)
plies++
}
// Whatever happened, it has to have been a game rather than a hang.
assertTrue("the game never got anywhere in $plies plies", plies > 10)
}
@Test
fun `it takes a mill that is there for the taking`() {
// White holds both ends of the top edge and steps up from d6 to close
// it. Black is scattered — no two of their pieces share a line — so
// there is nothing to weigh against a free piece.
val state = position(
white = listOf("a7", "g7", "d6", "f6"),
black = listOf("a4", "b2", "d3", "e4", "g1"),
)
val move = choose(state, MorrisLevel.STRONG)
assertEquals(point("d7"), move.to)
assertEquals(point("d6"), move.from)
assertNotNull("closing the mill has to take something", move.remove)
}
@Test
fun `it takes a piece it is allowed to take, not one standing in a mill`() {
// Black's a-file mill is protected; three loose pieces are not. Black
// keeps five afterwards, so this does not hand them a flying mill.
val state = position(
white = listOf("d7", "d6", "c5", "b6"),
black = listOf("a7", "a4", "a1", "g1", "b2", "f2"),
)
val move = choose(state, MorrisLevel.STRONG)
assertEquals(point("c5"), move.from)
assertEquals(point("d5"), move.to)
assertTrue(
"a piece in a mill cannot be taken while a loose one is going spare",
move.remove in setOf(point("g1"), point("b2"), point("f2")),
)
}
@Test
fun `it will not hand the opponent a flying mill for the sake of a piece`() {
// Taking here would leave Black on three, flying, with their three
// pieces already in a line — they would step out and back in for a
// piece every second move, and White would lose the game for the sake
// of winning one. The search has to see past the free piece.
val state = position(
white = listOf("d7", "d6", "c5", "b6"),
black = listOf("a7", "a4", "a1", "g1"),
)
val move = choose(state, MorrisLevel.STRONG)
assertTrue(
"closing the mill here loses; expected anything else, got $move",
move.remove == null,
)
}
@Test
fun `it finishes a won game rather than playing on`() {
// Black is down to three, all of them takeable, and White can close a
// mill: that is the game, and the search should see it.
val state = position(
white = listOf("d7", "d6", "c5", "a7"),
black = listOf("g7", "g4", "g1"),
)
val move = choose(state, MorrisLevel.STRONG)
val next = MorrisRules.applyMove(state, WHITE_SEAT, move)
assertEquals(MorrisPhase.GAME_OVER, next.phase)
assertEquals(MorrisOutcome.WHITE_WINS_REDUCED, next.outcome)
}
@Test
fun `it blocks a mill about to be closed against it`() {
// Black holds d7 and d5 and will step b6 into d6 for the mill. Neither
// of the two pieces already on that line can close it themselves, so
// d6 is the whole game — and f6 is the only White piece that can reach
// it. White has no mill of its own to play instead.
val state = position(
white = listOf("f6", "d2", "a4", "g4"),
black = listOf("d7", "d5", "b6", "e3", "c3", "g1"),
)
val move = choose(state, MorrisLevel.STRONG)
assertEquals("d6 is the only square that matters", point("d6"), move.to)
}
@Test
fun `the same position gives the same move twice over`() {
// The host and a client both replay a game; if the AI wandered they
// would end up looking at different boards.
val state = MorrisRules.initialState(MorrisOptions(level = MorrisLevel.CLUB))
val first = choose(state)
val second = choose(state)
assertEquals(first, second)
}
@Test
fun `the opening does not take forever to think about`() {
// Twenty-four choices a ply with nothing to tell them apart is the
// widest this game ever gets. If the node budget is not doing its job
// this is where it shows.
val started = System.nanoTime()
choose(MorrisRules.initialState(MorrisOptions(level = MorrisLevel.STRONG)))
val millis = (System.nanoTime() - started) / 1_000_000
assertTrue("the opening move took ${millis}ms", millis < 20_000)
}
}
@@ -0,0 +1,476 @@
package org.prolibertate.games.game.morris
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MorrisRulesTest {
private fun start(options: MorrisOptions = MorrisOptions()) = MorrisRules.initialState(options)
/** Plays the given moves in turn, requiring each to be legal. */
private fun play(state: MorrisState, vararg moves: MorrisMove): MorrisState =
moves.fold(state) { current, move -> MorrisRules.applyMove(current, current.turn, move) }
private fun place(name: String) = MorrisMove(to = point(name))
private fun place(name: String, takes: String) =
MorrisMove(to = point(name), remove = point(takes))
private fun step(from: String, to: String) =
MorrisMove(to = point(to), from = point(from))
private fun step(from: String, to: String, takes: String) =
MorrisMove(to = point(to), from = point(from), remove = point(takes))
private fun point(name: String) = requireNotNull(pointFromName(name)) { "no point $name" }
// -----------------------------------------------------------------------
// The board
// -----------------------------------------------------------------------
@Test
fun `the board is twenty-four points and sixteen mills`() {
assertEquals(24, POINTS)
assertEquals(16, MILLS.size)
assertEquals(24, ADJACENCY.size)
// Every mill is three distinct points, and every point is in two mills.
MILLS.forEach { assertEquals(3, it.distinct().size) }
(0 until POINTS).forEach { assertEquals("point $it", 2, MILLS_THROUGH[it].size) }
}
@Test
fun `corners have two neighbours and mid-edge points three or four`() {
(0 until POINTS).forEach { point ->
val expected = when {
!isMidEdge(point) -> 2
// The middle square's mid-edge points have a ladder each way.
ringOf(point) == 1 -> 4
else -> 3
}
assertEquals(pointName(point), expected, ADJACENCY[point].size)
}
}
@Test
fun `adjacency is mutual`() {
(0 until POINTS).forEach { point ->
ADJACENCY[point].forEach { neighbour ->
assertTrue(
"${pointName(point)} sees ${pointName(neighbour)} but not the other way",
point in ADJACENCY[neighbour],
)
}
}
}
@Test
fun `points are named as the game is written down`() {
// The four corners of the outer square, and the centre of the top edge.
assertEquals("a7", pointName(pointOf(0, 0)))
assertEquals("d7", pointName(pointOf(0, 1)))
assertEquals("g7", pointName(pointOf(0, 2)))
assertEquals("g1", pointName(pointOf(0, 4)))
assertEquals("a1", pointName(pointOf(0, 6)))
// The middle of the board is a gap, not a point: the inner square's
// mid-edge points are the nearest thing to it.
assertEquals("d6", pointName(pointOf(1, 1)))
assertEquals("d5", pointName(pointOf(2, 1)))
assertNull(pointFromName("d4"))
(0 until POINTS).forEach { assertEquals(it, pointFromName(pointName(it))) }
}
// -----------------------------------------------------------------------
// Placing
// -----------------------------------------------------------------------
@Test
fun `the game opens with twenty-four placements and white to move`() {
val state = start()
assertEquals(MorrisPhase.PLACING, state.phase)
assertEquals(WHITE_SEAT, state.turn)
assertEquals(24, MorrisRules.legalMoves(state).size)
assertEquals(9, state.inHand(WHITE_SEAT))
assertEquals(0, state.onBoard(WHITE_SEAT))
}
@Test
fun `placing takes a piece from the hand and passes the turn`() {
val state = play(start(), place("a7"))
assertEquals(BLACK_SEAT, state.turn)
assertEquals(8, state.inHand(WHITE_SEAT))
assertEquals(1, state.onBoard(WHITE_SEAT))
assertEquals(23, MorrisRules.legalMoves(state).size)
}
@Test
fun `placing continues until every piece is on the board`() {
// Nine each, placed alternately down opposite edges of the board.
val white = listOf("a7", "d7", "g4", "g1", "d3", "b6", "f6", "c5", "e5")
val black = listOf("a4", "a1", "d1", "g7", "b4", "f4", "d2", "c3", "e3")
var state = start()
for (index in 0 until 9) {
state = play(state, place(white[index]))
state = play(state, place(black[index]))
}
assertEquals(MorrisPhase.MOVING, state.phase)
assertEquals(0, state.inHand(WHITE_SEAT))
assertEquals(0, state.inHand(BLACK_SEAT))
// Nobody built a mill, so all eighteen are still standing.
assertEquals(9, state.onBoard(WHITE_SEAT))
assertEquals(9, state.onBoard(BLACK_SEAT))
assertTrue(MorrisRules.legalMoves(state).all { it.from != null })
}
// -----------------------------------------------------------------------
// Mills
// -----------------------------------------------------------------------
@Test
fun `closing a mill takes an enemy piece`() {
val state = play(
start(),
place("a7"), place("a4"),
place("d7"), place("a1"),
// a7-d7-g7 is a mill, so this placement carries a removal.
place("g7", takes = "a4"),
)
assertEquals(3, state.onBoard(WHITE_SEAT))
assertEquals(1, state.onBoard(BLACK_SEAT))
assertEquals(0, state.pliesSinceMill)
assertTrue(state.isInMill(point("d7")))
}
@Test
fun `a mill must take something when there is anything to take`() {
val opening = play(
start(),
place("a7"), place("a4"),
place("d7"), place("a1"),
)
val millMoves = MorrisRules.legalMoves(opening).filter { it.to == point("g7") }
assertEquals(2, millMoves.size)
assertTrue("the mill has to be paid for", millMoves.all { it.remove != null })
assertEquals(setOf(point("a4"), point("a1")), millMoves.mapNotNull { it.remove }.toSet())
}
@Test
fun `a piece standing in a mill is spared while another is available`() {
// Black holds a mill down the a-file and one loose piece on g1. White
// steps c5 to d5 to close d7-d6-d5, and only the loose piece may be
// taken for it.
val state = start().copy(
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
board = boardOf(
white = listOf("d7", "d6", "c5", "b6"),
black = listOf("a7", "a4", "a1", "g1"),
),
)
// Only the step from c5 closes the mill; d6 can reach d5 too, but the
// piece it would need is the one making the move.
val choices = MorrisRules.legalMoves(state)
.filter { it.from == point("c5") && it.to == point("d5") }
assertEquals(1, choices.size)
assertEquals(point("g1"), choices.single().remove)
}
@Test
fun `when every enemy piece is in a mill any of them may be taken`() {
// Black's only three pieces are the a-file mill, so the rule that
// spares a milled piece has to give way — otherwise nothing could ever
// be taken and the game could not end.
val state = start().copy(
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
board = boardOf(
white = listOf("d7", "d6", "c5", "b6"),
black = listOf("a7", "a4", "a1"),
),
)
val choices = MorrisRules.legalMoves(state)
.filter { it.from == point("c5") && it.to == point("d5") }
assertEquals(3, choices.size)
assertEquals(
setOf(point("a7"), point("a4"), point("a1")),
choices.mapNotNull { it.remove }.toSet(),
)
}
@Test
fun `a mill needs three pieces, and the one that just left is not one of them`() {
// White holds two of the top edge and slides one of them along that
// same line. a7 to d7 leaves a7 empty, so it is two in a row, not a
// mill — which is the whole reason the vacated point is excluded.
val state = start().copy(
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
board = boardOf(
white = listOf("a7", "g7", "b6"),
black = listOf("g4", "g1", "d1"),
),
)
val slide = MorrisRules.legalMoves(state).single {
it.from == point("a7") && it.to == point("d7")
}
assertNull(slide.remove)
}
@Test
fun `stepping out of a mill takes nothing, and stepping back in pays again`() {
var state = start().copy(
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
board = boardOf(
white = listOf("a7", "d7", "g7", "b6"),
black = listOf("g4", "g1", "d1", "b2"),
),
)
val out = MorrisRules.legalMoves(state).filter { it.from == point("d7") }
assertTrue(out.isNotEmpty())
assertTrue("breaking your own mill is not a mill", out.all { it.remove == null })
state = play(state, step("d7", "d6"))
state = play(state, step("b2", "b4"))
val back = MorrisRules.legalMoves(state).filter {
it.from == point("d6") && it.to == point("d7")
}
// Re-forming the same mill pays again — that is the standard rule, and
// the piece that closes it is one that was not there a moment ago.
assertTrue(back.isNotEmpty())
assertTrue(back.all { it.remove != null })
assertEquals(
"any of Black's four, none of which is in a mill",
4,
back.size,
)
}
// -----------------------------------------------------------------------
// Moving, flying, and the end of the game
// -----------------------------------------------------------------------
@Test
fun `a piece steps along a line and not across the board`() {
val state = start().copy(
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
board = boardOf(
white = listOf("a7"),
black = listOf("g7", "g4", "g1", "d1"),
),
)
val moves = MorrisRules.legalMoves(state)
// a7 touches d7 and a4 only.
assertEquals(setOf(point("d7"), point("a4")), moves.map { it.to }.toSet())
}
@Test
fun `three pieces may fly, unless the table says otherwise`() {
val board = boardOf(white = listOf("a7", "a4", "a1"), black = listOf("g7", "g4", "g1"))
val flying = start().copy(phase = MorrisPhase.MOVING, placed = listOf(9, 9), board = board)
// Eighteen empty points, three pieces, and no line to follow.
assertEquals(18 * 3, MorrisRules.legalMoves(flying).size)
val grounded = flying.copy(options = flying.options.copy(flyingWithThree = false))
assertTrue(MorrisRules.legalMoves(grounded).size < 18 * 3)
assertTrue(
"without flying a piece still only steps to a neighbour",
MorrisRules.legalMoves(grounded).all { it.to in ADJACENCY[it.from!!] },
)
}
@Test
fun `taking a third piece ends the game`() {
// White closes a mill and takes Black's fourth piece, leaving two.
val state = start().copy(
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
board = boardOf(
white = listOf("d7", "d6", "c5", "a7"),
black = listOf("g7", "g4", "g1"),
),
)
val mill = MorrisRules.legalMoves(state).first { it.remove != null }
val ended = MorrisRules.applyMove(state, WHITE_SEAT, mill)
assertEquals(MorrisPhase.GAME_OVER, ended.phase)
assertEquals(MorrisOutcome.WHITE_WINS_REDUCED, ended.outcome)
assertTrue(MorrisRules.isFinished(ended))
assertTrue(MorrisRules.legalMoves(ended).isEmpty())
}
@Test
fun `a player with nowhere to go loses`() {
// Black holds the three top-left corners, one to a square, and White
// stands on every point they touch. Flying is off, so Black has no
// move at all once the turn comes round.
val state = start().copy(
options = MorrisOptions(flyingWithThree = false),
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
turn = WHITE_SEAT,
board = boardOf(
white = listOf("d7", "a4", "d6", "b4", "d5", "c4", "g1"),
black = listOf("a7", "b6", "c5"),
),
)
// White plays a quiet move away on the far side; Black is then on the
// clock with nothing to play.
val quiet = MorrisRules.legalMoves(state).first { it.from == point("g1") }
val ended = MorrisRules.applyMove(state, WHITE_SEAT, quiet)
assertEquals(MorrisOutcome.WHITE_WINS_BLOCKED, ended.outcome)
}
@Test
fun `the game is drawn when no mill has been closed for a long time`() {
val state = start().copy(
options = MorrisOptions(plyLimitWithoutMill = 4, flyingWithThree = false),
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
pliesSinceMill = 3,
board = boardOf(white = listOf("a7", "a4", "g4"), black = listOf("g7", "g1", "d1")),
)
val shuffle = MorrisRules.legalMoves(state).first { it.remove == null }
val ended = MorrisRules.applyMove(state, WHITE_SEAT, shuffle)
assertEquals(MorrisOutcome.DRAW_NO_MILL, ended.outcome)
}
@Test
fun `the same position three times over is a draw`() {
var state = start().copy(
phase = MorrisPhase.MOVING,
placed = listOf(9, 9),
board = boardOf(
white = listOf("a7", "a4", "g4", "f4"),
black = listOf("g7", "g1", "d1", "b2"),
),
)
state = state.copy(repetitionKeys = listOf(state.positionKey()))
// Both sides walk a piece out and back twice over, which returns the
// position to the board it started from for the third time.
val cycle = listOf(
step("f4", "f6"), step("b2", "b4"),
step("f6", "f4"), step("b4", "b2"),
)
var moves = 0
for (round in 0 until 2) {
for (move in cycle) {
if (state.phase == MorrisPhase.GAME_OVER) break
state = MorrisRules.applyMove(state, state.turn, move)
moves++
}
}
assertEquals(MorrisOutcome.DRAW_REPETITION, state.outcome)
assertTrue("it should not take more than two full cycles", moves <= 8)
}
// -----------------------------------------------------------------------
// The engine contract
// -----------------------------------------------------------------------
@Test
fun `an illegal move is refused rather than played`() {
val state = play(start(), place("a7"))
// Black tries to place on a point White has taken.
val onTop = runCatching { MorrisRules.applyMove(state, BLACK_SEAT, place("a7")) }
assertTrue(onTop.exceptionOrNull() is IllegalArgumentException)
// White tries to move out of turn.
val outOfTurn = runCatching { MorrisRules.applyMove(state, WHITE_SEAT, place("d7")) }
assertTrue(outOfTurn.exceptionOrNull() is IllegalArgumentException)
// A mill claimed where there is none.
val unearned = runCatching {
MorrisRules.applyMove(state, BLACK_SEAT, place("d7", takes = "a7"))
}
assertTrue(unearned.exceptionOrNull() is IllegalArgumentException)
}
@Test
fun `a state survives the round trip over the wire`() {
val state = play(
start(),
place("a7"), place("a4"),
place("d7"), place("a1"),
place("g7", takes = "a4"),
)
val restored = MorrisRules.decodeState(MorrisRules.encodeState(state))
assertEquals(state, restored)
val move = step("d7", "d6", takes = "a1")
assertEquals(move, MorrisRules.decodeMove(MorrisRules.encodeMove(move)))
}
@Test
fun `nothing is hidden from either seat`() {
val state = play(start(), place("a7"))
assertEquals(state, MorrisRules.viewFor(state, BLACK_SEAT))
assertEquals(state, MorrisRules.viewFor(state, WHITE_SEAT))
}
@Test
fun `the summary says whose turn it is and how the game ended`() {
assertTrue(MorrisRules.summary(start()).startsWith("White to place"))
val moved = start().copy(phase = MorrisPhase.MOVING, placed = listOf(9, 9))
assertEquals("White to move", MorrisRules.summary(moved))
val over = moved.copy(
phase = MorrisPhase.GAME_OVER,
outcome = MorrisOutcome.BLACK_WINS_BLOCKED,
)
assertEquals(MorrisOutcome.BLACK_WINS_BLOCKED.label, MorrisRules.summary(over))
assertNull(MorrisRules.currentSeat(over))
assertNotNull(MorrisRules.currentSeat(moved))
}
@Test
fun `every generated move is legal and every legal move can be played`() {
// Two plies of the placing phase, exhaustively: the opening is where
// the move count is widest and a mistake would hide the longest.
var checked = 0
val first = start()
for (opening in MorrisRules.legalMoves(first)) {
val afterFirst = MorrisRules.applyMove(first, WHITE_SEAT, opening)
assertEquals(23, MorrisRules.legalMoves(afterFirst).size)
for (reply in MorrisRules.legalMoves(afterFirst)) {
MorrisRules.applyMove(afterFirst, BLACK_SEAT, reply)
checked++
}
}
assertEquals(24 * 23, checked)
}
@Test
fun `the move generator counts what it should three plies down`() {
// 24 openings, 23 replies each, and 22 for the third — no mill can be
// closed inside three plies, so every count is exact and independent of
// the rest of the rules.
assertEquals(24L, MorrisRules.perft(start(), 1))
assertEquals(24L * 23, MorrisRules.perft(start(), 2))
assertEquals(24L * 23 * 22, MorrisRules.perft(start(), 3))
}
/** Builds a board from point names, for positions no sane game would reach quickly. */
private fun boardOf(white: List<String>, black: List<String>): List<Int?> {
val board = arrayOfNulls<Int>(POINTS)
white.forEach { board[point(it)] = WHITE_SEAT }
black.forEach { board[point(it)] = BLACK_SEAT }
return board.toList()
}
@Test
fun `a shorter game can be dealt out with fewer pieces`() {
var state = start(MorrisOptions(piecesEach = 3))
assertEquals(3, state.inHand(WHITE_SEAT))
state = play(state, place("a7"), place("g7"), place("a4"), place("g4"))
assertEquals(MorrisPhase.PLACING, state.phase)
state = play(state, place("b6"))
assertEquals(MorrisPhase.PLACING, state.phase)
state = play(state, place("f6"))
assertEquals(MorrisPhase.MOVING, state.phase)
assertFalse(MorrisRules.legalMoves(state).any { it.from == null })
}
}