Compare commits

...
Author SHA1 Message Date
Claude d568ff7750 Meowdoku: one cat per row, column and colour, no two touching
CI / Build and check (pull_request) Successful in 8m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 16:32:39 +00:00
devadmin 2e66567643 Merge pull request 'Fix flaky ScorekeeperSessionTest' (#11) from fix/scorekeeper-test-flake into dev
CI / Build and check (push) Successful in 8m25s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-10-17 14:01:39 -06:00
Claude a8ff9863a6 Fix a race in ScorekeeperSessionTest's admittedGuest helper
CI / Build and check (pull_request) Successful in 8m32s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
A guest counts as connected before the host's sheet has arrived over the socket, so
tests that read the guest's players straight away could see an empty list. The helper
now waits for the guest to hold the host's sheet. 0 failures in 12 runs (was ~1 in 3).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 19:52:44 +00:00
devadmin bd12a7a70b Merge pull request 'Save state for every game between sessions' (#10) from feature/saved-games into dev
CI / Build and check (push) Successful in 8m28s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-17 13:19:05 -06:00
Claude e370aa0ceb Save state for every game between sessions
CI / Build and check (pull_request) Successful in 8m23s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
A game against the computer is kept as it is played (the host's true state plus its
table config, one file per game) and can be resumed from the main menu or the game's
setup screen. Leaving keeps the position; End game discards it; a finished game is
not kept; a networked table is not saved.

MatchController gains restore() and an onHostState listener; MatchSaver writes off
the main thread and flushes on leaving and ON_STOP. Tests cover the store, the saver,
resuming, and a save/resume round trip of every game in the catalogue at every move.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 19:04:47 +00:00
Claude 59a6ffc940 CLAUDE.md: local build, Gitea shipping flow and Katan rule sets
CI / Build and check (push) Successful in 8m6s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
CI / Build and check (pull_request) Successful in 8m13s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 18:31:29 +00:00
devadmin 98506c05b7 Merge pull request 'Katan: Cities & Knights' (#8) from feature/cities-and-knights into dev
CI / Build and check (push) Successful in 8m14s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-10-17 08:57:06 -06:00
39 changed files with 3184 additions and 612 deletions
+42 -59
View File
@@ -1,78 +1,61 @@
> **Moved to Gitea.** CI now runs on Gitea Actions at
> `git.wallacenet.ca/devadmin/ProLibertateGames`, publishing to Cloudflare Pages
> (`plg.wallacenet.ca`) only. The sandbox notes below describe the earlier GitHub
> setup (no CI run from session pushes, `workflow_dispatch` as the workaround);
> re-verify them against Gitea before relying on them.
# Working on this repo with Claude
## Nothing a session does triggers CI. Dispatch it explicitly.
CI runs on **Gitea Actions** at `git.wallacenet.ca/devadmin/ProLibertateGames`
(workflows in `.github/workflows/`), publishing to Cloudflare Pages
(`plg.wallacenet.ca`). The workflow below was measured on the dev machine
(`~/ProLibertateGames`); it replaces the earlier GitHub-sandbox notes, which no
longer apply.
GitHub creates **no workflow run** for anything this sandbox does — pushing a
branch, opening a pull request, or merging one. All of it goes through the same
integration, and GitHub suppresses event-triggered runs from it. The pull
request will say it was opened by the repo owner; that is only how the UI
attributes it, and it does not change the suppression.
## Build, test and lint locally
This was measured, not assumed:
Gradle works on the dev machine. Two environment variables are required: the
default JDK is 25, which Gradle 8.14 cannot use, and the Android SDK lives in the
home directory.
| Action | Result |
| --- | --- |
| Human pushes a Claude-authored commit (`d165e5ef`, `469c900e`) | CI runs |
| Session pushes `dev` / `main` (`43394b8`, `742add4`) | no run |
| Session opens a PR against `dev` (#1) | no run |
| Session calls `workflow_dispatch` | **runs** |
```
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export ANDROID_HOME=$HOME/android-sdk # platform 34, build-tools 34.0.0
./gradlew testDebugUnitTest lintDebug assembleDebug
```
`workflow_dispatch` works because it creates a run directly instead of relying
on an event. It is the only mechanism available from a session, so every build
has to be asked for by hand.
The Katan tests run in seconds; a clean build takes a few minutes. Run the full
suite, lint and `assembleDebug` before pushing: they are what CI runs. There is
no KVM, so no emulator; the engine and the UI logic are covered by unit tests
(see `docs/UI-catan.md`).
## Verify before merging, without publishing
## Shipping: branch, PR, CI, merge
`ci.yml` builds, tests and lints on any ref, but its staging and publishing
steps are gated on `github.ref == 'refs/heads/dev'`. Dispatching it on a
**feature branch** therefore gives the full check — build, unit tests, Android
lint — and publishes nothing.
So the working loop is:
1. Commit and push the feature branch.
2. Dispatch `ci.yml` on that branch. Wait for green. This is the real review
gate, and it is the only verification that exists (see below).
3. Open the PR through the API and merge it there, so the change is reviewable
and the merge commit is honest about what happened. Neither step builds
anything.
4. Dispatch the target branch's workflow to actually build and publish:
`ci.yml` on `dev`, `release.yml` on `main`.
Skipping step 4 leaves a branch whose head was never built and no release.
1. Branch from `dev`; before pushing, merge `origin/dev` in (it moves between
sessions).
2. Plain `git push` has no credentials. Use the Gitea token as a one-off header:
`git -c "http.extraheader=Authorization: token $(cat ~/.config/gitea/token)" push -u origin <branch>`.
3. Open the PR with `POST /api/v1/repos/devadmin/ProLibertateGames/pulls`
(same token, `Authorization: token ...`). CI (`ci.yml`) runs on the PR: build,
unit tests and Android lint; it publishes nothing. Poll
`/actions/runs` until it completes (about 7 minutes).
4. Merge with `POST .../pulls/N/merge {"Do":"merge"}`. The push to `dev` runs
`ci.yml` again and publishes the build (about 9 minutes).
## Merging is a shipping action
Both long-lived branches publish, so a merge is a release, not just an
integration:
- **`dev`** → `ci.yml` publishes a `v<series>.N-dev` **prerelease**, offered by the
in-app updater to anyone on the Dev channel.
- **`main`** → `release.yml` publishes a **production** release, which the
updater installs for everyone on the Production channel.
- **`dev`** publishes a `v<series>.N-dev` build to
`https://plg.wallacenet.ca/dev/latest.json`, offered by the in-app updater to
anyone on the Dev channel.
- **`main`** runs `release.yml` and publishes a **production** release for
everyone on the Production channel. Confirm before merging to `main`.
`release.yml` runs `assembleDebug` only (no tests, no lint), so a `main`
release is only as verified as the `dev` build it replays.
Confirm before shipping to `main`. Note that `release.yml` runs `assembleDebug`
only — no tests, no lint — so a `main` release is only as verified as the `dev`
build it replays.
## Katan
## Gradle does not run in the sandbox
`./gradlew` cannot resolve the Android plugin here: the proxy blocks
`dl.google.com` with a 403. No local build, no local test run, no local lint. A
dispatched CI run is the only way to find out whether a change compiles.
## A permanent fix, if you want one
The suppression is a property of the sandbox's credential, so it cannot be
fixed from inside a session. Giving the environment a personal access token to
push with would make pushes and PRs behave normally for everyone. Until then,
the dispatch step above is the workaround.
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
sets chosen at setup: the base game (`docs/RULES-catan.md`) and Cities & Knights
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`CatanState.ck`, which is null in the base game). The screen is layered; see
`docs/UI-catan.md` before changing it.
## Bash output is filtered before it reaches the model
@@ -14,7 +14,9 @@ import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme
import org.prolibertate.games.update.Updater
import java.io.File
import java.util.UUID
import org.prolibertate.games.game.engine.SavedGameStore
class MainActivity : ComponentActivity() {
@@ -40,6 +42,7 @@ class MainActivity : ComponentActivity() {
deviceId = peerId,
),
peerId = peerId,
saves = SavedGameStore(File(filesDir, "saved-games")),
)
setContent {
@@ -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,101 @@
package org.prolibertate.games.game.engine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
/**
* A game in progress, kept so it can be picked up again after the app has been
* closed.
*
* The match is stored the way the host holds it: the [config] it was dealt from
* (seed, seats, options) and the whole authoritative [stateJson], hidden cards
* included, as the game's own rules encode it. Nothing here knows what game it
* is; [gameId] is only the name of the slot.
*/
@Serializable
data class SavedMatch(
val gameId: String,
val config: TableConfig,
/** The seat the person at this device plays. */
val localSeat: Int,
val stateJson: String,
/** Wall-clock milliseconds when the position was written. */
val savedAtMillis: Long,
/** A line saying how the game stands, from the rules' own summary. */
val summary: String = "",
)
/**
* One saved game per game, kept as a file each under [directory].
*
* One slot per game rather than a list: the player is mid-way through a game of
* Chess or they are not. Starting a new one replaces it, which is what the
* resume prompt on the setup screen says.
*
* Writes are atomic — the position goes to a temporary file that is then moved
* over the real one — so being killed half way through a save leaves the
* previous position, not half of the new one. Reads never throw: a file that
* will not parse is a game that cannot be resumed, so it is removed and reported
* as absent.
*/
class SavedGameStore(private val directory: File) {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private fun fileFor(gameId: String): File {
// The ids are catalogue constants, but they become file names, so
// nothing that could climb out of the directory is let through.
require(gameId.isNotBlank() && gameId.all { it.isLetterOrDigit() || it == '_' || it == '-' }) {
"Not a game id: $gameId"
}
return File(directory, "$gameId.json")
}
/** Writes [match] over whatever was saved for its game. */
@Synchronized
fun save(match: SavedMatch) {
directory.mkdirs()
val target = fileFor(match.gameId)
val temp = File(directory, "${match.gameId}.json.tmp")
temp.writeText(json.encodeToString(SavedMatch.serializer(), match))
if (!temp.renameTo(target)) {
// Some file systems will not rename over an existing file.
target.delete()
check(temp.renameTo(target)) { "Could not write the saved game" }
}
}
/** The saved game for [gameId], or null if there is none or it cannot be read. */
@Synchronized
fun load(gameId: String): SavedMatch? {
val file = fileFor(gameId)
if (!file.isFile) return null
val match = runCatching {
json.decodeFromString(SavedMatch.serializer(), file.readText())
}.getOrNull()
if (match == null || match.gameId != gameId) {
file.delete()
return null
}
return match
}
/** Forgets the saved game for [gameId]. A no-op if there is none. */
@Synchronized
fun clear(gameId: String) {
fileFor(gameId).delete()
}
/** Every readable saved game, the most recently played first. */
@Synchronized
fun all(): List<SavedMatch> {
val files = directory.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
return files
.mapNotNull { file -> runCatching { load(file.name.removeSuffix(".json")) }.getOrNull() }
.sortedByDescending { it.savedAtMillis }
}
}
@@ -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)
}
@@ -96,6 +96,27 @@ class MatchController<S : Any, M : Any>(
/** Host only: the true state. Clients never see this. */
private var authoritative: S? = null
/** A position to carry on from instead of dealing, set by [restore] before [startAsHost]. */
private var restored: S? = null
/**
* Host only: told about every position the table reaches, so it can be kept
* for later. Called under the table's lock, so it must return quickly — the
* snapshot encodes its state only when asked, and the work of writing it
* belongs to whoever is listening.
*/
var onHostState: ((HostSnapshot) -> Unit)? = null
/** One position of the table, as handed to [onHostState]. */
class HostSnapshot(
val finished: Boolean,
val summary: String,
private val encode: () -> String,
) {
/** The whole true state, encoded by the game's own rules. */
fun stateJson(): String = encode()
}
private val lock = Mutex()
private var sequence = 0
@@ -160,7 +181,7 @@ class MatchController<S : Any, M : Any>(
check(role == Role.HOST) { "Not hosting" }
clients.forEach { connection -> listenToPeer(connection) }
scope.launch {
val initial = runCatching { rules.initialState(config) }.getOrElse { error ->
val initial = restored ?: runCatching { rules.initialState(config) }.getOrElse { error ->
// A table the rules will not accept is a dead end, not a reason
// to take the app down with it: this runs in the screen's own
// scope, where nothing would catch it.
@@ -175,6 +196,20 @@ class MatchController<S : Any, M : Any>(
}
}
/**
* Host: carry on from a saved position instead of dealing a new game.
*
* Call before [startAsHost]. Returns false, and changes nothing, if
* [stateJson] cannot be read by this version of the game — a save made by an
* older build, say — and the caller should deal a fresh table instead.
*/
fun restore(stateJson: String): Boolean {
check(role == Role.HOST) { "Only a host can restore a game" }
val state = runCatching { rules.decodeState(stateJson) }.getOrNull() ?: return false
restored = state
return true
}
private fun listenToPeer(connection: Connection) {
peers[connection.peerId] = connection
peerJobs[connection.peerId] = scope.launch {
@@ -449,6 +484,16 @@ class MatchController<S : Any, M : Any>(
}
_finished.value = rules.isFinished(current)
if (role == Role.HOST) {
onHostState?.invoke(
HostSnapshot(
finished = rules.isFinished(current),
summary = rules.summary(current),
encode = { rules.encodeState(current) },
),
)
}
for (slot in config.seats) {
val peerId = slot.peerId ?: continue
val connection = peers[peerId] ?: continue
@@ -0,0 +1,95 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import org.prolibertate.games.game.engine.SavedGameStore
import org.prolibertate.games.game.engine.SavedMatch
import org.prolibertate.games.game.engine.TableConfig
/**
* Keeps the position of one table on disk as it is played.
*
* It is fed every position the host reaches ([offer]) and writes the newest one
* off the main thread. Positions that arrive while a write is under way are
* dropped in favour of the latest — a computer's turn can be several moves in a
* second, and only where the game stands matters. A finished game is not kept:
* its slot is cleared, so a won game is not offered back as one to resume.
*
* [flush] writes whatever has not been written yet, on the caller's thread. It
* is what makes leaving the table, or the app going to the background, safe: the
* background writer may not have caught up, and the process may not be given the
* chance to let it.
*/
class MatchSaver(
private val store: SavedGameStore,
private val gameId: String,
private val config: TableConfig,
private val localSeat: Int,
private val scope: CoroutineScope,
private val clock: () -> Long = System::currentTimeMillis,
) {
private val latest = MutableStateFlow<MatchController.HostSnapshot?>(null)
private var worker: Job? = null
private var written: MatchController.HostSnapshot? = null
private var discarded = false
/** Starts the background writer. */
fun start() {
worker = scope.launch(Dispatchers.IO) {
latest.filterNotNull().collectLatest { write(it) }
}
}
/** Hands over the newest position. Cheap: the encoding happens on the writer. */
fun offer(snapshot: MatchController.HostSnapshot) {
latest.value = snapshot
}
/** Writes the newest position now if the writer has not already. */
fun flush() {
latest.value?.let { write(it) }
}
/** The game was ended on purpose: forget the saved position and write no more. */
@Synchronized
fun discard() {
discarded = true
worker?.cancel()
worker = null
runCatching { store.clear(gameId) }
}
fun stop() {
worker?.cancel()
worker = null
}
@Synchronized
private fun write(snapshot: MatchController.HostSnapshot) {
if (discarded || snapshot === written) return
written = snapshot
// Saving is a courtesy to the player: a full disk must not take the game
// down with it.
runCatching {
if (snapshot.finished) {
store.clear(gameId)
} else {
store.save(
SavedMatch(
gameId = gameId,
config = config,
localSeat = localSeat,
stateJson = snapshot.stateJson(),
savedAtMillis = clock(),
summary = snapshot.summary,
),
)
}
}
}
}
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -50,6 +52,10 @@ import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.ui.game.LocalCardSpeed
import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.prolibertate.games.game.engine.SavedGameStore
import org.prolibertate.games.game.engine.SavedMatch
/** Everything the screens need, assembled once by the activity. */
class AppEnv(
@@ -60,6 +66,8 @@ class AppEnv(
/** The shared score sheet, for the Scorekeeper to host or join. */
val scoreSession: ScorekeeperSession,
val peerId: String,
/** Games in progress, kept between sessions. */
val saves: SavedGameStore,
)
/**
@@ -83,6 +91,8 @@ sealed interface Route {
val config: TableConfig,
val hosting: Boolean,
val localSeat: Int,
/** A saved position to carry on from, as the game's rules encoded it; null deals a new game. */
val resumeState: String? = null,
) : Route
}
@@ -167,6 +177,10 @@ private fun AppContent(
// Dev builds show the games that are not finished yet; production
// releases list only what can actually be played.
showComingSoon = showComingSoon,
saved = savedGames(env, stack),
onResume = { saved ->
push(Route.Play(saved.gameId, saved.config, true, saved.localSeat, saved.stateJson))
},
onPickSection = { push(Route.Section(it)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onScorekeeper = { push(Route.Scorekeeper) },
@@ -194,15 +208,30 @@ private fun AppContent(
onBack = { pop() },
)
is Route.Setup -> GameSetupScreen(
descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.displayName,
onBack = { pop() },
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
onHostOnline = { optionsJson ->
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
},
)
is Route.Setup -> {
// Bumped when the saved game is thrown away, so the banner goes with it.
var discards by remember(route) { mutableIntStateOf(0) }
val saved by produceState<SavedMatch?>(null, route, discards) {
value = withContext(Dispatchers.IO) { env.saves.load(route.gameId) }
}
GameSetupScreen(
descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.displayName,
onBack = { pop() },
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
onHostOnline = { optionsJson ->
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
},
saved = saved,
onResume = { match ->
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
},
onDiscardSaved = {
env.saves.clear(route.gameId)
discards++
},
)
}
is Route.Lobby -> LobbyScreen(
env = env,
@@ -231,6 +260,8 @@ private fun AppContent(
@Composable
fun MainMenuScreen(
showComingSoon: Boolean,
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
onPickSection: (GameMenu) -> Unit,
onJoinGame: () -> Unit,
onScorekeeper: () -> Unit,
@@ -280,6 +311,23 @@ fun MainMenuScreen(
)
}
// Games left half-played, most recent first, above everything
// else: coming back to the app is mostly coming back to one.
if (saved.isNotEmpty()) {
Text(
"Continue",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.fillMaxWidth(),
)
val now = System.currentTimeMillis()
saved.forEach { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Button(onClick = { onResume(match) }, modifier = Modifier.fillMaxWidth()) {
Text(savedLine(title, match, now))
}
}
}
// Below the games, and full width like them, so the column
// stays one column all the way down.
OutlinedButton(
@@ -490,3 +538,17 @@ fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) {
Text(text)
}
}
/**
* The saved games, read off the main thread each time the menu is the screen
* showing — which is when a game has just been left and its last position
* written, so the list is not read until that has happened.
*/
@Composable
private fun savedGames(env: AppEnv, stack: List<Route>): List<SavedMatch> {
val onMenu = stack.last() is Route.Menu
val saved by produceState(emptyList<SavedMatch>(), onMenu, stack.size) {
value = if (onMenu) withContext(Dispatchers.IO) { env.saves.all() } else emptyList()
}
return saved
}
@@ -0,0 +1,21 @@
package org.prolibertate.games.ui
import androidx.compose.runtime.staticCompositionLocalOf
/**
* How a screen's "End game" button tells the table to forget the saved game.
*
* Leaving a table keeps its position so it can be resumed; ending one on purpose
* does not. The two arrive at the screen as the same "go back", so the
* difference is carried here instead: [PlayScreen] provides one handle, the match
* it builds says what ending means for it by setting [onEnd], and each End-game
* dialog calls [end] before it leaves.
*/
class EndGameHandle {
/** Set by the table once it knows how to forget its own saved position. */
var onEnd: () -> Unit = {}
fun end() = onEnd()
}
val LocalEndGameHandle = staticCompositionLocalOf { EndGameHandle() }
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Divider
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -29,6 +32,7 @@ import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.SavedMatch
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.backgammon.BackgammonLevel
import org.prolibertate.games.game.backgammon.BackgammonOptions
@@ -46,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
@@ -83,6 +89,10 @@ fun GameSetupScreen(
onBack: () -> Unit,
onPlayOffline: (TableConfig, Int) -> Unit,
onHostOnline: (String) -> Unit,
/** A game of this kind left half-played, if there is one. */
saved: SavedMatch? = null,
onResume: (SavedMatch) -> Unit = {},
onDiscardSaved: () -> Unit = {},
) {
var euchre by remember { mutableStateOf(EuchreOptions()) }
var sequence by remember { mutableStateOf(SequenceOptions()) }
@@ -101,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()) }
@@ -132,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)
@@ -149,6 +161,10 @@ fun GameSetupScreen(
) {
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
if (saved != null) {
SavedGameBanner(saved, onResume = { onResume(saved) }, onDiscard = onDiscardSaved)
}
Divider()
Text("House rules", fontWeight = FontWeight.Bold)
@@ -193,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 }
@@ -233,6 +251,12 @@ fun GameSetupScreen(
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
else -> 0
}
if (saved != null) {
Text(
"Starting a new game replaces the one in progress.",
style = MaterialTheme.typography.bodySmall,
)
}
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
onPlayOffline(
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
@@ -1116,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)) {
@@ -1216,3 +1270,30 @@ private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOpti
onSelect = { onChange(options.copy(redeals = it)) },
)
}
/**
* The game that was left unfinished, with the two things to do about it. Shown
* above the options so it is the first thing seen on coming back to a game.
*/
@Composable
private fun SavedGameBanner(saved: SavedMatch, onResume: () -> Unit, onDiscard: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("Game in progress", fontWeight = FontWeight.Bold)
Text(
buildString {
append("Saved ${agoText(System.currentTimeMillis(), saved.savedAtMillis)}")
if (saved.summary.isNotBlank()) append(" · ${saved.summary}")
},
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onResume) { Text("Resume") }
TextButton(onClick = onDiscard) { Text("Discard") }
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
package org.prolibertate.games.ui
import org.prolibertate.games.game.engine.SavedMatch
/** How long ago something happened, in the words a person would use. */
fun agoText(nowMillis: Long, thenMillis: Long): String {
val seconds = ((nowMillis - thenMillis) / 1000).coerceAtLeast(0)
val minutes = seconds / 60
val hours = minutes / 60
val days = hours / 24
return when {
seconds < 45 -> "just now"
minutes < 2 -> "a minute ago"
minutes < 60 -> "$minutes minutes ago"
hours < 2 -> "an hour ago"
hours < 24 -> "$hours hours ago"
days < 2 -> "yesterday"
else -> "$days days ago"
}
}
/** "Chess · saved 5 minutes ago" for a menu line. */
fun savedLine(title: String, match: SavedMatch, nowMillis: Long): String =
"$title · saved ${agoText(nowMillis, match.savedAtMillis)}"
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -378,7 +379,12 @@ private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -79,7 +80,12 @@ fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text(what) },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -314,7 +315,12 @@ private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -215,7 +216,12 @@ private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -268,7 +269,12 @@ private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -342,7 +343,12 @@ private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The deal is left as it stands.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -356,7 +357,12 @@ private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -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") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -483,7 +484,12 @@ private fun LeaveBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -242,7 +243,12 @@ private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The board is left as it stands.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -371,7 +372,12 @@ private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
@@ -668,7 +669,12 @@ private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -295,7 +296,12 @@ private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -603,7 +604,12 @@ fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left.",
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -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,119 @@
package org.prolibertate.games.game.engine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class SavedGameStoreTest {
@get:Rule
val folder = TemporaryFolder()
private fun store() = SavedGameStore(File(folder.root, "saves"))
private fun match(gameId: String = "chess", state: String = "{\"turn\":1}", at: Long = 1_000L) = SavedMatch(
gameId = gameId,
config = TableConfig(
gameId = gameId,
seats = listOf(
PlayerSlot(0, "You", PlayerKind.HUMAN_LOCAL, 0),
PlayerSlot(1, "Computer", PlayerKind.AI, 1),
),
optionsJson = "{}",
seed = 42L,
),
localSeat = 0,
stateJson = state,
savedAtMillis = at,
summary = "White to move",
)
@Test
fun `a saved game comes back exactly as it went in`() {
val s = store()
s.save(match())
assertEquals(match(), s.load("chess"))
}
@Test
fun `there is nothing to load before anything is saved`() {
assertNull(store().load("chess"))
assertTrue(store().all().isEmpty())
}
@Test
fun `a game keeps one slot, and saving again replaces it`() {
val s = store()
s.save(match(state = "one", at = 1))
s.save(match(state = "two", at = 2))
assertEquals("two", s.load("chess")!!.stateJson)
assertEquals(1, s.all().size)
}
@Test
fun `clearing forgets the game and leaves the others`() {
val s = store()
s.save(match("chess"))
s.save(match("golf"))
s.clear("chess")
assertNull(s.load("chess"))
assertEquals("golf", s.load("golf")!!.gameId)
s.clear("chess") // clearing nothing is fine
}
@Test
fun `the most recently played game is listed first`() {
val s = store()
s.save(match("chess", at = 10))
s.save(match("golf", at = 30))
s.save(match("catan", at = 20))
assertEquals(listOf("golf", "catan", "chess"), s.all().map { it.gameId })
}
@Test
fun `a file that cannot be read is dropped rather than thrown`() {
val s = store()
s.save(match())
File(folder.root, "saves/chess.json").writeText("{ this is not json")
assertNull(s.load("chess"))
assertFalse(File(folder.root, "saves/chess.json").exists())
}
@Test
fun `a file holding another game is not served as this one`() {
val s = store()
s.save(match("golf"))
File(folder.root, "saves/golf.json").copyTo(File(folder.root, "saves/chess.json"))
assertNull(s.load("chess"))
}
@Test
fun `a save that was cut off half way leaves the previous position`() {
val s = store()
s.save(match(state = "good"))
// What a kill mid-write leaves behind: a stray temporary file.
File(folder.root, "saves/chess.json.tmp").writeText("{ half a wri")
assertEquals("good", s.load("chess")!!.stateJson)
assertEquals(1, s.all().size)
}
@Test
fun `a game id cannot climb out of the folder`() {
val bad = runCatching { store().load("../secret") }
assertTrue(bad.isFailure)
}
@Test
fun `an unknown field from a newer build does not stop a save loading`() {
val s = store()
s.save(match())
val file = File(folder.root, "saves/chess.json")
file.writeText(file.readText().replaceFirst("{", "{\"addedLater\":true,"))
assertEquals("chess", s.load("chess")!!.gameId)
}
}
@@ -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) }
}
}
@@ -0,0 +1,191 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.prolibertate.games.game.engine.GameAi
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.SavedGameStore
import org.prolibertate.games.game.engine.TableConfig
import java.io.File
/**
* Saving a table as it is played, and starting a table from a save.
*
* The game here is a counter: one seat, a move adds one, and it is finished at
* [GOAL]. It has hidden state — [Counter.secret] — so the tests can check that
* what is saved is the true state, not the view a player is shown.
*/
class MatchSaverTest {
@get:Rule
val folder = TemporaryFolder()
private val scopes = mutableListOf<CoroutineScope>()
@After
fun tearDown() {
scopes.forEach { it.cancel() }
}
private data class Counter(val n: Int, val secret: String)
private object Rules : GameRules<Counter, String> {
override val gameId = "counter"
override fun initialState(config: TableConfig) = Counter(0, "hidden-${config.seed}")
override fun currentSeat(state: Counter): Int? = if (state.n >= GOAL) null else 0
override fun legalMoves(state: Counter, seat: Int) = if (state.n >= GOAL) emptyList() else listOf("add")
override fun applyMove(state: Counter, seat: Int, move: String) = state.copy(n = state.n + 1)
override fun isFinished(state: Counter) = state.n >= GOAL
override fun summary(state: Counter) = "at ${state.n}"
override fun viewFor(state: Counter, seat: Int) = state.copy(secret = "")
override fun encodeState(state: Counter) = "${state.n}|${state.secret}"
override fun decodeState(json: String): Counter {
val (n, secret) = json.split("|", limit = 2)
return Counter(n.toInt(), secret)
}
override fun encodeMove(move: String) = move
override fun decodeMove(json: String) = json
}
private val config = TableConfig(
gameId = "counter",
seats = listOf(PlayerSlot(0, "You", PlayerKind.HUMAN_LOCAL, 0)),
optionsJson = "{}",
seed = 7L,
)
private fun scope() = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
private fun controller(): MatchController<Counter, String> = MatchController(
rules = Rules,
ai = object : GameAi<Counter, String> {
override fun chooseMove(state: Counter, seat: Int, legal: List<String>) = legal.first()
},
config = config,
scope = scope(),
role = MatchController.Role.HOST,
localSeats = setOf(0),
primarySeat = 0,
)
private fun store() = SavedGameStore(File(folder.root, "saves"))
private fun waitFor(what: String, check: () -> Boolean) = runBlocking {
repeat(200) {
if (check()) return@runBlocking
delay(10)
}
error("Timed out waiting for $what")
}
private fun saver(s: SavedGameStore, c: MatchController<Counter, String>) =
MatchSaver(s, "counter", config, localSeat = 0, scope = scope(), clock = { 123L }).also {
it.start()
c.onHostState = it::offer
}
@Test
fun `every position the table reaches is kept, with the true state`() {
val s = store()
val c = controller()
saver(s, c)
c.startAsHost(emptyList())
waitFor("the deal") { c.state.value != null }
c.submit("add")
c.submit("add")
waitFor("two moves") { s.load("counter")?.stateJson?.startsWith("2|") == true }
val saved = s.load("counter")!!
assertEquals("2|hidden-7", saved.stateJson) // the hidden part is saved; the view blanks it
assertEquals("at 2", saved.summary)
assertEquals(123L, saved.savedAtMillis)
assertEquals(config, saved.config)
}
@Test
fun `a table started from a save carries on from where it stood`() {
val s = store()
val first = controller()
saver(s, first)
first.startAsHost(emptyList())
waitFor("the deal") { first.state.value != null }
repeat(3) { first.submit("add") }
waitFor("three moves") { s.load("counter")?.stateJson?.startsWith("3|") == true }
first.close()
val second = controller()
assertTrue(second.restore(s.load("counter")!!.stateJson))
second.startAsHost(emptyList())
waitFor("the restored table") { second.state.value != null }
assertEquals(3, second.state.value!!.n)
second.submit("add")
waitFor("a move after resuming") { second.state.value!!.n == 4 }
}
@Test
fun `a save the game cannot read is refused and the table is dealt afresh`() {
val c = controller()
assertFalse(c.restore("not a counter"))
c.startAsHost(emptyList())
waitFor("a fresh deal") { c.state.value != null }
assertEquals(0, c.state.value!!.n)
}
@Test
fun `a finished game is not kept`() {
val s = store()
val c = controller()
saver(s, c)
c.startAsHost(emptyList())
waitFor("the deal") { s.load("counter") != null }
repeat(GOAL) { c.submit("add") }
waitFor("the finish") { c.finished.value }
waitFor("the slot to clear") { s.load("counter") == null }
}
@Test
fun `flush writes the position the background writer has not reached`() {
val s = store()
val saver = MatchSaver(s, "counter", config, 0, scope(), clock = { 1L })
// Not started: nothing is written in the background at all.
saver.offer(MatchController.HostSnapshot(false, "at 5") { "5|x" })
assertNull(s.load("counter"))
saver.flush()
assertEquals("5|x", s.load("counter")!!.stateJson)
}
@Test
fun `ending a game on purpose forgets it and writes no more`() {
val s = store()
val saver = MatchSaver(s, "counter", config, 0, scope(), clock = { 1L })
saver.offer(MatchController.HostSnapshot(false, "at 1") { "1|x" })
saver.flush()
assertNotNull(s.load("counter"))
saver.discard()
assertNull(s.load("counter"))
// Leaving after ending must not put it back.
saver.offer(MatchController.HostSnapshot(false, "at 2") { "2|x" })
saver.flush()
assertNull(s.load("counter"))
}
private companion object {
const val GOAL = 5
}
}
@@ -0,0 +1,177 @@
package org.prolibertate.games.net
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.GameAi
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.ui.seatCountFor
import org.prolibertate.games.game.backgammon.BackgammonAi
import org.prolibertate.games.game.backgammon.BackgammonOptions
import org.prolibertate.games.game.backgammon.BackgammonRules
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.checkers.CheckersAi
import org.prolibertate.games.game.checkers.CheckersOptions
import org.prolibertate.games.game.checkers.CheckersRules
import org.prolibertate.games.game.chess.ChessAi
import org.prolibertate.games.game.chess.ChessOptions
import org.prolibertate.games.game.chess.ChessRules
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
import org.prolibertate.games.game.cribbage.CribbageAi
import org.prolibertate.games.game.cribbage.CribbageOptions
import org.prolibertate.games.game.cribbage.CribbageRules
import org.prolibertate.games.game.euchre.EuchreAi
import org.prolibertate.games.game.euchre.EuchreOptions
import org.prolibertate.games.game.euchre.EuchreRules
import org.prolibertate.games.game.freecell.FreeCellOptions
import org.prolibertate.games.game.freecell.FreeCellRules
import org.prolibertate.games.game.golf.GolfAi
import org.prolibertate.games.game.golf.GolfOptions
import org.prolibertate.games.game.golf.GolfRules
import org.prolibertate.games.game.hearts.HeartsAi
import org.prolibertate.games.game.hearts.HeartsOptions
import org.prolibertate.games.game.hearts.HeartsRules
import org.prolibertate.games.game.kaiser.KaiserAi
import org.prolibertate.games.game.kaiser.KaiserOptions
import org.prolibertate.games.game.kaiser.KaiserRules
import org.prolibertate.games.game.klondike.KlondikeAi
import org.prolibertate.games.game.klondike.KlondikeOptions
import org.prolibertate.games.game.klondike.KlondikeRules
import org.prolibertate.games.game.mastermind.MastermindAi
import org.prolibertate.games.game.mastermind.MastermindOptions
import org.prolibertate.games.game.mastermind.MastermindRules
import org.prolibertate.games.game.morris.MorrisAi
import org.prolibertate.games.game.morris.MorrisOptions
import org.prolibertate.games.game.morris.MorrisRules
import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules
import org.prolibertate.games.game.pirates.PiratesAi
import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.pirates.PiratesRules
import org.prolibertate.games.game.president.PresidentAi
import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.president.PresidentRules
import org.prolibertate.games.game.pyramid.PyramidOptions
import org.prolibertate.games.game.pyramid.PyramidRules
import org.prolibertate.games.game.sequence.SequenceAi
import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.sequence.SequenceRules
import org.prolibertate.games.game.solitaire.FirstLegalAi
import org.prolibertate.games.game.spider.SpiderOptions
import org.prolibertate.games.game.spider.SpiderRules
import org.prolibertate.games.game.tayu.TayuAi
import org.prolibertate.games.game.tayu.TayuOptions
import org.prolibertate.games.game.tayu.TayuRules
import org.prolibertate.games.game.wizard.WizardAi
import org.prolibertate.games.game.wizard.WizardOptions
import org.prolibertate.games.game.wizard.WizardRules
import org.prolibertate.games.game.yahtzee.YahtzeeAi
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
import org.prolibertate.games.game.yahtzee.YahtzeeRules
/**
* Every game can be saved and picked up again.
*
* For each game in the catalogue a table is dealt and played by the computer, and
* at every single move the whole true state is encoded and decoded again — which
* is exactly what saving it to disk and resuming does — and play carries on from
* the decoded copy. A game whose state does not survive that, or that cannot make
* a legal move from a state it has been through it, fails here.
*/
class SaveRoundTripAllGamesTest {
private val json = Json { encodeDefaults = true }
private fun <S : Any, M : Any> exercise(
gameId: String,
rules: GameRules<S, M>,
ai: GameAi<S, M>,
optionsJson: String,
steps: Int = 120,
) {
val seats = seatCountFor(gameId, optionsJson)
val config = TableConfig(
gameId = gameId,
seats = (0 until seats).map {
PlayerSlot(it, "P$it", if (it == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, it)
},
optionsJson = optionsJson,
seed = 20240607L,
)
var state = rules.initialState(config)
var moves = 0
repeat(steps) {
if (rules.isFinished(state)) return
// The save and the resume: what goes to disk, and what comes back.
val saved = rules.encodeState(state)
val back = rules.decodeState(saved)
assertEquals("$gameId: a saved state encodes differently after a resume", saved, rules.encodeState(back))
assertEquals(
"$gameId: a resumed state is not on the same seat's turn",
rules.currentSeat(state),
rules.currentSeat(back),
)
assertEquals(
"$gameId: a resumed state has different legal moves",
rules.currentSeat(state)?.let { rules.legalMoves(state, it) },
rules.currentSeat(back)?.let { rules.legalMoves(back, it) },
)
state = back
val seat = rules.currentSeat(state) ?: return // waiting on a scoreboard, which is not a move
val legal = rules.legalMoves(state, seat)
if (legal.isEmpty()) return
val move = ai.chooseMove(state, seat, legal)
assertTrue("$gameId: move $move is not legal after a resume", rules.isLegalMove(state, seat, move))
state = rules.applyMove(state, seat, move)
moves++
}
assertTrue("$gameId: made no moves", moves > 0)
}
private inline fun <reified O> opts(o: O) = json.encodeToString(o)
@Test fun euchre() = exercise(GameCatalog.EUCHRE, EuchreRules, EuchreAi(), opts(EuchreOptions()))
@Test fun sequence() = exercise(GameCatalog.SEQUENCE, SequenceRules, SequenceAi(), opts(SequenceOptions()))
@Test fun president() = exercise(GameCatalog.PRESIDENT, PresidentRules, PresidentAi(), opts(PresidentOptions()))
@Test fun golf() = exercise(GameCatalog.GOLF, GolfRules, GolfAi(), opts(GolfOptions()))
@Test fun kaiser() = exercise(GameCatalog.KAISER, KaiserRules, KaiserAi(), opts(KaiserOptions()))
@Test fun wizard() = exercise(GameCatalog.WIZARD, WizardRules, WizardAi(), opts(WizardOptions()))
@Test fun crazyEights() = exercise(GameCatalog.CRAZY_EIGHTS, CrazyEightsRules, CrazyEightsAi(), opts(CrazyEightsOptions()))
@Test fun cribbage() = exercise(GameCatalog.CRIBBAGE, CribbageRules, CribbageAi(), opts(CribbageOptions()))
@Test fun tayu() = exercise(GameCatalog.TAYU, TayuRules, TayuAi(), opts(TayuOptions()))
@Test fun checkers() = exercise(GameCatalog.CHECKERS, CheckersRules, CheckersAi(), opts(CheckersOptions()))
@Test fun backgammon() = exercise(GameCatalog.BACKGAMMON, BackgammonRules, BackgammonAi(), opts(BackgammonOptions()))
@Test fun mastermind() = exercise(GameCatalog.MASTERMIND, MastermindRules, MastermindAi(), opts(MastermindOptions()))
@Test fun pirates() = exercise(GameCatalog.PIRATES, PiratesRules, PiratesAi(), opts(PiratesOptions()))
@Test fun hearts() = exercise(GameCatalog.HEARTS, HeartsRules, HeartsAi, opts(HeartsOptions()))
@Test fun pegSolitaire() = exercise(GameCatalog.PEG_SOLITAIRE, PegSolitaireRules, PegSolitaireAi, opts(PegSolitaireOptions()))
@Test fun yahtzee() = exercise(GameCatalog.YAHTZEE, YahtzeeRules, YahtzeeAi, opts(YahtzeeOptions()))
@Test fun klondike() = exercise(GameCatalog.KLONDIKE, KlondikeRules, KlondikeAi, opts(KlondikeOptions()))
@Test fun freecell() = exercise(GameCatalog.FREECELL, FreeCellRules, FirstLegalAi(), opts(FreeCellOptions()))
@Test fun spider() = exercise(GameCatalog.SPIDER, SpiderRules, FirstLegalAi(), opts(SpiderOptions()))
@Test fun pyramid() = exercise(GameCatalog.PYRAMID, PyramidRules, FirstLegalAi(), opts(PyramidOptions()))
@Test fun morris() = exercise(GameCatalog.MORRIS, MorrisRules, MorrisAi(), opts(MorrisOptions()))
@Test fun chess() = exercise(GameCatalog.CHESS, ChessRules, ChessAi(), opts(ChessOptions()), steps = 24)
@Test fun catan() = exercise(GameCatalog.CATAN, CatanRules, CatanAi(), opts(CatanOptions()), steps = 400)
@Test
fun catanCitiesAndKnights() = exercise(
GameCatalog.CATAN,
CatanRules,
CatanAi(),
opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)),
steps = 600,
)
}
@@ -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
}
@@ -0,0 +1,27 @@
package org.prolibertate.games.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class SavedGameTextTest {
private val minute = 60_000L
private val hour = 60 * minute
private val day = 24 * hour
@Test
fun `how long ago reads like a person would say it`() {
assertEquals("just now", agoText(1_000, 1_000))
assertEquals("just now", agoText(30_000, 0))
assertEquals("a minute ago", agoText(minute + 20_000, 0))
assertEquals("5 minutes ago", agoText(5 * minute, 0))
assertEquals("an hour ago", agoText(90 * minute, 0))
assertEquals("3 hours ago", agoText(3 * hour, 0))
assertEquals("yesterday", agoText(30 * hour, 0))
assertEquals("4 days ago", agoText(4 * day, 0))
}
@Test
fun `a clock that ran backwards does not say nonsense`() {
assertEquals("just now", agoText(0, 10 * minute))
}
}
+3
View File
@@ -27,6 +27,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) |
| Klondike | — (TODO) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
| Meowdoku | [RULES-meowdoku.md](RULES-meowdoku.md) |
| Morris | [RULES-morris.md](RULES-morris.md) |
| Peg solitaire | — (TODO) |
| Pirates | [RULES-pirates.md](RULES-pirates.md) |
@@ -42,6 +43,8 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
- Build, CI, releases and OTA are described in the CI section of the root
[README](../README.md).
- [SAVED-GAMES.md](SAVED-GAMES.md) — how a game in progress is kept between sessions, and what is
not kept.
- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle,
re-copy or re-lay-out it without touching the rules.
- `licenses/` — third-party licences that must travel with the app
+87
View File
@@ -0,0 +1,87 @@
# Meowdoku
A board of colours, and a cat in every row, column and colour — no two sharing
even a corner.
## The puzzle
The board is **N by N** and coloured into **N regions**. Exactly one cat hides
in every row, every column and every region, and no two cats may touch — not
along a side and not at a corner, so a cat rules out all eight squares around
itself. Those four constraints together always leave exactly one arrangement.
Uniqueness is not decoration. The game is deduction, and a second answer means
a square the player cannot prove either way. Every board is therefore generated
and then searched: build the colours, count the ways a board can be answered,
and if it is more than one, throw the board away and deal another. The count is
a plain depth-first search over the rows with the obvious exclusions propagated,
and it sees only the colours and the rules — never the cats it was grown from.
## Playing it
**Tap a square where a cat lives.** A correct tap lands the cat; a wrong one
costs a heart and crosses the square over, because being wrong here is
information and the board keeps it for you. Finding all N cats wins; losing the
hearts ends the board with the rest still hidden.
The **Pencil** toggle is for the marks you make yourself. They are free, they
cost nothing to erase, and the board does not judge them. The one trap they
carry: a square crossed over is a square you have decided against, so tapping a
cat onto it is refused until the pencil erases the note. The original game
draws crosses for you on a wrong tap for the same reason — a wrong square
crossed twice is the same mistake twice — and here the same thing happens
whether the cross came from the heart or from the pencil.
A **Hint** spends a token and shows the cat in the first row that still lacks
one. The original sells these as tools; here they are simply counted, three per
board by default, which keeps the hint a decision rather than a reflex.
## The heart
Why a wrong tap costs anything at all: the puzzle is only hard if guessing is.
A board with no penalty is a board you can solve by tapping every square, and
the hearts are what make the deduction on a colour you have not reasoned about
yet feel like a choice. Three is the number the original deals, and it stays
three by default.
## Options
| | |
| --- | --- |
| **Board** | 4×4 up to 10×10; the number of cats equals the side |
| **Hearts** | 1 to 5, default 3 |
| **Hints** | 0 to 3, default 3 |
Boards are dealt from the table's seed, so a saved game resumes to the same
cats in the same colours, and **New puzzle** moves the seed to deal a different
board. Smaller sides are gentler: a 4×4 is the shortest way to learn what
no-touch does to a corner, and the ten-wide boards are for people who have
already made that mistake.
## How a board is dealt
The order matters, and it is the order the original game uses:
1. **Settle the cats first.** One per row, one per column, no two touching,
chosen by backtracking over shuffled column orders.
2. **Grow the colours around them.** Each region starts as its cat's square and
expands outwards one square at a time; some regions are grown as straight
bars along the cat's row or column, which is what stops the board reading as
texture, and the rest take whichever neighbour is smallest so nothing
sprawls while another dies young.
3. **Check the answer.** Count solutions up to two; keep the board only when
the count comes back exactly one.
The generator's rejection loop is why the same seed can take a few attempts:
most boards are unique on the first try, and the odd seed has to walk until it
finds one.
## Where this engine differs from the original
The app is a game collection, not a live-service puzzle, so what sits around
the original's gameplay is left behind: lives-as-monetisation (watching an ad
for a heart), daily streaks and leaderboards, an Elo-matched bank of
hand-authored levels, and the cosmetic layer (cat animations, combo feedback,
confetti). This engine **deals its boards from a seed** instead of shipping a
level bank, and the rules are the part worth keeping: the cats, the colours,
the no-touch, the hearts, the one answer.
+43
View File
@@ -0,0 +1,43 @@
# Saved games
A game played against the computer (or on one device) is kept as it is played, and can be
picked up again after the app has been closed.
## What the player sees
- **Main menu → Continue.** Each game left half-played is listed, most recent first
("Chess · saved 5 minutes ago"); tapping it resumes.
- **A game's setup screen** shows a *Game in progress* card with **Resume** and **Discard**.
Starting a new game from that screen replaces the saved one.
- **Leaving the table** (the back arrow, or the app being closed or sent to the background)
keeps the position. **End game → End the game** is the deliberate way to throw it away.
- A game that is **finished** is not kept.
- A **networked** table is not kept: the other player's seat cannot be held for them. Only a
table with no remote human at it (`PlayerKind.HUMAN_REMOTE`) is saved.
One slot per game: there is one saved Chess game, one saved Katan game, and so on.
## How it works
| Piece | Where | Does |
| --- | --- | --- |
| `SavedMatch`, `SavedGameStore` | `game/engine/SavedGame.kt` | one JSON file per game under `filesDir/saved-games/`, written atomically (temp file, then renamed); an unreadable file is deleted and read as absent |
| `MatchController.onHostState`, `restore` | `net/MatchController.kt` | the host reports every position it reaches; `restore(json)` starts the table from a saved position instead of dealing |
| `MatchSaver` | `net/MatchSaver.kt` | takes the newest position and writes it off the main thread; `flush()` writes synchronously on leaving or on `ON_STOP`; `discard()` forgets the game |
| `StartMatch` | `ui/PlayScreen.kt` | wires the two together for every game; consumes the resume state once, so a solitaire "new deal" is not put back |
| `LocalEndGameHandle` | `ui/EndGameHandle.kt` | how each *End game* dialog tells the table to forget its save |
What is saved is `rules.encodeState` of the **host's true state** (hidden cards included),
plus the `TableConfig` (seed, seats, options) and the local seat. Nothing in the store knows
which game it is holding, so a new game is saved with no work, provided its state encodes.
A save this build cannot decode (one written by an older version whose state has since
changed shape) is thrown away and a fresh game is dealt.
## Tests
- `SavedGameStoreTest` — the files: round trip, one slot, ordering, corruption, a cut-off write.
- `MatchSaverTest` — saving as a table is played, resuming from a save, finished games, flush, discard.
- `SaveRoundTripAllGamesTest` — **every game in the catalogue**: the whole state is encoded and
decoded at every move of a computer-played game, and play carries on from the decoded copy.
A new game should be added to it.