Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d568ff7750 | ||
|
|
2e66567643 | ||
|
|
a8ff9863a6 | ||
|
|
bd12a7a70b |
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,11 @@ class ScorekeeperSessionTest {
|
||||
await("$name to be asking") { host.state.requests.any { it.id == deviceId } }
|
||||
host.session.allow(deviceId)
|
||||
await("$name to be let in") { guest.state.value.connected }
|
||||
// The sheet follows the connection over the socket, so `connected` can
|
||||
// flip while it is still in flight. Callers read the guest's sheet at
|
||||
// once (its players, its rounds), and on a slow runner that was an empty
|
||||
// one: wait until the guest is looking at the host's.
|
||||
await("$name to receive the sheet") { guest.state.value.sheet == host.state.sheet }
|
||||
return guest
|
||||
}
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user