Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d568ff7750 | ||
|
|
2e66567643 | ||
|
|
a8ff9863a6 | ||
|
|
bd12a7a70b | ||
|
|
e370aa0ceb | ||
|
|
59a6ffc940 | ||
|
|
98506c05b7 |
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user