From 7942adeaf7684c807df6ae7ea1d03cf1201f4a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 01:14:45 +0000 Subject: [PATCH] Meowdoku: rules, AI and screen refinements Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ --- .../games/game/meowdoku/MeowdokuAi.kt | 17 +- .../games/game/meowdoku/MeowdokuModel.kt | 28 ++- .../games/game/meowdoku/MeowdokuRules.kt | 25 +- .../games/ui/game/MeowdokuScreen.kt | 226 +++++++++++++----- .../games/game/meowdoku/MeowdokuRulesTest.kt | 40 +++- docs/RULES-meowdoku.md | 37 ++- 6 files changed, 255 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt index 93bd59e..b7c2cd3 100644 --- a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt @@ -25,19 +25,12 @@ object MeowdokuAi : GameAi { require(legal.isNotEmpty()) { "No move available" } val places = legal.filterIsInstance().associateBy { it.square } - // The first cat still missing. A pencil cross in front of it is the - // player's own note being wrong, so erase it; a hint spends one token - // and reveals the square regardless. + // The first cat still missing. A cross on its square is no obstacle: + // crossed squares take attempts like anything else, and a correct one + // replaces the cross. for (row in 0 until state.side) { - val missing = state.cats.none { it.row == row } - if (!missing) continue - val square = Square(row, state.catCols[row]) - places[square]?.let { return it } - if (square in state.marks) { - if (TakeHint in legal && state.hintsLeft > 0) return TakeHint - legal.filterIsInstance() - .firstOrNull { it.square == square } - ?.let { return it } + if (state.cats.none { it.row == row }) { + places[Square(row, state.catCols[row])]?.let { return it } } } diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt index cae5e88..089426e 100644 --- a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt @@ -4,11 +4,11 @@ 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. +// column and every colour, and no two cats sharing even a corner. A tap crosses +// a square over; a second tap on that square asks the board whether a cat +// lives there. A right answer lands the cat, a wrong one costs a heart and +// keeps the cross — so the crossing over is both the player's note and the +// first half of every guess, and the hearts are what stop it being a lottery. /** One square of the board. */ @Serializable @@ -83,23 +83,25 @@ data class MeowdokuState( sealed interface MeowdokuMove /** - * Taps a square expecting a cat. + * The second tap of a double tap: the cat attempt. * * 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. + * decides whether the tap found a cat or cost a heart. The square may carry the + * cross its own first tap just drew — that cross is part of the double tap, + * not a decision against it — and a correct attempt replaces it. */ @Serializable @SerialName("cat") data class PlaceCat(val square: Square) : MeowdokuMove /** - * Draws or erases a pencil cross on a square with no cat on it. + * Draws or erases a 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. + * This is the first tap of a tap pair and every cell of a drag stroke, so it is + * the note-taking half of the scheme: free, unrewarded, and the board cannot + * lose a heart over it. A cross drawn here can still be tapped through with a + * cat attempt — the note is the player's, and only they get to change their + * mind about it. */ @Serializable @SerialName("pencil") diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt index be53e8e..ed2a0a4 100644 --- a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt @@ -69,10 +69,14 @@ object MeowdokuRules : GameRules { /** * 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. + * The scheme is the original's: the first tap crosses a square over and + * opens a short window, and a second tap in that window is the cat attempt + * — so an attempt is offered on every square without a cat, crossed or + * not. The cross the attempt replaces is exactly the cross the first tap + * drew. Cats, once placed, are never tapped again: they are not a guess + * you take back. + * + * The list is written row by row, which is also the order the hint uses. */ override fun legalMoves(state: MeowdokuState, seat: Int): List { if (seat != SOLO_SEAT || isFinished(state)) return emptyList() @@ -82,7 +86,7 @@ object MeowdokuRules : GameRules { 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) + cats += PlaceCat(square) pencils += TogglePencil(square) } } @@ -105,17 +109,18 @@ object MeowdokuRules : GameRules { } /** - * The tap that is the whole game: found a cat, or lost a heart. + * The second tap of a double tap: 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. + * A correct tap clears the cross the first tap left behind — the cat + * replaces it. A wrong one leaves the cross where it is and spends a + * heart, because a wrong square crossed twice is the same mistake twice, + * and the board remembers it either way. */ private fun tap(state: MeowdokuState, square: Square): MeowdokuState { if (state.isCatSquare(square)) { val next = state.copy( cats = state.cats + square, + marks = state.marks - square, lastSquare = square, lastWrong = false, ) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt index 87b5bdf..6fcb32a 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt @@ -2,7 +2,6 @@ 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 @@ -12,6 +11,8 @@ 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.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.FilterChip @@ -23,10 +24,13 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -41,9 +45,20 @@ 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 +import kotlin.math.abs +import kotlin.math.roundToInt private const val TITLE = "Meowdoku" +/** + * How long the second tap of a double tap may take to arrive. + * + * 350 ms, the number the original game ships: long enough that a deliberate + * one-two still reads as one question, short enough that nobody pairs two + * taps they meant for two separate squares. + */ +private const val DOUBLE_TAP_WINDOW_MILLIS = 350L + /** * The region colours, one per cat the board can hold. * @@ -66,16 +81,14 @@ private val RegionColours = listOf( 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. + * The puzzle board, tapped the way the original's is. * - * 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. + * One tap crosses a square over; a second tap on the same square within + * [DOUBLE_TAP_WINDOW_MILLIS] asks the board whether a cat lives there. Drag + * strokes paint or erase crosses along the line the finger takes, which is + * how a row gets crossed out in one gesture rather than eight taps. Cats are + * permanent: tapping one does nothing, and a stroke skips over it. */ @Composable fun MeowdokuScreen( @@ -87,7 +100,6 @@ fun MeowdokuScreen( 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 = { @@ -118,8 +130,8 @@ fun MeowdokuScreen( 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." + else -> "${current.catsFound} of ${current.side} cats found. " + + "Tap to cross a square, tap it again for a cat." }, fontWeight = FontWeight.Bold, ) @@ -131,37 +143,20 @@ fun MeowdokuScreen( 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)) - } - }, + // Moves are refused rather than hidden: an attempt or a note + // the board would reject (a tap on a cat, mostly) has to die + // here, in the gesture, where the player can be doing nothing + // else the same instant. + onNote = { square -> if (square in pencils) controller.submit(TogglePencil(square)) }, + onAttempt = { square -> if (square in places) controller.submit(PlaceCat(square)) }, ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (TakeHint in legal) { FilterChip( - selected = mode == TapMode.CATS, - onClick = { mode = TapMode.CATS }, - label = { Text("Cats") }, + selected = false, + onClick = { controller.submit(TakeHint) }, + label = { Text("Hint") }, ) - 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) { @@ -181,30 +176,110 @@ fun MeowdokuScreen( } } +/** + * The board as one gesture surface. + * + * Cells are drawn by weight inside a square frame, but input is not per-cell: + * a stroke is one continuous gesture, so the pointer works in board + * coordinates and maps positions to squares itself. The cell list renders + * underneath, and the live state is read through [rememberUpdatedState] + * because a gesture outlives the recompositions it lands in. + */ @Composable private fun Board( state: MeowdokuState, - places: Set, - pencils: Set, - onTap: (Square) -> Unit, + onNote: (Square) -> Unit, + onAttempt: (Square) -> Unit, ) { + val side = state.side + val current by rememberUpdatedState(state) + val note by rememberUpdatedState(onNote) + val attempt by rememberUpdatedState(onAttempt) + 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) }, - ) + val cell = maxWidth / side + Box( + modifier = Modifier + .aspectRatio(1f) + .pointerInput(side) { + val cellPx = size.width.toFloat() / side + var lastTap: Square? = null + var lastTapAt = 0L + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val board = current + val start = squareAt(down.position, cellPx, side) + ?: return@awaitEachGesture + if (start in board.cats) return@awaitEachGesture + + val now = System.currentTimeMillis() + if (start == lastTap && now - lastTapAt < DOUBLE_TAP_WINDOW_MILLIS) { + // The second tap of a pair: the cat attempt, and + // no note — the cross it lands on was drawn by + // this pair's first tap, and the board replaces it. + lastTap = null + attempt(start) + do { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + change?.consume() + } while (change != null && change.pressed) + return@awaitEachGesture + } + + // The first tap: a note, and the stroke that may follow + // paints the same note everywhere it goes. + lastTap = start + lastTapAt = now + val erasing = start in board.marks + note(start) + val marks = board.marks.toMutableSet() + marks.flip(start) + var previous = start + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null || !change.pressed) break + change.consume() + val here = squareAt(change.position, cellPx, side) ?: continue + if (here == previous) continue + for (square in path(previous, here)) { + if (square in board.cats) continue + // `marks` is the stroke's own mirror: the base + // board plus every note this stroke has + // already submitted. The state flow lags a + // frame behind submissions, and a stroke that + // crosses a square twice must leave it at the + // stroke's target, not flip it back. + // + // Erasing means "flip everything still + // crossed"; painting means "flip everything + // still blank" — which is the same test read + // two ways: flip while marked == erasing. + if ((square in marks) == erasing) { + note(square) + marks.flip(square) + } + } + previous = here + } + } + } + ) { + Column(modifier = Modifier.aspectRatio(1f)) { + for (row in 0 until side) { + Row(modifier = Modifier.weight(1f)) { + for (col in 0 until side) { + CellView( + square = Square(row, col), + state = current, + glyphSize = (cell.value * 0.55f).sp, + modifier = Modifier.weight(1f), + ) + } } } } @@ -212,14 +287,46 @@ private fun Board( } } +/** Which square a pointer position is over, or null if it has left the board. */ +private fun squareAt(position: Offset, cellPx: Float, side: Int): Square? { + val col = (position.x / cellPx).toInt() + val row = (position.y / cellPx).toInt() + if (row !in 0 until side || col !in 0 until side) return null + return Square(row, col) +} + +/** Add the square, or drop it if it was already there — the stroke's own toggle. */ +private fun MutableSet.flip(square: Square) { + if (!add(square)) remove(square) +} + +/** + * The squares between two consecutive pointer reads, endpoints not repeated. + * + * A fast stroke publishes fewer positions than it crosses cells, so the line + * between two reads is walked in whole-cell steps. [path] returns at most one + * square per cell crossed, in order, which is what lets a stroke cross a row + * in one drag without missing the squares between two events. + */ +private fun path(from: Square, to: Square): List { + val dr = to.row - from.row + val dc = to.col - from.col + val steps = maxOf(abs(dr), abs(dc)) + if (steps <= 1) return listOf(to) + return (1..steps).map { i -> + Square( + from.row + (dr * i.toFloat() / steps).roundToInt(), + from.col + (dc * i.toFloat() / steps).roundToInt(), + ) + } +} + @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 @@ -231,8 +338,7 @@ private fun CellView( .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 { diff --git a/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt index 8f34d28..9521bf1 100644 --- a/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt @@ -198,17 +198,37 @@ class MeowdokuRulesTest { } @Test - fun `a tapped square that is crossed over is not offered`() { + fun `a crossed square still takes a cat attempt`() { + // The double tap is one gesture pair: the first tap crosses the square + // and the second asks it. The cross the pair drew cannot veto the + // question that follows it. 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) + val truth = Square(0, state.catCols[0]) + val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(truth)) + assertTrue(truth in crossed.marks) + assertTrue( + "the attempt is offered over its own cross", + PlaceCat(truth) in MeowdokuRules.legalMoves(crossed, 0), + ) + + val placed = MeowdokuRules.applyMove(crossed, 0, PlaceCat(truth)) + assertTrue(truth in placed.cats) + assertFalse("the cat replaces the cross", truth in placed.marks) + assertEquals(3, placed.heartsLeft) + assertEquals(0, placed.wrongCats) + } + + @Test + fun `a wrong pair keeps its cross and spends a heart`() { + val state = dealt(6) + val wrongCol = if (state.catCols[1] == 0) 1 else 0 + val wrong = Square(1, wrongCol) + val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(wrong)) + val judged = MeowdokuRules.applyMove(crossed, 0, PlaceCat(wrong)) + assertTrue(wrong in judged.marks) + assertFalse(wrong in judged.cats) + assertEquals(2, judged.heartsLeft) + assertEquals(1, judged.wrongCats) } @Test diff --git a/docs/RULES-meowdoku.md b/docs/RULES-meowdoku.md index 15531e1..571d1c2 100644 --- a/docs/RULES-meowdoku.md +++ b/docs/RULES-meowdoku.md @@ -19,22 +19,33 @@ 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. +Controls are the original's, and they carry the game's whole psychology: -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. +- **One tap crosses a square over.** Free, instant, erasable — the note you + make while thinking. +- **A second tap on the same square within 350 ms is the cat attempt.** The + board judges it against the answer it was dealt: correct, the cross is + replaced by a cat; wrong, the cross stays and a heart goes. This is why a + guess feels like a decision: your own note has to survive one more tap + before it becomes a bet. +- **Drag to cross a line.** The stroke paints the same note every square it + enters: start on a blank square and it crosses the row or column you drag + along; start on a crossed square and the stroke erases them instead. Cats + are skipped — placed cats are permanent. + +A square you pencilled over can still be tapped for a cat, and the cross is +forgiven: the attempt replaces it if it is right. The one thing no gesture +undoes is a cat once placed. + +The original ships a fifth gesture — a **draft** scratchpad mode, a tool you +buy, where crosses and cats are laid down without judgement and applied all at +once — and it is left out here with the rest of the shop: a scratchpad whose +notes cost nothing is what tapping already is. 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. +one, replacing any cross drawn over it. 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 -- 2.47.3