Merge remote-tracking branch 'origin/dev' into CatanImprovements
CI / Build and check (pull_request) Successful in 8m41s
CI / Deploy to Cloudflare Pages (pull_request) Skipped

This commit is contained in:
Claude
2026-10-24 16:58:58 +00:00
12 changed files with 1513 additions and 8 deletions
@@ -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 }
@@ -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<MeowdokuState, MeowdokuMove> {
override fun chooseMove(
state: MeowdokuState,
seat: Int,
legal: List<MeowdokuMove>,
): MeowdokuMove {
require(legal.isNotEmpty()) { "No move available" }
val places = legal.filterIsInstance<PlaceCat>().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<TogglePencil>()
.firstOrNull { it.square == square }
?.let { return it }
}
}
return legal.first()
}
}
@@ -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<IntArray> {
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<IntArray>, 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<IntArray>,
candidates: Array<MutableSet<Int>>,
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<IntArray>,
candidates: Array<MutableSet<Int>>,
): (() -> 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<IntArray>): 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<IntArray>,
/** 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<Int> get() = (0 until side).toList()
fun solutionSquares(): List<Pair<Int, Int>> =
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 <T> shuffled(items: List<T>): List<T> {
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()
}
}
@@ -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<List<Int>>,
/** The column of the cat in each row — the answer, kept so taps can be judged. */
val catCols: List<Int>,
/** Cats the player has found (or a hint has shown). */
val cats: Set<Square>,
/** Squares crossed over: wrong taps and pencil marks alike. */
val marks: Set<Square>,
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<String>,
) {
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
@@ -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<MeowdokuState, MeowdokuMove> {
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<MeowdokuOptions>(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<MeowdokuMove> {
if (seat != SOLO_SEAT || isFinished(state)) return emptyList()
val cats = ArrayList<MeowdokuMove>()
val pencils = ArrayList<MeowdokuMove>()
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)
}
@@ -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)) {
@@ -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<MeowdokuState, MeowdokuMove>(
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<YahtzeeState, YahtzeeMove>(
@@ -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<MeowdokuState, MeowdokuMove>,
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<PlaceCat>().map { it.square }.toSet()
val pencils = legal.filterIsInstance<TogglePencil>().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<Square>,
pencils: Set<Square>,
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") } },
)
}
@@ -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))
@@ -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 <T> 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) }
}
}
+1
View File
@@ -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) |
+87
View File
@@ -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.