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. + } + } +}