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 fa63168..ed028bf 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -106,6 +106,7 @@ object GameCatalog { const val TAYU = "tayu" const val MORRIS = "morris" const val PIRATES = "pirates" + const val MEOWDOKU = "meowdoku" /** * The island game the collection is named for. Listed under Board Games @@ -368,6 +369,18 @@ object GameCatalog { available = false, devPreview = true, ), + GameDescriptor( + id = MEOWDOKU, + title = "Meowdoku", + menus = setOf(GameMenu.BOARD, GameMenu.SOLITAIRE), + minPlayers = 1, + maxPlayers = 1, + teamBased = false, + blurb = "One cat in every row, column and colour, and no two touching. " + + "Wrong taps cost a heart; the board only ever accepts a cat that belongs.", + available = false, + devPreview = true, + ), ) fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt new file mode 100644 index 0000000..93bd59e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt @@ -0,0 +1,46 @@ +package org.prolibertate.games.game.meowdoku + +import org.prolibertate.games.game.engine.GameAi + +/** + * Plays the board for the one seat. + * + * Like peg solitaire's AI, nothing normally asks it to: the single seat belongs + * to the person holding the phone. It exists because an empty seat has to be + * playable by something, and because "the computer can finish this board" is a + * property worth testing — a generated puzzle that only a human could unpack is + * a broken promise about uniqueness. + * + * It reads the answer from the state. The rules hold it, the seat is alone with + * it, and a solver that deduced its way there would be the same length as the + * hint engine in the original game without making this seat any more honest. + */ +object MeowdokuAi : GameAi { + + override fun chooseMove( + state: MeowdokuState, + seat: Int, + legal: List, + ): MeowdokuMove { + require(legal.isNotEmpty()) { "No move available" } + val places = legal.filterIsInstance().associateBy { it.square } + + // The first cat still missing. A pencil cross in front of it is the + // player's own note being wrong, so erase it; a hint spends one token + // and reveals the square regardless. + for (row in 0 until state.side) { + val missing = state.cats.none { it.row == row } + if (!missing) continue + val square = Square(row, state.catCols[row]) + places[square]?.let { return it } + if (square in state.marks) { + if (TakeHint in legal && state.hintsLeft > 0) return TakeHint + legal.filterIsInstance() + .firstOrNull { it.square == square } + ?.let { return it } + } + } + + return legal.first() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuGenerator.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuGenerator.kt new file mode 100644 index 0000000..bee202d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuGenerator.kt @@ -0,0 +1,371 @@ +package org.prolibertate.games.game.meowdoku + +import kotlin.math.abs + +/** + * Builds the puzzles. + * + * A Meowdoku puzzle is an N by N board, a colouring of it into N regions, and a + * hidden answer of N cats. The three are not chosen separately: the cats come + * first, then the regions grow out from them so every region owns exactly one + * cat, then the finished board is checked to make sure it has only that one + * answer. A puzzle with two answers is a puzzle with no answer, since the second + * one cannot be deduced from what is printed. + * + * The construction is seeded and reproducible: the same seed gives the same + * board, which is what lets a saved game come back unchanged and lets a test + * speak about "the" puzzle for a seed. + */ +object MeowdokuGenerator { + + /** Smallest and largest boards worth dealing; anything bigger is a wall. */ + val MIN_SIDE = 4 + val MAX_SIDE = 10 + + /** + * The answer, as the column of the cat in each row. + * + * One cat per row and one per column is already built in, since the rows are + * enumerated once each and no column is taken twice. + */ + fun placeCats(side: Int, rng: SeededRng): IntArray { + val cols = IntArray(side) { -1 } + val placed = placeCatsFrom(0, side, cols, rng) + // Every board from four up has a placement, and the search is complete, + // so failing here would mean the generator itself is broken. + check(placed) { "No way to settle $side cats on a $side board" } + return cols + } + + private fun placeCatsFrom(row: Int, side: Int, cols: IntArray, rng: SeededRng): Boolean { + if (row == side) return true + for (col in rng.shuffled(cols.indices.toList())) { + if (catFitsHere(row, col, cols)) { + cols[row] = col + if (placeCatsFrom(row + 1, side, cols, rng)) return true + cols[row] = -1 + } + } + return false + } + + /** + * Whether a cat at [row],[col] is at peace with the cats in the rows above. + * + * No shared column, and no shared corner: a cat grooms the squares touching + * its own on all eight sides, so a neighbour one step away in any direction + * is too close. + */ + fun catFitsHere(row: Int, col: Int, cols: IntArray): Boolean { + for (r in 0 until row) { + val c = cols[r] + if (c == col) return false + if (abs(r - row) <= 1 && abs(c - col) <= 1) return false + } + return true + } + + /** + * The colours of the board, as a region id per square. + * + * Each region starts as the cat it contains and grows outwards one square at + * a time. The first [strips] regions are grown as straight bars along their + * cat's row or column, alternating which way they run: a board of nothing but + * blobs reads as texture, and the bars are what give the regions a shape a + * player can reason about. The rest take the shortest border square, which + * keeps them clumped rather than trailing off into the far corner. + */ + fun growRegions(side: Int, catCols: IntArray, rng: SeededRng, strips: Int): Array { + val regions = Array(side) { IntArray(side) { -1 } } + val dirs = listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0) + + // Region i owns the cat in row i, which is where its colour starts. + for (r in 0 until side) regions[r][catCols[r]] = r + + val sizes = IntArray(side) { 1 } + var unassigned = side * side - side + + // The straight bars are laid first, and the rest is grown around them. + // The straight bars are laid first, and the rest is grown around them. + // Bar i runs along its cat's row when i is even, down its column when odd. + val stripRegions = rng.shuffled((0 until side).toList()).take(strips.coerceIn(0, side - 1)).toSet() + for ((n, region) in stripRegions.withIndex()) { + val r0 = region + val c0 = catCols[r0] + val extra = 1 + rng.nextInt(3) + var added = 0 + if (n % 2 == 0) { + var left = c0 - 1 + var right = c0 + 1 + while (added < extra) { + val goLeft = rng.nextBoolean() + when { + goLeft && left >= 0 && regions[r0][left] == -1 -> + regions[r0][left--] = region + right < side && regions[r0][right] == -1 -> + regions[r0][right++] = region + left >= 0 && regions[r0][left] == -1 -> + regions[r0][left--] = region + else -> break + } + sizes[region]++ + unassigned-- + added++ + } + } else { + var up = r0 - 1 + var down = r0 + 1 + while (added < extra) { + val goUp = rng.nextBoolean() + when { + goUp && up >= 0 && regions[up][c0] == -1 -> + regions[up--][c0] = region + down < side && regions[down][c0] == -1 -> + regions[down++][c0] = region + up >= 0 && regions[up][c0] == -1 -> + regions[up--][c0] = region + else -> break + } + sizes[region]++ + unassigned-- + added++ + } + } + } + + // What is left goes to whichever neighbouring region is smallest, so no + // region dies young while another sprawls. + var guard = side * side * 20 + while (unassigned > 0 && guard > 0) { + guard-- + for (r in 0 until side) { + for (c in 0 until side) { + if (regions[r][c] != -1) continue + var best = -1 + for ((dr, dc) in dirs) { + val nr = r + dr + val nc = c + dc + if (nr !in 0 until side || nc !in 0 until side) continue + val neighbour = regions[nr][nc] + if (neighbour == -1 || neighbour in stripRegions) continue + if (best == -1 || sizes[neighbour] < sizes[best]) best = neighbour + } + if (best == -1) { + for ((dr, dc) in dirs) { + val nr = r + dr + val nc = c + dc + if (nr !in 0 until side || nc !in 0 until side) continue + val neighbour = regions[nr][nc] + if (neighbour != -1 && (best == -1 || sizes[neighbour] < sizes[best])) { + best = neighbour + } + } + } + if (best != -1) { + regions[r][c] = best + sizes[best]++ + unassigned-- + } + } + } + } + for (r in 0 until side) { + for (c in 0 until side) { + if (regions[r][c] == -1) regions[r][c] = 0 + } + } + return regions + } + + /** + * How many answers this coloured board has, up to [limit]. + * + * A depth-first search with the obvious propagation: once a row is given a + * cat, the squares that cat excludes — its column, the rows beside it, and + * everything in its colour — lose their candidates. A row whose candidate + * list runs out fails immediately and the search backtracks, which is what + * makes the count finish quickly on boards of this size. + * + * Nothing about the true cats is handed to the search; it sees the colours + * and the rules and no more. + */ + fun countSolutions(side: Int, regions: Array, limit: Int = 2): Int { + val candidates = Array(side) { r -> (0 until side).toMutableSet() } + val colsTaken = BooleanArray(side) + val coloursTaken = BooleanArray(side) + val count = IntArray(1) + solve(0, side, regions, candidates, colsTaken, coloursTaken, count, limit) + return count[0] + } + + private fun solve( + row: Int, + side: Int, + regions: Array, + candidates: Array>, + colsTaken: BooleanArray, + coloursTaken: BooleanArray, + count: IntArray, + limit: Int, + ) { + if (count[0] >= limit) return + if (row == side) { + count[0]++ + return + } + for (col in candidates[row].toList()) { + val colour = regions[row][col] + if (colsTaken[col] || coloursTaken[colour]) continue + val undo = exclude(row, col, side, regions, candidates) + if (undo != null) { + colsTaken[col] = true + coloursTaken[colour] = true + solve(row + 1, side, regions, candidates, colsTaken, coloursTaken, count, limit) + colsTaken[col] = false + coloursTaken[colour] = false + undo() + } + if (count[0] >= limit) return + } + } + + /** + * Crosses out what the cat at [row],[col] forbids, and returns the function + * that puts it back. Returns null if some row loses its last candidate, + * which means this placement is a dead end. + */ + private fun exclude( + row: Int, + col: Int, + side: Int, + regions: Array, + candidates: Array>, + ): (() -> Unit)? { + val colour = regions[row][col] + val saved = Array(side) { r -> candidates[r].toMutableSet() } + var dead = false + for (r in 0 until side) { + if (r == row) continue + candidates[r].remove(col) + if (abs(r - row) == 1) { + candidates[r].remove(col - 1) + candidates[r].remove(col + 1) + } + candidates[r].removeAll { c -> regions[r][c] == colour } + if (candidates[r].isEmpty()) dead = true + } + if (dead) { + for (r in 0 until side) candidates[r] = saved[r] + return null + } + return { + for (r in 0 until side) candidates[r] = saved[r] + } + } + + /** + * A complete puzzle: the colours, the answer, and a proof it is unique. + * + * Generation is rejection rather than construction — build a board, check it, + * throw it away if the check fails — and the attempts walk forward from + * [seed] so a seed that has a puzzle within a few tries gives the same one + * every time. + */ + fun generate(side: Int, seed: Long): MeowdokuPuzzle? { + val base = (seed xor (seed ushr 32)).toInt() + for (attempt in 0 until MAX_ATTEMPTS) { + val rng = SeededRng(base + attempt * 7919) + val catCols = placeCats(side, rng) + val regions = growRegions(side, catCols, SeededRng(base * 31 + attempt * 104729), strips = side / 2) + if (distinctColours(side, regions) != side) continue + if (countSolutions(side, regions, limit = 2) != 1) continue + return MeowdokuPuzzle(side, regions, catCols) + } + return null + } + + private const val MAX_ATTEMPTS = 4000 + + /** How many different colours a board actually shows. */ + private fun distinctColours(side: Int, regions: Array): Int = + regions.flatMap { it.toList() }.distinct().size +} + +/** + * A board and its answer. + * + * The answer is stored rather than re-derived because the rules need it to + * referee: a tap either lands a cat or loses a heart, and that is judged against + * the solution, not against whether the player could have deduced it. + */ +data class MeowdokuPuzzle( + val side: Int, + /** Region id per square; ids run 0 until side. */ + val regions: Array, + /** The column of the cat in each row. */ + val catCols: IntArray, +) { + fun regionOf(row: Int, col: Int): Int = regions[row][col] + + /** Every colour id, in board order, for a legend or a palette. */ + val colours: List get() = (0 until side).toList() + + fun solutionSquares(): List> = + catCols.indices.map { it to catCols[it] } + + override fun equals(other: Any?): Boolean = other is MeowdokuPuzzle && + side == other.side && + regions.contentDeepEquals(other.regions) && + catCols.contentEquals(other.catCols) + + override fun hashCode(): Int = 31 * side + regions.contentDeepHashCode() +} + +/** + * A 32-bit linear congruential generator. + * + * Deliberately not [kotlin.random.Random]: that one is seeded per-JVM and can + * change between releases, and a puzzle generator that drifts silently corrupts + * saved games. This one is small, explicit, and the same everywhere. + */ +class SeededRng(seed: Int) { + private var state = if (seed == 0) DEFAULT_SEED else seed + + /** The next 31 bits, as a non-negative int. */ + fun nextInt(): Int { + state = state * MULTIPLIER + 1 + return (state ushr 1) and 0x7FFF_FFFF + } + + /** A value in 0 until [bound], taken by rejection so it stays uniform. */ + fun nextInt(bound: Int): Int { + require(bound > 0) + while (true) { + val n = nextInt() + // 2^31 is a multiple of no useful number of squares, so a bare + // modulus biases the low values; the slack is thrown and redrawn. + if (n < Int.MAX_VALUE - (Int.MAX_VALUE % bound)) return n % bound + } + } + + fun nextBoolean(): Boolean = nextInt(2) == 0 + + /** The same list every time for the same seed, in a different order. */ + fun shuffled(items: List): List { + val out = items.toMutableList() + for (i in out.lastIndex downTo 1) { + val j = nextInt(i + 1) + val tmp = out[i] + out[i] = out[j] + out[j] = tmp + } + return out + } + + private companion object { + // The constants of the old VAX C library's `rand`, which are well tested + // and give a period of the full 31 bits. + const val MULTIPLIER = 1103515245 + const val DEFAULT_SEED = 0x5DEECE66D.toInt() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt new file mode 100644 index 0000000..cae5e88 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt @@ -0,0 +1,117 @@ +package org.prolibertate.games.game.meowdoku + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// Meowdoku hides N cats in an N by N board: exactly one cat in every row, every +// column and every colour, and no two cats sharing even a corner. The player +// taps the squares they believe hold a cat; the board only ever accepts a +// correct tap, so a cat that lands is a cat that was always going to be there. +// A wrong tap costs a heart and crosses the square over, which is what makes +// pencil marks a choice rather than a necessity. + +/** One square of the board. */ +@Serializable +data class Square(val row: Int, val col: Int) + +@Serializable +data class MeowdokuOptions( + /** The board is [side] by [side], and the answer is [side] cats. */ + val side: Int = 6, + /** + * How many wrong taps the board forgives. The original game deals three, + * and the hearts on a level are part of its difficulty rather than a + * courtesy. + */ + val hearts: Int = 3, + /** How many cats the board will show on demand. */ + val hints: Int = 3, +) { + init { + require(side in MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE) { + "No board of $side squares a side" + } + require(hearts in 1..9) { "A board with no hearts is lost before it is dealt" } + require(hints in 0..9) { "Hints must read on the board, not scroll" } + } +} + +/** + * The dealt board plus the marks on it. + * + * [cats] and [marks] are sets rather than a state per square because a square's + * story is exactly those two memberships: nothing is a cat and crossed, and the + * empty squares carry no information worth storing. + */ +@Serializable +data class MeowdokuState( + val options: MeowdokuOptions, + /** The region id of every square, row by row. Ids run 0 until [side]. */ + val regions: List>, + /** The column of the cat in each row — the answer, kept so taps can be judged. */ + val catCols: List, + /** Cats the player has found (or a hint has shown). */ + val cats: Set, + /** Squares crossed over: wrong taps and pencil marks alike. */ + val marks: Set, + val heartsLeft: Int, + val hintsLeft: Int, + /** Wrong taps so far, which is how a solved board says how it was solved. */ + val wrongCats: Int, + /** The last square touched, and whether touching it cost a heart. */ + val lastSquare: Square? = null, + val lastWrong: Boolean = false, + val log: List, +) { + val side: Int get() = options.side + + /** Cats found, out of the [side] the board hides. */ + val catsFound: Int get() = cats.size + + val solved: Boolean get() = catsFound == side + + /** Out of hearts, with cats still missing. */ + val lost: Boolean get() = heartsLeft == 0 && !solved + + fun regionOf(square: Square): Int = regions[square.row][square.col] + + fun isCatSquare(square: Square): Boolean = catCols[square.row] == square.col +} + +/** What the player may do to a board. */ +@Serializable +sealed interface MeowdokuMove + +/** + * Taps a square expecting a cat. + * + * One move rather than two, because that is what the player is doing: the board + * decides whether the tap found a cat or cost a heart. A square that is already + * crossed over has to be un-crossed first — the tap that made the cross is + * exactly the tap this would repeat. + */ +@Serializable +@SerialName("cat") +data class PlaceCat(val square: Square) : MeowdokuMove + +/** + * Draws or erases a pencil cross on a square with no cat on it. + * + * Free, and it cannot be undone by the board: crosses are the player's notes, + * not the puzzle's. A cross on a square that actually holds a cat costs nothing + * to erase, which is why placing a cat never consults the marks. + */ +@Serializable +@SerialName("pencil") +data class TogglePencil(val square: Square) : MeowdokuMove + +/** + * Spends a hint to show the cat in the first row that lacks one. + * + * The original game sells hints; here they are simply counted, and the one + * choice that has to be made about them — a hint is placed by the board, not + * guessed at by the player — is the same either way. + */ +@Serializable +@SerialName("hint") +data object TakeHint : MeowdokuMove diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt new file mode 100644 index 0000000..be53e8e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt @@ -0,0 +1,192 @@ +package org.prolibertate.games.game.meowdoku + +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 + +/** + * Meowdoku as a pure state machine. See RULES-meowdoku.md. + * + * The engine is a second single-seat game, alongside peg solitaire, and it + * inherits the same freedom: there is nobody to hide anything from, so + * [viewFor] hands out the whole board including the answer. The answer lives in + * the state rather than being re-derived because judging a tap against a + * deduction the player could have made is a different thing from judging it + * against what the board was dealt — and the deduction engine (crossing out + * rows, columns and colours) belongs to the player, not the referee. + */ +object MeowdokuRules : GameRules { + + override val gameId: String = GameCatalog.MEOWDOKU + + /** The one seat. */ + const val SOLO_SEAT: Int = 0 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): MeowdokuState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == 1) { + "Meowdoku seats one, not ${config.seats.size}" + } + val puzzle = deal(options.side, config.seed) + return MeowdokuState( + options = options, + regions = puzzle.regions.map { it.toList() }, + catCols = puzzle.catCols.toList(), + cats = emptySet(), + marks = emptySet(), + heartsLeft = options.hearts, + hintsLeft = options.hints, + wrongCats = 0, + log = listOf("A ${options.side} by ${options.side} board, ${options.side} cats to find."), + ) + } + + /** + * Deals until the board is good. + * + * A seed whose first attempts all fail to produce a unique board moves the + * seed along rather than giving up: the same table asked twice gets the + * same board both times, which is what a saved game and a unit test need. + */ + private fun deal(side: Int, seed: Long): MeowdokuPuzzle { + var trySeed = seed + repeat(DEAL_TRIES) { + MeowdokuGenerator.generate(side, trySeed)?.let { return it } + trySeed = trySeed * 6364136223846793005L + 1442695040888963407L + } + error("No unique ${side} x $side board within $DEAL_TRIES deals of seed $seed") + } + + private const val DEAL_TRIES = 8 + + /** + * Every tap the board would take. + * + * Cats go on squares that are neither cat nor cross; pencils work any + * square without a cat on it, adding or erasing a cross; a hint is offered + * while there are hints left and cats still missing. The list is written + * row by row, which is also the order the hint engine uses. + */ + override fun legalMoves(state: MeowdokuState, seat: Int): List { + if (seat != SOLO_SEAT || isFinished(state)) return emptyList() + val cats = ArrayList() + val pencils = ArrayList() + for (row in 0 until state.side) { + for (col in 0 until state.side) { + val square = Square(row, col) + if (square in state.cats) continue + if (square !in state.marks) cats += PlaceCat(square) + pencils += TogglePencil(square) + } + } + val hint = if (state.hintsLeft > 0) listOf(TakeHint) else emptyList() + return cats + pencils + hint + } + + override fun applyMove( + state: MeowdokuState, + seat: Int, + move: MeowdokuMove, + ): MeowdokuState { + require(seat == SOLO_SEAT) { "Meowdoku has one seat" } + require(move in legalMoves(state, seat)) { "$move is not a legal move" } + return when (move) { + is PlaceCat -> tap(state, move.square) + is TogglePencil -> pencil(state, move.square) + TakeHint -> hint(state) + } + } + + /** + * The tap that is the whole game: found a cat, or lost a heart. + * + * The square was already crossed? Then the tap is not offered at all — see + * [legalMoves] — so a player who pencilled over the truth has to notice and + * erase before tapping. That is the one way a board can be lost that the + * board itself did not do. + */ + private fun tap(state: MeowdokuState, square: Square): MeowdokuState { + if (state.isCatSquare(square)) { + val next = state.copy( + cats = state.cats + square, + lastSquare = square, + lastWrong = false, + ) + val note = if (next.solved) { + "All ${next.side} cats, ${next.wrongCats} wrong taps." + } else { + "Found a cat at row ${square.row + 1}, column ${square.col + 1}." + } + return next.copy(log = next.log + note) + } + val hearts = state.heartsLeft - 1 + val next = state.copy( + marks = state.marks + square, + heartsLeft = hearts, + wrongCats = state.wrongCats + 1, + lastSquare = square, + lastWrong = true, + ) + return if (hearts == 0) { + next.copy(log = next.log + "Out of hearts, with ${next.side - next.catsFound} cats still hiding.") + } else { + next + } + } + + private fun pencil(state: MeowdokuState, square: Square): MeowdokuState { + val marks = if (square in state.marks) state.marks - square else state.marks + square + return state.copy(marks = marks, lastSquare = square, lastWrong = false) + } + + /** + * Shows the cat in the first row without one. + * + * A revealed cat lands like a found one — and erases a pencil cross on its + * square, because the point of a hint is that the player stops having to + * work out whether the board contradicts their own notes. + */ + private fun hint(state: MeowdokuState): MeowdokuState { + val row = (0 until state.side).first { r -> state.cats.none { it.row == r } } + val square = Square(row, state.catCols[row]) + return state.copy( + cats = state.cats + square, + marks = state.marks - square, + hintsLeft = state.hintsLeft - 1, + lastSquare = square, + lastWrong = false, + log = state.log + "A hint shows the cat in row ${row + 1}.", + ) + } + + /** A board is finished when it is solved or when the hearts run out. */ + override fun isFinished(state: MeowdokuState): Boolean = state.solved || state.lost + + override fun currentSeat(state: MeowdokuState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + override fun summary(state: MeowdokuState): String = when { + state.solved -> "Solved with ${state.heartsLeft} hearts to spare" + state.lost -> "Lost with ${state.catsFound} of ${state.side} cats found" + else -> "${state.catsFound} of ${state.side} cats, ${state.heartsLeft} hearts left" + } + + /** + * Nothing to strip. One person plays, and the answer in the state is there + * for the referee, not as a secret. Should a second seat ever race through + * the same board, this is where [MeowdokuState.catCols] gets hidden. + */ + override fun viewFor(state: MeowdokuState, seat: Int): MeowdokuState = state + + override fun encodeState(state: MeowdokuState): String = json.encodeToString(state) + override fun decodeState(json: String): MeowdokuState = this.json.decodeFromString(json) + override fun encodeMove(move: MeowdokuMove): String = json.encodeToString(move) + override fun decodeMove(json: String): MeowdokuMove = this.json.decodeFromString(json) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 0303f70..68bf362 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -50,6 +50,8 @@ import org.prolibertate.games.game.freecell.FreeCellOptions import org.prolibertate.games.game.pyramid.PyramidOptions import org.prolibertate.games.game.spider.SpiderOptions import org.prolibertate.games.game.yahtzee.YahtzeeOptions +import org.prolibertate.games.game.meowdoku.MeowdokuGenerator +import org.prolibertate.games.game.meowdoku.MeowdokuOptions import org.prolibertate.games.game.pegsolitaire.PegBoard import org.prolibertate.games.game.pegsolitaire.PegGoal import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions @@ -109,6 +111,7 @@ fun GameSetupScreen( var pirates by remember { mutableStateOf(PiratesOptions()) } var hearts by remember { mutableStateOf(HeartsOptions()) } var pegs by remember { mutableStateOf(PegSolitaireOptions()) } + var meowdoku by remember { mutableStateOf(MeowdokuOptions()) } var yahtzee by remember { mutableStateOf(YahtzeeOptions()) } var klondike by remember { mutableStateOf(KlondikeOptions()) } var freecell by remember { mutableStateOf(FreeCellOptions()) } @@ -140,6 +143,7 @@ fun GameSetupScreen( GameCatalog.PIRATES -> setupJson.encodeToString(pirates) GameCatalog.HEARTS -> setupJson.encodeToString(hearts) GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs) + GameCatalog.MEOWDOKU -> setupJson.encodeToString(meowdoku) GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee) GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike) GameCatalog.FREECELL -> setupJson.encodeToString(freecell) @@ -205,6 +209,8 @@ fun GameSetupScreen( GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it } + GameCatalog.MEOWDOKU -> MeowdokuOptionsEditor(meowdoku) { meowdoku = it } + GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it } GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it } @@ -1134,6 +1140,36 @@ private fun PegSolitaireOptionsEditor( } } +@Composable +private fun MeowdokuOptionsEditor( + options: MeowdokuOptions, + onChange: (MeowdokuOptions) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Board", + values = (MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE).toList(), + selected = options.side, + display = { "$it × $it" }, + onSelect = { onChange(options.copy(side = it)) }, + ) + ChipRow( + label = "Hearts", + values = (1..5).toList(), + selected = options.hearts, + display = { if (it == 1) "One" else "$it" }, + onSelect = { onChange(options.copy(hearts = it)) }, + ) + ChipRow( + label = "Hints", + values = (0..3).toList(), + selected = options.hints, + display = { if (it == 0) "None" else "$it" }, + onSelect = { onChange(options.copy(hints = it)) }, + ) + } +} + @Composable private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 7b806d9..50435f0 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -87,6 +88,10 @@ import org.prolibertate.games.game.mastermind.MastermindAi import org.prolibertate.games.game.mastermind.MastermindMove import org.prolibertate.games.game.mastermind.MastermindRules import org.prolibertate.games.game.mastermind.MastermindState +import org.prolibertate.games.game.meowdoku.MeowdokuAi +import org.prolibertate.games.game.meowdoku.MeowdokuMove +import org.prolibertate.games.game.meowdoku.MeowdokuRules +import org.prolibertate.games.game.meowdoku.MeowdokuState import org.prolibertate.games.game.morris.MorrisAi import org.prolibertate.games.game.morris.MorrisMove import org.prolibertate.games.game.morris.MorrisRules @@ -131,6 +136,7 @@ import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.GolfScreen import org.prolibertate.games.ui.game.KaiserScreen import org.prolibertate.games.ui.game.MastermindScreen +import org.prolibertate.games.ui.game.MeowdokuScreen import org.prolibertate.games.ui.game.MorrisScreen import org.prolibertate.games.ui.game.PegSolitaireScreen import org.prolibertate.games.ui.game.SpiderScreen @@ -544,6 +550,34 @@ fun PlayScreen( ) } + GameCatalog.MEOWDOKU -> { + // Like peg solitaire the controller is rebuilt rather than + // reset — a finished board has nothing left to do — but unlike + // it a new board is dealt every time: the same cats in the same + // colours twice would only repeat the last puzzle's answer. + // Moving the seed moves the deal, and each deal is still + // reproducible from its own seed. + var attempt by remember(route) { mutableLongStateOf(0L) } + val controller = remember(route, attempt) { + MatchController( + rules = MeowdokuRules, + // Never consulted while a person holds the only seat. + ai = MeowdokuAi, + config = route.config.copy(seed = route.config.seed + attempt), + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + MeowdokuScreen( + controller = controller, + onNewPuzzle = { attempt++ }, + onExit = onExit, + ) + } + GameCatalog.YAHTZEE -> { val controller = remember(route) { MatchController( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt new file mode 100644 index 0000000..87b5bdf --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt @@ -0,0 +1,273 @@ +package org.prolibertate.games.ui.game + +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.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.TextUnit +import org.prolibertate.games.game.meowdoku.MeowdokuMove +import org.prolibertate.games.game.meowdoku.MeowdokuState +import org.prolibertate.games.game.meowdoku.PlaceCat +import org.prolibertate.games.game.meowdoku.Square +import org.prolibertate.games.game.meowdoku.TakeHint +import org.prolibertate.games.game.meowdoku.TogglePencil +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.LocalEndGameHandle +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.theme.CardRed + +private const val TITLE = "Meowdoku" + +/** + * The region colours, one per cat the board can hold. + * + * The board never needs two regions of one colour — a side-ten board has ten + * regions and the palette has ten — so a region's colour is just its id, and + * nothing has to be computed to keep neighbours apart. The palette reads as + * watercolour on purpose: cats are dark ink on top of it, and a square the + * player has crossed over is too. + */ +private val RegionColours = listOf( + Color(0xFFF2C6C2), // rose + Color(0xFFF7E0A6), // butter + Color(0xFFCDE8C5), // mint + Color(0xFFC5E3F0), // sky + Color(0xFFD8CDEB), // lilac + Color(0xFFEBC9A0), // apricot + Color(0xFFBFE3D0), // spruce + Color(0xFFF0D7E3), // blossom + Color(0xFFCFCFE8), // periwinkle + Color(0xFFE3E7C1), // lime +) + +/** What a tap does. The original game has a pencil tool too, and this is it. */ +private enum class TapMode { CATS, PENCIL } + +/** + * The puzzle board. + * + * Cells are laid out by weight inside a square frame, which keeps the grid + * exactly as wide as it is tall on any screen, and scales the glyphs with the + * cell rather than the font setting: a cat is half a square no matter how big + * the square is. + */ +@Composable +fun MeowdokuScreen( + controller: MatchController, + onNewPuzzle: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var mode by remember { mutableStateOf(TapMode.CATS) } + 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 { LeftBoardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false } + } + return + } + + val places = legal.filterIsInstance().map { it.square }.toSet() + val pencils = legal.filterIsInstance().map { it.square }.toSet() + val finished by controller.finished.collectAsState() + + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = when { + current.solved -> "Found every cat" + + if (current.wrongCats == 0) ", not one tap wasted." else ", with ${current.wrongCats} wrong taps." + current.lost -> "Out of hearts, with ${current.side - current.catsFound} cats still hiding." + mode == TapMode.CATS -> "${current.catsFound} of ${current.side} cats found. Tap where a cat lives." + else -> "Tap to cross a square over, or to erase a cross." + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Hearts " + "♥".repeat(current.heartsLeft) + + " · Hints " + current.hintsLeft, + style = MaterialTheme.typography.bodySmall, + ) + + Board( + state = current, + places = places, + pencils = pencils, + onTap = { square -> + when { + mode == TapMode.CATS && square in places -> + controller.submit(PlaceCat(square)) + + mode == TapMode.PENCIL && square in pencils -> + controller.submit(TogglePencil(square)) + } + }, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = mode == TapMode.CATS, + onClick = { mode = TapMode.CATS }, + label = { Text("Cats") }, + ) + FilterChip( + selected = mode == TapMode.PENCIL, + onClick = { mode = TapMode.PENCIL }, + label = { Text("Pencil") }, + ) + if (TakeHint in legal) { + FilterChip( + selected = false, + onClick = { controller.submit(TakeHint) }, + label = { Text("Hint") }, + ) + } + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onNewPuzzle) { Text("New puzzle") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false } + } +} + +@Composable +private fun Board( + state: MeowdokuState, + places: Set, + pencils: Set, + onTap: (Square) -> Unit, +) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + // One cell's edge, so a cat is always about half a square whatever + // board was dealt. + val cell = maxWidth / state.side + Column(modifier = Modifier.aspectRatio(1f)) { + for (row in 0 until state.side) { + Row(modifier = Modifier.weight(1f)) { + for (col in 0 until state.side) { + val square = Square(row, col) + CellView( + square = square, + state = state, + tappable = square in places || square in pencils, + glyphSize = (cell.value * 0.55f).sp, + modifier = Modifier.weight(1f), + onTap = { onTap(square) }, + ) + } + } + } + } + } +} + +@Composable +private fun CellView( + square: Square, + state: MeowdokuState, + tappable: Boolean, + glyphSize: TextUnit, + modifier: Modifier, + onTap: () -> Unit, +) { + val isCat = square in state.cats + val isMark = square in state.marks + val justWrong = state.lastWrong && state.lastSquare == square + Box( + modifier = modifier + .aspectRatio(1f) + .border(1.dp, Color(0x33000000)) + .background(RegionColours[state.regionOf(square) % RegionColours.size]) + .then( + if (justWrong) Modifier.border(3.dp, CardRed) else Modifier + ) + .clickable(enabled = tappable) { onTap() }, + contentAlignment = Alignment.Center, + ) { + when { + isCat -> Text("🐱", fontSize = glyphSize) + isMark -> Text( + "✕", + color = Color(0xAA1C1B1F), + fontWeight = FontWeight.Bold, + fontSize = glyphSize, + ) + } + } +} + +@Composable +private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text("The board is left as it stands.") }, + confirmButton = { + val handle = LocalEndGameHandle.current + TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") } + }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftBoardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text(notice) }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 4e30831..c3043ec 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -107,24 +107,36 @@ class CatalogTest { @Test fun `every reference game in the catalogue can be played`() { - // Every game the app ships is playable. The one exception is Katan, - // listed so the slot exists and marked *Coming soon* until its engine - // and screen land — `available = false` is what keeps an unfinished - // game out of a production build while leaving it visible on a dev one. + // Every game the app ships is playable. The exceptions are the games + // whose engine and screen exist but have not been verified on a real + // device yet: they are marked as previews so a production build keeps + // them out while a dev one can open them. `available = false` is what + // keeps an unverified game out of a production build while leaving it + // visible on a dev one. assertEquals( - listOf(GameCatalog.CATAN), + listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU), GameCatalog.all.filterNot { it.available }.map { it.id }, ) // It is also the only difference the two channels see in a menu. - val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) - + // Katan sits under Board and Meowdoku under Solitaire, so the two + // channels part ways in different sections. + val hiddenFromBoard = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) - GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false) - assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction) + assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromBoard) + val hiddenFromSolitaire = GameCatalog.inMenu(GameMenu.SOLITAIRE, includeComingSoon = true) - + GameCatalog.inMenu(GameMenu.SOLITAIRE, includeComingSoon = false) + assertEquals(listOf(GameCatalog.byId(GameCatalog.MEOWDOKU)!!), hiddenFromSolitaire) } @Test fun `a preview game opens on a dev build and nowhere else`() { + // Katan until its two-device play is verified; Meowdoku until its board + // and hearts have been looked at on a real screen. val preview = GameCatalog.all.filter { it.devPreview } - assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN)) + assertEquals( + setOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU), + preview.map { it.id }.toSet(), + ) preview.forEach { game -> assertFalse("${game.id}: preview is for games that are not yet available", game.available) assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true)) diff --git a/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt new file mode 100644 index 0000000..8f34d28 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt @@ -0,0 +1,323 @@ +package org.prolibertate.games.game.meowdoku + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +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 + +/** + * The puzzle, the generator, and the referee. + * + * The generator is where a wrong board would hurt most — a second answer makes + * the deduction unfair and the hearts a lottery — so the generation tests run + * over seeds rather than one lucky one. + */ +class MeowdokuRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun soloConfig(options: MeowdokuOptions, seed: Long = 7L) = TableConfig( + gameId = "meowdoku", + seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)), + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun dealt(side: Int, seed: Long = 7L) = + MeowdokuRules.initialState(soloConfig(MeowdokuOptions(side = side), seed)) + + /** + * A puzzle that must exist, named so the assertions below read as facts + * rather than as unwrapping. + */ + private fun required(value: T?, message: String): T { + assertNotNull(message, value) + @Suppress("UNCHECKED_CAST") + return value as T + } + + // ---- generator --------------------------------------------------------- + + @Test + fun `every dealt board has exactly one answer`() { + for (side in 4..8) { + for (seed in 1L..6L) { + val puzzle = required( + MeowdokuGenerator.generate(side, seed), + "no puzzle for side $side seed $seed", + ) + assertEquals( + "the $side board dealt from seed $seed has more than one answer", + 1, + MeowdokuGenerator.countSolutions(side, puzzle.regions), + ) + } + } + } + + @Test + fun `the same seed deals the same board`() { + val a = MeowdokuGenerator.generate(6, 12345L) + val b = MeowdokuGenerator.generate(6, 12345L) + assertEquals(a, b) + } + + @Test + fun `the answer satisfies the cats' own rules`() { + for (seed in 0L..20L) { + val puzzle = required(MeowdokuGenerator.generate(7, seed), "no puzzle for seed $seed") + val cats = puzzle.solutionSquares() + assertEquals(7, cats.size) + assertEquals(7, cats.map { it.first }.distinct().size) + assertEquals(7, cats.map { it.second }.distinct().size) + for (i in cats.indices) { + for (j in i + 1 until cats.size) { + val (r1, c1) = cats[i] + val (r2, c2) = cats[j] + val touches = kotlin.math.abs(r1 - r2) <= 1 && kotlin.math.abs(c1 - c2) <= 1 + assertFalse("cats $cats touch", touches) + assertFalse("two cats share a colour", puzzle.regionOf(r1, c1) == puzzle.regionOf(r2, c2)) + } + } + } + } + + @Test + fun `every colour owns exactly one cat and is contiguous`() { + val puzzle = required(MeowdokuGenerator.generate(6, 99L), "no puzzle for seed 99") + val cells = (0 until 6).flatMap { r -> (0 until 6).map { c -> r to c } } + + val owned = (0 until 6).map { region -> + cells.filter { (r, c) -> puzzle.regionOf(r, c) == region } + } + owned.forEach { cellsOfRegion -> + assertTrue("an empty colour", cellsOfRegion.isNotEmpty()) + val cats = cellsOfRegion.count { (r, c) -> puzzle.catCols[r] == c } + assertEquals("a colour with ${cats}cats", 1, cats) + + // Contiguous: a breadth-first walk from the first cell reaches all. + val rest = cellsOfRegion.toMutableSet() + val queue = ArrayDeque(listOf(rest.first())) + val reached = mutableSetOf(queue.first()) + while (queue.isNotEmpty()) { + val (r, c) = queue.removeFirst() + for ((dr, dc) in listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0)) { + val next = (r + dr) to (c + dc) + if (next in rest && reached.add(next)) queue.add(next) + } + } + assertEquals("a colour in two pieces", rest.size, reached.size) + } + } + + @Test + fun `the cats can always be settled`() { + // A four board is the smallest deal, and its answer is the point where + // no-touch and one-per-line first stop contradicting each other. + val rng = SeededRng(42) + val cols = MeowdokuGenerator.placeCats(4, rng) + assertEquals(4, cols.size) + // The placement the generator returns is internally consistent. + for (row in 0 until 4) { + for (earlier in 0 until row) { + assertFalse(MeowdokuGenerator.catFitsHere(row, cols[earlier], cols)) + } + } + } + + // ---- the referee ------------------------------------------------------- + + @Test + fun `the board opens empty and seated for one`() { + val state = dealt(6) + assertTrue(state.cats.isEmpty()) + assertTrue(state.marks.isEmpty()) + assertEquals(6, state.side) + assertEquals(3, state.heartsLeft) + assertEquals(0, state.catsFound) + assertEquals(0, MeowdokuRules.currentSeat(state)) + assertFalse(MeowdokuRules.isFinished(state)) + assertTrue(MeowdokuRules.legalMoves(state, 1).isEmpty()) + } + + @Test + fun `a correct tap lands a cat and a wrong tap costs a heart`() { + val state = dealt(6) + val square = Square(0, state.catCols[0]) + val found = MeowdokuRules.applyMove(state, 0, PlaceCat(square)) + assertTrue(square in found.cats) + assertEquals(3, found.heartsLeft) + + val wrongRow = (0 until state.side).first { it != square.row } + val wrong = Square(wrongRow, if (state.catCols[wrongRow] == 0) 1 else 0) + val missed = MeowdokuRules.applyMove(found, 0, PlaceCat(wrong)) + assertTrue(wrong in missed.marks) + assertFalse(wrong in missed.cats) + assertEquals(2, missed.heartsLeft) + assertEquals(1, missed.wrongCats) + } + + @Test + fun `a board is solved by finding every cat`() { + var state = dealt(5) + for (row in 0 until 5) { + state = MeowdokuRules.applyMove(state, 0, PlaceCat(Square(row, state.catCols[row]))) + } + assertTrue(state.solved) + assertTrue(MeowdokuRules.isFinished(state)) + assertNull(MeowdokuRules.currentSeat(state)) + assertTrue(MeowdokuRules.legalMoves(state, 0).isEmpty()) + assertTrue(MeowdokuRules.summary(state).startsWith("Solved")) + } + + @Test + fun `running out of hearts loses the board`() { + val state = dealt(8, seed = 3L) + var lost = state + var row = 0 + while (lost.heartsLeft > 0) { + // A wrong square: not the answer's column, in a row with no cat. + val wrongCol = if (state.catCols[row] == 0) 1 else 0 + val before = lost.catsFound + lost = MeowdokuRules.applyMove(lost, 0, PlaceCat(Square(row, wrongCol))) + assertEquals(before, lost.catsFound) + row = (row + 1) % state.side + } + assertTrue(lost.lost) + assertTrue(MeowdokuRules.isFinished(lost)) + assertEquals(0, lost.catsFound) + assertTrue(MeowdokuRules.summary(lost).startsWith("Lost")) + } + + @Test + fun `a tapped square that is crossed over is not offered`() { + val state = dealt(6) + val wrongRow = 1 + val wrongCol = if (state.catCols[wrongRow] == 0) 1 else 0 + val marked = MeowdokuRules.applyMove(state, 0, PlaceCat(Square(wrongRow, wrongCol))) + assertThrows(IllegalArgumentException::class.java) { + MeowdokuRules.applyMove(marked, 0, PlaceCat(Square(wrongRow, wrongCol))) + } + // A pencil erases the cross and opens the tap again. + val erased = MeowdokuRules.applyMove(marked, 0, TogglePencil(Square(wrongRow, wrongCol))) + assertFalse(Square(wrongRow, wrongCol) in erased.marks) + } + + @Test + fun `pencil marks are free notes`() { + val state = dealt(6) + val square = Square(2, if (state.catCols[2] == 5) 4 else 5) + val marked = MeowdokuRules.applyMove(state, 0, TogglePencil(square)) + assertTrue(square in marked.marks) + assertEquals(3, marked.heartsLeft) + assertEquals(0, marked.wrongCats) + + val erased = MeowdokuRules.applyMove(marked, 0, TogglePencil(square)) + assertTrue(erased.marks.isEmpty()) + } + + @Test + fun `a cat square is never a pencil square`() { + val state = dealt(6) + val cat = Square(0, state.catCols[0]) + val found = MeowdokuRules.applyMove(state, 0, PlaceCat(cat)) + assertFalse( + "legal moves offer pencil on a cat", + MeowdokuRules.legalMoves(found, 0).any { + it is TogglePencil && it.square == cat + }, + ) + } + + @Test + fun `a hint shows a missing cat and costs one token`() { + val state = dealt(6) + val hinted = MeowdokuRules.applyMove(state, 0, TakeHint) + assertEquals(1, hinted.catsFound) + assertEquals(2, hinted.hintsLeft) + + // Out of hints the move stops being offered. + var drained = MeowdokuRules.applyMove( + MeowdokuRules.applyMove(hinted, 0, TakeHint), + 0, + TakeHint, + ) + assertEquals(0, drained.hintsLeft) + // A note on a square that is not a cat, so the pencil move is legal. + val note = Square(0, if (state.catCols[0] == 0) 1 else 0) + drained = MeowdokuRules.applyMove(drained, 0, TogglePencil(note)) + assertFalse(TakeHint in MeowdokuRules.legalMoves(drained, 0)) + } + + @Test + fun `a hint over a wrong pencil reveals the cat anyway`() { + val state = dealt(6) + val truth = Square(0, state.catCols[0]) + val pencilled = MeowdokuRules.applyMove(state, 0, TogglePencil(truth)) + val revealed = MeowdokuRules.applyMove(pencilled, 0, TakeHint) + assertTrue(truth in revealed.cats) + assertFalse(truth in revealed.marks) + } + + @Test + fun `the computer can finish any board it is handed`() { + var state = dealt(7) + var guard = 0 + while (!MeowdokuRules.isFinished(state) && guard++ < 100) { + val legal = MeowdokuRules.legalMoves(state, 0) + assertFalse("stuck with cats still missing", legal.isEmpty()) + state = MeowdokuRules.applyMove(state, 0, MeowdokuAi.chooseMove(state, 0, legal)) + } + assertTrue("the AI did not solve the board", state.solved) + } + + // ---- state across the wire and the save slot --------------------------- + + @Test + fun `state and moves survive their json`() { + val state = dealt(6) + val decoded = MeowdokuRules.decodeState(MeowdokuRules.encodeState(state)) + assertEquals(state, decoded) + + val move: MeowdokuMove = PlaceCat(Square(1, 1)) + assertEquals(move, MeowdokuRules.decodeMove(MeowdokuRules.encodeMove(move))) + assertEquals(TakeHint, MeowdokuRules.decodeMove(MeowdokuRules.encodeMove(TakeHint))) + } + + @Test + fun `nothing is hidden from the only seat`() { + val state = dealt(6) + assertEquals(state, MeowdokuRules.viewFor(state, 0)) + } + + @Test + fun `a table with two seats is refused`() { + val config = TableConfig( + gameId = "meowdoku", + seats = listOf( + PlayerSlot(0, "P0", PlayerKind.HUMAN_LOCAL, 0), + PlayerSlot(1, "P1", PlayerKind.AI, 1), + ), + optionsJson = json.encodeToString(MeowdokuOptions()), + seed = 1L, + ) + assertThrows(IllegalArgumentException::class.java) { + MeowdokuRules.initialState(config) + } + } + + @Test + fun `the options guard their own board`() { + assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(side = 3) } + assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(side = 11) } + assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(hearts = 0) } + } +} diff --git a/docs/README.md b/docs/README.md index 934bd34..4e9fca3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`. | Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) | | Klondike | — (TODO) | | Mastermind | [RULES-mastermind.md](RULES-mastermind.md) | +| Meowdoku | [RULES-meowdoku.md](RULES-meowdoku.md) | | Morris | [RULES-morris.md](RULES-morris.md) | | Peg solitaire | — (TODO) | | Pirates | [RULES-pirates.md](RULES-pirates.md) | diff --git a/docs/RULES-meowdoku.md b/docs/RULES-meowdoku.md new file mode 100644 index 0000000..15531e1 --- /dev/null +++ b/docs/RULES-meowdoku.md @@ -0,0 +1,87 @@ +# Meowdoku + +A board of colours, and a cat in every row, column and colour — no two sharing +even a corner. + +## The puzzle + +The board is **N by N** and coloured into **N regions**. Exactly one cat hides +in every row, every column and every region, and no two cats may touch — not +along a side and not at a corner, so a cat rules out all eight squares around +itself. Those four constraints together always leave exactly one arrangement. + +Uniqueness is not decoration. The game is deduction, and a second answer means +a square the player cannot prove either way. Every board is therefore generated +and then searched: build the colours, count the ways a board can be answered, +and if it is more than one, throw the board away and deal another. The count is +a plain depth-first search over the rows with the obvious exclusions propagated, +and it sees only the colours and the rules — never the cats it was grown from. + +## Playing it + +**Tap a square where a cat lives.** A correct tap lands the cat; a wrong one +costs a heart and crosses the square over, because being wrong here is +information and the board keeps it for you. Finding all N cats wins; losing the +hearts ends the board with the rest still hidden. + +The **Pencil** toggle is for the marks you make yourself. They are free, they +cost nothing to erase, and the board does not judge them. The one trap they +carry: a square crossed over is a square you have decided against, so tapping a +cat onto it is refused until the pencil erases the note. The original game +draws crosses for you on a wrong tap for the same reason — a wrong square +crossed twice is the same mistake twice — and here the same thing happens +whether the cross came from the heart or from the pencil. + +A **Hint** spends a token and shows the cat in the first row that still lacks +one. The original sells these as tools; here they are simply counted, three per +board by default, which keeps the hint a decision rather than a reflex. + +## The heart + +Why a wrong tap costs anything at all: the puzzle is only hard if guessing is. +A board with no penalty is a board you can solve by tapping every square, and +the hearts are what make the deduction on a colour you have not reasoned about +yet feel like a choice. Three is the number the original deals, and it stays +three by default. + +## Options + +| | | +| --- | --- | +| **Board** | 4×4 up to 10×10; the number of cats equals the side | +| **Hearts** | 1 to 5, default 3 | +| **Hints** | 0 to 3, default 3 | + +Boards are dealt from the table's seed, so a saved game resumes to the same +cats in the same colours, and **New puzzle** moves the seed to deal a different +board. Smaller sides are gentler: a 4×4 is the shortest way to learn what +no-touch does to a corner, and the ten-wide boards are for people who have +already made that mistake. + +## How a board is dealt + +The order matters, and it is the order the original game uses: + +1. **Settle the cats first.** One per row, one per column, no two touching, + chosen by backtracking over shuffled column orders. +2. **Grow the colours around them.** Each region starts as its cat's square and + expands outwards one square at a time; some regions are grown as straight + bars along the cat's row or column, which is what stops the board reading as + texture, and the rest take whichever neighbour is smallest so nothing + sprawls while another dies young. +3. **Check the answer.** Count solutions up to two; keep the board only when + the count comes back exactly one. + +The generator's rejection loop is why the same seed can take a few attempts: +most boards are unique on the first try, and the odd seed has to walk until it +finds one. + +## Where this engine differs from the original + +The app is a game collection, not a live-service puzzle, so what sits around +the original's gameplay is left behind: lives-as-monetisation (watching an ad +for a heart), daily streaks and leaderboards, an Elo-matched bank of +hand-authored levels, and the cosmetic layer (cat animations, combo feedback, +confetti). This engine **deals its boards from a seed** instead of shipping a +level bank, and the rules are the part worth keeping: the cats, the colours, +the no-touch, the hearts, the one answer.