Compare commits

...
Author SHA1 Message Date
Claude 25e38c4bf8 Scorekeeper: remember the last 10 player names and offer them when naming a column
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 03:16:53 +00:00
devadmin 6bf7522042 Merge pull request 'Meowdoku: rules, AI and screen refinements' (#15) from meow2 into dev
CI / Build and check (push) Successful in 20m52s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-25 20:29:24 -06:00
Claude 7942adeaf7 Meowdoku: rules, AI and screen refinements
CI / Build and check (pull_request) Successful in 10m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 01:14:45 +00:00
devadmin 488f600f15 Merge pull request 'Katan: Catan Universe style HUD and trade board (2D)' (#13) from CatanImprovements into dev
CI / Build and check (push) Successful in 8m49s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-24 11:13:07 -06:00
10 changed files with 380 additions and 127 deletions
@@ -25,19 +25,12 @@ object MeowdokuAi : GameAi<MeowdokuState, 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.
// 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<TogglePencil>()
.firstOrNull { it.square == square }
?.let { return it }
if (state.cats.none { it.row == row }) {
places[Square(row, state.catCols[row])]?.let { return it }
}
}
@@ -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")
@@ -69,10 +69,14 @@ object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
/**
* 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<MeowdokuMove> {
if (seat != SOLO_SEAT || isFinished(state)) return emptyList()
@@ -82,7 +86,7 @@ object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
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<MeowdokuState, MeowdokuMove> {
}
/**
* 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,
)
@@ -0,0 +1,17 @@
package org.prolibertate.games.score
/** The short list of names offered when a column is being named. */
object RecentNames {
const val LIMIT = 10
/**
* [name] moved to the front of [names], trimmed, with any earlier copy
* (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name
* leaves the list as it was.
*/
fun with(names: List<String>, name: String): List<String> {
val typed = name.trim()
if (typed.isEmpty()) return names
return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT)
}
}
@@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) {
private object Keys {
val SHEET = stringPreferencesKey("sheet")
val RECENT_NAMES = stringPreferencesKey("recent_names")
}
// Unknown keys are ignored so a sheet written by a newer build can still be
@@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) {
?: ScoreSheet()
}
/** The names last used on a sheet, most recent first, for the rename dialog. */
val recentNames: Flow<List<String>> = context.scorekeeperDataStore.data.map { prefs ->
prefs[Keys.RECENT_NAMES]
?.let { stored ->
runCatching { json.decodeFromString<List<String>>(stored) }.getOrNull()
}
?: emptyList()
}
suspend fun rememberName(name: String) {
context.scorekeeperDataStore.edit { prefs ->
val current = prefs[Keys.RECENT_NAMES]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
.orEmpty()
prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name))
}
}
suspend fun save(sheet: ScoreSheet) {
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
}
@@ -114,6 +114,8 @@ fun ScorekeeperScreen(
local = repository.sheet.first()
loaded = true
}
val recentNames by repository.recentNames.collectAsState(initial = emptyList())
val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } }
val shared by session.state.collectAsState()
val role = shared.role
@@ -220,7 +222,7 @@ fun ScorekeeperScreen(
role == Role.GUEST -> Column(modifier = modifier) {
ConnectionBanner(shared = shared, onSync = { session.sync() })
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed)
}
joining && role == Role.NONE ->
@@ -232,7 +234,7 @@ fun ScorekeeperScreen(
onJoin = { joining = true },
)
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
else -> Scoring(modifier, sheet, change, recentNames, nameUsed)
}
}
@@ -356,16 +358,28 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
// ---------------------------------------------------------------------------
@Composable
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Scoring(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
if (sheet.mode == ScoringMode.TALLY) {
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
} else {
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
}
}
@Composable
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Sheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
// One scroll state shared by the head, the rows and the totals, so all three
// move together: a column and the name over it must never come apart. Every
// row is built the same way and is therefore exactly the same width, which
@@ -587,6 +601,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -607,7 +623,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
* sheet, so tapping one in the list takes it back off.
*/
@Composable
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun TallySheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
var renaming by remember { mutableStateOf(-1) }
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -676,6 +698,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -1064,16 +1088,27 @@ private fun ActionBar(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenameDialog(
sheet: ScoreSheet,
player: ScorePlayer,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
onName: (String) -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
// Closing the dialog is what files the name away, so a name is remembered
// once rather than once per keystroke.
val finish = {
onNameUsed(player.name)
onDismiss()
}
val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() }
val suggestions = recentNames.filter { it.trim().lowercase() !in taken }
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = finish,
title = { Text("Name this column") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1084,6 +1119,21 @@ private fun RenameDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { name ->
FilterChip(
selected = name.equals(player.name.trim(), ignoreCase = true),
onClick = { onName(name) },
label = { Text(name) },
)
}
}
}
TextButton(
onClick = onRemove,
// The last two columns stay: a sheet needs somebody on it.
@@ -1091,7 +1141,7 @@ private fun RenameDialog(
) { Text("Remove this player and their score") }
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
confirmButton = { TextButton(onClick = finish) { Text("Done") } },
)
}
@@ -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<Square>,
pencils: Set<Square>,
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<Square>.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<Square> {
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 {
@@ -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
@@ -0,0 +1,30 @@
package org.prolibertate.games.score
import org.junit.Test
import org.junit.Assert.assertEquals
class RecentNamesTest {
@Test
fun newestComesFirst() {
assertEquals(listOf("Bo", "Al"), RecentNames.with(listOf("Al"), "Bo"))
}
@Test
fun repeatMovesToFrontIgnoringCase() {
assertEquals(listOf("al", "Bo"), RecentNames.with(listOf("Bo", "Al"), " al "))
}
@Test
fun blankChangesNothing() {
assertEquals(listOf("Al"), RecentNames.with(listOf("Al"), " "))
}
@Test
fun keepsOnlyTen() {
val names = (1..10).map { "n$it" }
val result = RecentNames.with(names, "new")
assertEquals(10, result.size)
assertEquals("new", result.first())
assertEquals("n9", result.last())
}
}
+24 -13
View File
@@ -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