From 51aa0c35186b438099f7d4d42b41a89b8f6d1b6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 13:01:07 +0000 Subject: [PATCH] Peg solitaire, on five boards The tee game from a restaurant table, and four larger boards it generalises to. Two grid shapes, and the only difference between them is how many directions a peg can jump: a triangle staggers its rows so a hole has six neighbours, a square board has four. The jump, the goal and the dead end are the same either way, which is why one legalJumps serves all five. Hole counts are checked rather than asserted from memory -- 15, 21, 33 for the English cross, 37 for the shallower French cut, 41 for the diamond -- and a test walks every board from its opening to a standstill, which is what would catch a geometry that produced a board nobody can move on. PegJump carries only its ends. A jump is always two steps in one direction, so the hole in between is the only one it can be, and storing it as well would be a second copy of the same fact with the freedom to disagree. This is the first one-seat game in the app. GameRules took it without changes: one seat, always on the clock until the board runs out of jumps, and viewFor is the identity because there is nobody to hide anything from. The catalogue entry and the seat-count change it needs come with the screen. The AI never runs during an ordinary game, since the only seat belongs to the person playing. It is here because an empty seat has to be playable by something, and because keeping the pegs together is what a "suggest a jump" button would want. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC --- .../org/prolibertate/games/game/Catalog.kt | 7 + .../games/game/pegsolitaire/PegSolitaireAi.kt | 54 ++++ .../game/pegsolitaire/PegSolitaireModel.kt | 153 +++++++++++ .../game/pegsolitaire/PegSolitaireRules.kt | 127 +++++++++ .../pegsolitaire/PegSolitaireRulesTest.kt | 240 ++++++++++++++++++ 5 files changed, 581 insertions(+) create mode 100644 app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 20e3cea..bf0b16d 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -41,6 +41,13 @@ object GameCatalog { * [all] when its screen lands. */ const val HEARTS = "hearts" + + /** + * Peg solitaire, likewise an id ahead of its screen. It is also the first + * game here played by one person, so it arrives with the seat-count change + * that allows it rather than before it. + */ + const val PEG_SOLITAIRE = "pegsolitaire" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt new file mode 100644 index 0000000..0b73946 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireAi.kt @@ -0,0 +1,54 @@ +package org.prolibertate.games.game.pegsolitaire + +import org.prolibertate.games.game.engine.GameAi + +/** + * Plays the board for the one seat. + * + * Nothing normally asks it to: the single seat belongs to the person holding the + * phone, so this never runs during an ordinary game. It exists because a seat + * that could be left empty has to be playable by something, and because a + * one-move-deep chooser is exactly what a "suggest a jump" button would want. + * + * The heuristic is the one every good peg player uses by hand: keep the pegs + * together. A jump that leaves more jumps behind it has not painted the board + * into a corner, and pulling toward the middle stops a stranded peg being left + * out on an arm where nothing can ever reach it. + */ +object PegSolitaireAi : GameAi { + + override fun chooseMove( + state: PegSolitaireState, + seat: Int, + legal: List, + ): PegSolitaireMove { + require(legal.isNotEmpty()) { "No jump available" } + val jumps = legal.filterIsInstance() + if (jumps.isEmpty()) return legal.first() + return jumps.maxBy { score(state, it) } + } + + private fun score(state: PegSolitaireState, jump: PegJump): Int { + val after = state.copy(pegs = state.pegs - jump.from - jump.over + jump.to) + // Solving outright beats every other consideration. + if (after.solved) return Int.MAX_VALUE + val followUps = PegSolitaireRules.legalJumps(after).size + return followUps * 10 - spread(after) + } + + /** + * How far the pegs sit from their own centre, in whole holes. + * + * A smaller number is a tighter cluster. Manhattan distance is the right + * measure on both board shapes here, because a jump moves two steps along one + * of the grid's own directions either way. + */ + private fun spread(state: PegSolitaireState): Int { + if (state.pegs.isEmpty()) return 0 + val meanRow = state.pegs.sumOf { it.row } / state.pegs.size + val meanCol = state.pegs.sumOf { it.col } / state.pegs.size + return state.pegs.sumOf { + kotlin.math.abs(it.row - meanRow) + kotlin.math.abs(it.col - meanCol) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt new file mode 100644 index 0000000..2112031 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireModel.kt @@ -0,0 +1,153 @@ +package org.prolibertate.games.game.pegsolitaire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * A hole in the board, by row and column. + * + * Both geometries the boards come in are indexed this way, which is what lets + * one jump rule serve them all: a triangular row is simply shorter than the one + * below it, and a cross is a square with pieces bitten out of the corners. + */ +@Serializable +data class Hole(val row: Int, val col: Int) + +/** + * The boards this plays on. + * + * Two shapes of grid, and the difference between them is only how many + * directions a peg can jump in. On a triangle a hole has six neighbours, because + * the rows are staggered; on a square board it has four, because they are not. + * Everything else — the jump, the goal, the dead end — is the same game. + */ +@Serializable +enum class PegBoard(val label: String, val holeCount: Int) { + /** The tee game found on a restaurant table: five rows, fifteen holes. */ + TRIANGLE_15("Triangle of 15", 15), + TRIANGLE_21("Triangle of 21", 21), + + /** The classic cross: a seven by seven square with two-by-two corners cut. */ + ENGLISH_CROSS("English cross", 33), + + /** The continental board, with a shallower cut that leaves four more holes. */ + FRENCH_CROSS("French cross", 37), + DIAMOND_41("Diamond of 41", 41), + ; + + val isTriangular: Boolean get() = this == TRIANGLE_15 || this == TRIANGLE_21 + + /** Every hole on this board. */ + fun holes(): Set = when (this) { + TRIANGLE_15 -> triangle(5) + TRIANGLE_21 -> triangle(6) + // 49 less the four two-by-two corners. + ENGLISH_CROSS -> square(7) { r, c -> r in 2..4 || c in 2..4 } + // 49 less a three-hole triangle at each corner. + FRENCH_CROSS -> square(7) { r, c -> + val fromEdgeRow = minOf(r, 6 - r) + val fromEdgeCol = minOf(c, 6 - c) + fromEdgeRow + fromEdgeCol >= 2 + } + + DIAMOND_41 -> square(9) { r, c -> kotlin.math.abs(r - 4) + kotlin.math.abs(c - 4) <= 4 } + } + + /** + * The directions a jump may run in, as row and column steps. + * + * Diagonals are not among the four on a square board: a peg moves along a + * rank or a file and nowhere else, which is what makes the cross boards hard. + */ + fun directions(): List> = if (isTriangular) { + listOf(0 to -1, 0 to 1, -1 to -1, -1 to 0, 1 to 0, 1 to 1) + } else { + listOf(0 to -1, 0 to 1, -1 to 0, 1 to 0) + } + + /** Where the one empty hole sits when nothing else is chosen. */ + fun defaultStart(): Hole = when { + // The apex, which is how the tee game is set out on the table. + isTriangular -> Hole(0, 0) + this == DIAMOND_41 -> Hole(4, 4) + else -> Hole(3, 3) + } + + private fun triangle(rows: Int): Set = + (0 until rows).flatMap { r -> (0..r).map { c -> Hole(r, c) } }.toSet() + + private fun square(size: Int, keep: (Int, Int) -> Boolean): Set = + (0 until size).flatMap { r -> + (0 until size).mapNotNull { c -> Hole(r, c).takeIf { keep(r, c) } } + }.toSet() +} + +/** + * What counts as having won. + * + * Leaving one peg anywhere is the game as it is usually played. Leaving it in + * the hole the board started empty is the harder problem, and on the English + * cross it is the one the puzzle is famous for. + */ +@Serializable +enum class PegGoal(val label: String) { + ONE_PEG("Leave one peg"), + ONE_PEG_IN_START("Leave one peg, in the starting hole"), +} + +@Serializable +data class PegSolitaireOptions( + val board: PegBoard = PegBoard.TRIANGLE_15, + val goal: PegGoal = PegGoal.ONE_PEG, + /** + * The hole left empty at the start. Null takes the board's own default, + * which keeps a saved table valid if the default ever moves. + */ + val startEmpty: Hole? = null, +) { + /** The empty hole this table actually opens with. */ + fun start(): Hole = startEmpty ?: board.defaultStart() + + init { + require(board.holes().contains(start())) { + "${start()} is not a hole on the ${board.label}" + } + } +} + +@Serializable +data class PegSolitaireState( + val options: PegSolitaireOptions, + /** Which holes still hold a peg. Everything else on the board is empty. */ + val pegs: Set, + val jumps: Int, + /** Held so the screen can show what just happened. */ + val lastJump: PegJump? = null, + val log: List, +) { + val board: PegBoard get() = options.board + + val remaining: Int get() = pegs.size + + /** True once the board matches what the chosen goal asked for. */ + val solved: Boolean + get() = remaining == 1 && when (options.goal) { + PegGoal.ONE_PEG -> true + PegGoal.ONE_PEG_IN_START -> pegs.first() == options.start() + } +} + +@Serializable +sealed interface PegSolitaireMove + +/** + * A peg jumps a neighbour and lands two holes away, and the peg it jumped is + * taken off. [over] is not carried because a jump is always exactly two steps in + * one direction, so the hole between the ends is the only one it can be — + * storing it would be a second copy of the same fact, free to disagree. + */ +@Serializable +@SerialName("jump") +data class PegJump(val from: Hole, val to: Hole) : PegSolitaireMove { + val over: Hole get() = Hole((from.row + to.row) / 2, (from.col + to.col) / 2) +} diff --git a/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt new file mode 100644 index 0000000..e885805 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRules.kt @@ -0,0 +1,127 @@ +package org.prolibertate.games.game.pegsolitaire + +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 + +/** + * Peg solitaire as a pure state machine. See RULES-pegsolitaire.md. + * + * The first game here played by one person, which the engine takes in its + * stride: there is one seat, it is always on the clock until the board runs out + * of jumps, and [viewFor] has nothing to hide because there is nobody to hide it + * from. + */ +object PegSolitaireRules : GameRules { + + override val gameId: String = GameCatalog.PEG_SOLITAIRE + + /** The one seat. Named rather than written as a bare 0 at each use. */ + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): PegSolitaireState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { + "Peg solitaire seats one, not ${config.seats.size}" + } + val start = options.start() + return PegSolitaireState( + options = options, + pegs = options.board.holes() - start, + jumps = 0, + lastJump = null, + log = listOf("${options.board.label}, opening at row ${start.row + 1}."), + ) + } + + /** + * Every jump available on the board as it stands. + * + * A jump needs three holes in a line: a peg to move, a peg beside it to take, + * and an empty hole beyond. Checking that the landing hole is on the board is + * what keeps a jump from running off a triangle's short row or out through a + * cross's missing corner. + */ + fun legalJumps(state: PegSolitaireState): List { + val holes = state.board.holes() + val directions = state.board.directions() + return state.pegs.flatMap { from -> + directions.mapNotNull { (dr, dc) -> + val over = Hole(from.row + dr, from.col + dc) + val to = Hole(from.row + 2 * dr, from.col + 2 * dc) + // `over` holding a peg already implies it is a hole, since a peg + // can only ever sit in one. + val ok = state.pegs.contains(over) && + holes.contains(to) && + !state.pegs.contains(to) + if (ok) PegJump(from, to) else null + } + } + } + + override fun currentSeat(state: PegSolitaireState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + override fun legalMoves(state: PegSolitaireState, seat: Int): List = + if (seat != SOLO_SEAT) emptyList() else legalJumps(state) + + override fun applyMove( + state: PegSolitaireState, + seat: Int, + move: PegSolitaireMove, + ): PegSolitaireState { + require(seat == SOLO_SEAT) { "Peg solitaire has one seat" } + val jump = move as PegJump + require(legalJumps(state).contains(jump)) { "$jump is not a legal jump" } + val pegs = state.pegs - jump.from - jump.over + jump.to + val next = state.copy( + pegs = pegs, + jumps = state.jumps + 1, + lastJump = jump, + ) + val note = when { + next.solved -> "Solved in ${next.jumps} jumps." + legalJumps(next).isEmpty() -> "No jumps left, ${next.remaining} pegs standing." + else -> null + } + return if (note == null) next else next.copy(log = next.log + note) + } + + /** The board is finished when nothing can jump, whether or not it was solved. */ + override fun isFinished(state: PegSolitaireState): Boolean = legalJumps(state).isEmpty() + + override fun summary(state: PegSolitaireState): String = when { + state.solved -> "Solved in ${state.jumps} jumps" + isFinished(state) -> "Stuck with ${state.remaining} pegs" + else -> "${state.remaining} pegs, ${legalJumps(state).size} jumps available" + } + + /** + * Nothing to strip. One person plays, the whole board is face up in front of + * them, and there is no second seat that could be told less than the truth. + */ + override fun viewFor(state: PegSolitaireState, seat: Int): PegSolitaireState = state + + override fun encodeState(state: PegSolitaireState): String = json.encodeToString(state) + override fun decodeState(json: String): PegSolitaireState = this.json.decodeFromString(json) + override fun encodeMove(move: PegSolitaireMove): String = json.encodeToString(move) + override fun decodeMove(json: String): PegSolitaireMove = this.json.decodeFromString(json) + + /** Deals the same board again, so a lost game can be retried from the start. */ + fun restart(state: PegSolitaireState): PegSolitaireState { + val start = state.options.start() + return state.copy( + pegs = state.options.board.holes() - start, + jumps = 0, + lastJump = null, + log = state.log + "Board reset.", + ) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt new file mode 100644 index 0000000..387bde0 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/pegsolitaire/PegSolitaireRulesTest.kt @@ -0,0 +1,240 @@ +package org.prolibertate.games.game.pegsolitaire + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class PegSolitaireRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: PegSolitaireOptions = PegSolitaireOptions()) = TableConfig( + gameId = "pegsolitaire", + seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)), + optionsJson = json.encodeToString(options), + seed = 1L, + ) + + @Test + fun `every board has the number of holes it claims`() { + PegBoard.entries.forEach { board -> + assertEquals(board.label, board.holeCount, board.holes().size) + } + } + + @Test + fun `a triangle staggers its rows and a cross does not`() { + // Six ways off a hole on a triangle, four on a square grid. This is the + // only thing that differs between the two shapes. + assertEquals(6, PegBoard.TRIANGLE_15.directions().size) + assertEquals(6, PegBoard.TRIANGLE_21.directions().size) + assertEquals(4, PegBoard.ENGLISH_CROSS.directions().size) + assertEquals(4, PegBoard.FRENCH_CROSS.directions().size) + assertEquals(4, PegBoard.DIAMOND_41.directions().size) + // No diagonal creeps into a cross board. + assertTrue(PegBoard.ENGLISH_CROSS.directions().all { (dr, dc) -> dr == 0 || dc == 0 }) + } + + @Test + fun `the cross boards are cut where they should be`() { + val english = PegBoard.ENGLISH_CROSS.holes() + assertFalse("a two-by-two corner is gone", english.contains(Hole(0, 0))) + assertFalse(english.contains(Hole(1, 1))) + assertTrue(english.contains(Hole(0, 3))) + assertTrue(english.contains(Hole(3, 3))) + + val french = PegBoard.FRENCH_CROSS.holes() + // The shallower cut takes three holes per corner, so (1,1) survives. + assertFalse(french.contains(Hole(0, 0))) + assertFalse(french.contains(Hole(0, 1))) + assertFalse(french.contains(Hole(1, 0))) + assertTrue("the French cut leaves this one", french.contains(Hole(1, 1))) + + val diamond = PegBoard.DIAMOND_41.holes() + assertTrue(diamond.contains(Hole(0, 4))) + assertTrue(diamond.contains(Hole(4, 0))) + assertFalse(diamond.contains(Hole(0, 0))) + } + + @Test + fun `a board opens one peg short`() { + val state = PegSolitaireRules.initialState(config()) + assertEquals(PegBoard.TRIANGLE_15.holeCount - 1, state.remaining) + assertFalse("the opening hole is empty", state.pegs.contains(Hole(0, 0))) + assertEquals(0, state.jumps) + assertNull(state.lastJump) + } + + @Test + fun `the tee game opens with exactly two jumps, both into the apex`() { + val state = PegSolitaireRules.initialState(config()) + val jumps = PegSolitaireRules.legalJumps(state) + assertEquals(2, jumps.size) + assertTrue("every opening jump lands in the empty hole", jumps.all { it.to == Hole(0, 0) }) + assertEquals(setOf(Hole(2, 0), Hole(2, 2)), jumps.map { it.from }.toSet()) + // The jumped peg is the one between the ends, and it is never invented. + assertEquals(setOf(Hole(1, 0), Hole(1, 1)), jumps.map { it.over }.toSet()) + } + + @Test + fun `a jump moves one peg and takes the one it passed`() { + val state = PegSolitaireRules.initialState(config()) + val jump = PegJump(from = Hole(2, 0), to = Hole(0, 0)) + val after = PegSolitaireRules.applyMove(state, 0, jump) + assertFalse("the mover left", after.pegs.contains(Hole(2, 0))) + assertFalse("the jumped peg was taken", after.pegs.contains(Hole(1, 0))) + assertTrue("the mover arrived", after.pegs.contains(Hole(0, 0))) + assertEquals(state.remaining - 1, after.remaining) + assertEquals(1, after.jumps) + assertEquals(jump, after.lastJump) + } + + @Test + fun `a jump needs a peg to pass and an empty hole to land in`() { + val state = PegSolitaireRules.initialState(config()) + // Landing hole already occupied. + assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(4, 0), Hole(2, 0))) } + // Nothing to jump over: (1,0) is empty only after a jump, but (0,0) is + // the opening hole, so a jump starting there has no peg to move. + assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(0, 0), Hole(2, 0))) } + // Straight off the board. + assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(2, 0), Hole(2, 4))) } + } + + @Test + fun `only the one seat may play`() { + val state = PegSolitaireRules.initialState(config()) + assertEquals(0, PegSolitaireRules.currentSeat(state)) + assertTrue(PegSolitaireRules.legalMoves(state, 1).isEmpty()) + assertThrows { PegSolitaireRules.applyMove(state, 1, PegJump(Hole(2, 0), Hole(0, 0))) } + } + + @Test + fun `a table for anything but one seat is refused`() { + val twoSeats = TableConfig( + gameId = "pegsolitaire", + seats = (0..1).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(PegSolitaireOptions()), + seed = 1L, + ) + assertThrows { PegSolitaireRules.initialState(twoSeats) } + } + + @Test + fun `one peg left is solved, and the stricter goal asks where`() { + val loose = PegSolitaireOptions(goal = PegGoal.ONE_PEG) + val strict = PegSolitaireOptions(goal = PegGoal.ONE_PEG_IN_START) + val base = PegSolitaireRules.initialState(config(loose)) + + val elsewhere = base.copy(pegs = setOf(Hole(4, 2))) + assertTrue(elsewhere.solved) + assertFalse("the strict goal wants it back where it started", elsewhere.copy(options = strict).solved) + + val atStart = base.copy(options = strict, pegs = setOf(Hole(0, 0))) + assertTrue("the survivor is in the opening hole", atStart.solved) + } + + @Test + fun `a board with no jumps left is finished, solved or not`() { + val state = PegSolitaireRules.initialState(config()) + // Two pegs far apart cannot reach each other, so nothing can jump. + val stuck = state.copy(pegs = setOf(Hole(4, 0), Hole(4, 4))) + assertTrue(PegSolitaireRules.isFinished(stuck)) + assertFalse(stuck.solved) + assertNull(PegSolitaireRules.currentSeat(stuck)) + assertTrue(PegSolitaireRules.summary(stuck).contains("Stuck")) + + val won = state.copy(pegs = setOf(Hole(2, 1))) + assertTrue(PegSolitaireRules.isFinished(won)) + assertTrue(won.solved) + } + + @Test + fun `an opening hole has to be a hole on the board`() { + assertThrows { PegSolitaireOptions(board = PegBoard.TRIANGLE_15, startEmpty = Hole(9, 9)) } + // A hole that exists on one board but not another. + assertThrows { + PegSolitaireOptions(board = PegBoard.ENGLISH_CROSS, startEmpty = Hole(0, 0)) + } + } + + @Test + fun `every board can be opened and played`() { + // Each board deals, offers jumps, and runs to a finish without the rules + // throwing. This is what catches a geometry that produces a board nobody + // can move on at all. + PegBoard.entries.forEach { board -> + val state = PegSolitaireRules.initialState( + config(PegSolitaireOptions(board = board)), + ) + assertEquals(board.holeCount - 1, state.remaining) + assertTrue( + "${board.label} should have an opening jump", + PegSolitaireRules.legalJumps(state).isNotEmpty(), + ) + val end = playOut(state) + assertTrue(PegSolitaireRules.isFinished(end)) + assertTrue("${board.label} should take pegs off", end.remaining < state.remaining) + } + } + + @Test + fun `restart puts every peg back`() { + val state = PegSolitaireRules.initialState(config()) + val played = PegSolitaireRules.applyMove(state, 0, PegJump(Hole(2, 0), Hole(0, 0))) + val again = PegSolitaireRules.restart(played) + assertEquals(state.pegs, again.pegs) + assertEquals(0, again.jumps) + assertNull(again.lastJump) + } + + @Test + fun `nothing is hidden from the only player`() { + val state = PegSolitaireRules.initialState(config()) + assertEquals(state, PegSolitaireRules.viewFor(state, 0)) + } + + @Test + fun `state and moves survive a round trip`() { + PegBoard.entries.forEach { board -> + val state = PegSolitaireRules.initialState( + config(PegSolitaireOptions(board = board, goal = PegGoal.ONE_PEG_IN_START)), + ) + assertEquals(state, PegSolitaireRules.decodeState(PegSolitaireRules.encodeState(state))) + } + val move: PegSolitaireMove = PegJump(Hole(2, 0), Hole(0, 0)) + assertEquals(move, PegSolitaireRules.decodeMove(PegSolitaireRules.encodeMove(move))) + } + + /** Runs a board to a standstill using the AI, which never has to guess. */ + private fun playOut(from: PegSolitaireState): PegSolitaireState { + var state = from + // Each jump takes exactly one peg off, so the board cannot outlast its + // own peg count — a loop that did would be a bug in applyMove. + var guard = from.remaining + 1 + while (!PegSolitaireRules.isFinished(state) && guard-- > 0) { + val legal = PegSolitaireRules.legalMoves(state, 0) + state = PegSolitaireRules.applyMove(state, 0, PegSolitaireAi.chooseMove(state, 0, legal)) + } + assertTrue("playing out should terminate", guard > 0) + return state + } + + private fun assertThrows(block: () -> Unit) { + try { + block() + throw AssertionError("expected the rules to reject that") + } catch (expected: IllegalArgumentException) { + // Rejected, which is the point. + } + } +}