diff --git a/app/src/main/java/org/prolibertate/games/MainActivity.kt b/app/src/main/java/org/prolibertate/games/MainActivity.kt index 84f272d..1e6b2ab 100644 --- a/app/src/main/java/org/prolibertate/games/MainActivity.kt +++ b/app/src/main/java/org/prolibertate/games/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index fa63168..ed028bf 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -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 } diff --git a/app/src/main/java/org/prolibertate/games/game/engine/SavedGame.kt b/app/src/main/java/org/prolibertate/games/game/engine/SavedGame.kt new file mode 100644 index 0000000..c508f20 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/engine/SavedGame.kt @@ -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 { + 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 } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt new file mode 100644 index 0000000..b7c2cd3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt @@ -0,0 +1,39 @@ +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 { + + override fun chooseMove( + state: MeowdokuState, + seat: Int, + legal: List, + ): MeowdokuMove { + require(legal.isNotEmpty()) { "No move available" } + val places = legal.filterIsInstance().associateBy { it.square } + + // The first cat still missing. A cross on its square is no obstacle: + // crossed squares take attempts like anything else, and a correct one + // replaces the cross. + for (row in 0 until state.side) { + if (state.cats.none { it.row == row }) { + places[Square(row, state.catCols[row])]?.let { return it } + } + } + + return legal.first() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuGenerator.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuGenerator.kt new file mode 100644 index 0000000..bee202d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuGenerator.kt @@ -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 { + 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, 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, + candidates: Array>, + 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, + candidates: Array>, + ): (() -> 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): 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, + /** 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 get() = (0 until side).toList() + + fun solutionSquares(): List> = + 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 shuffled(items: List): List { + 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() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt new file mode 100644 index 0000000..089426e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt @@ -0,0 +1,119 @@ +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. A tap crosses +// a square over; a second tap on that square asks the board whether a cat +// lives there. A right answer lands the cat, a wrong one costs a heart and +// keeps the cross — so the crossing over is both the player's note and the +// first half of every guess, and the hearts are what stop it being a lottery. + +/** One square of the board. */ +@Serializable +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>, + /** The column of the cat in each row — the answer, kept so taps can be judged. */ + val catCols: List, + /** Cats the player has found (or a hint has shown). */ + val cats: Set, + /** Squares crossed over: wrong taps and pencil marks alike. */ + val marks: Set, + 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, +) { + 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 + +/** + * The second tap of a double tap: the cat attempt. + * + * One move rather than two, because that is what the player is doing: the board + * decides whether the tap found a cat or cost a heart. The square may carry the + * cross its own first tap just drew — that cross is part of the double tap, + * not a decision against it — and a correct attempt replaces it. + */ +@Serializable +@SerialName("cat") +data class PlaceCat(val square: Square) : MeowdokuMove + +/** + * Draws or erases a cross on a square with no cat on it. + * + * This is the first tap of a tap pair and every cell of a drag stroke, so it is + * the note-taking half of the scheme: free, unrewarded, and the board cannot + * lose a heart over it. A cross drawn here can still be tapped through with a + * cat attempt — the note is the player's, and only they get to change their + * mind about it. + */ +@Serializable +@SerialName("pencil") +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 diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt new file mode 100644 index 0000000..ed2a0a4 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt @@ -0,0 +1,197 @@ +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 { + + 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(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. + * + * The scheme is the original's: the first tap crosses a square over and + * opens a short window, and a second tap in that window is the cat attempt + * — so an attempt is offered on every square without a cat, crossed or + * not. The cross the attempt replaces is exactly the cross the first tap + * drew. Cats, once placed, are never tapped again: they are not a guess + * you take back. + * + * The list is written row by row, which is also the order the hint uses. + */ + override fun legalMoves(state: MeowdokuState, seat: Int): List { + if (seat != SOLO_SEAT || isFinished(state)) return emptyList() + val cats = ArrayList() + val pencils = ArrayList() + 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 + 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 second tap of a double tap: found a cat, or lost a heart. + * + * A correct tap clears the cross the first tap left behind — the cat + * replaces it. A wrong one leaves the cross where it is and spends a + * heart, because a wrong square crossed twice is the same mistake twice, + * and the board remembers it either way. + */ + private fun tap(state: MeowdokuState, square: Square): MeowdokuState { + if (state.isCatSquare(square)) { + val next = state.copy( + cats = state.cats + square, + marks = state.marks - square, + lastSquare = square, + lastWrong = false, + ) + 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) +} diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index 776a565..b0c1c84 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -96,6 +96,27 @@ class MatchController( /** 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( 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( } } + /** + * 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( } _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 diff --git a/app/src/main/java/org/prolibertate/games/net/MatchSaver.kt b/app/src/main/java/org/prolibertate/games/net/MatchSaver.kt new file mode 100644 index 0000000..3aae3da --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/MatchSaver.kt @@ -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(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, + ), + ) + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/score/RecentNames.kt b/app/src/main/java/org/prolibertate/games/score/RecentNames.kt new file mode 100644 index 0000000..7ab976b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/RecentNames.kt @@ -0,0 +1,17 @@ +package org.prolibertate.games.score + +/** The short list of names offered when a column is being named. */ +object RecentNames { + const val LIMIT = 10 + + /** + * [name] moved to the front of [names], trimmed, with any earlier copy + * (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name + * leaves the list as it was. + */ + fun with(names: List, name: String): List { + val typed = name.trim() + if (typed.isEmpty()) return names + return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT) + } +} diff --git a/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt b/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt index 0394199..05e171d 100644 --- a/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt +++ b/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt @@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) { private object Keys { val SHEET = stringPreferencesKey("sheet") + val RECENT_NAMES = stringPreferencesKey("recent_names") } // Unknown keys are ignored so a sheet written by a newer build can still be @@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) { ?: ScoreSheet() } + /** The names last used on a sheet, most recent first, for the rename dialog. */ + val recentNames: Flow> = context.scorekeeperDataStore.data.map { prefs -> + prefs[Keys.RECENT_NAMES] + ?.let { stored -> + runCatching { json.decodeFromString>(stored) }.getOrNull() + } + ?: emptyList() + } + + suspend fun rememberName(name: String) { + context.scorekeeperDataStore.edit { prefs -> + val current = prefs[Keys.RECENT_NAMES] + ?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + .orEmpty() + prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name)) + } + } + suspend fun save(sheet: ScoreSheet) { context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) } } diff --git a/app/src/main/java/org/prolibertate/games/score/SheetOp.kt b/app/src/main/java/org/prolibertate/games/score/SheetOp.kt index 102e8d6..90ff81d 100644 --- a/app/src/main/java/org/prolibertate/games/score/SheetOp.kt +++ b/app/src/main/java/org/prolibertate/games/score/SheetOp.kt @@ -31,8 +31,18 @@ sealed interface SheetOp { /** Sets a fresh sheet up for [count] players, scored by round or by tally. */ @Serializable @SerialName("start") -data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp { - override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode) +data class StartSheet( + val count: Int, + val mode: ScoringMode = ScoringMode.ROUNDS, + /** Names for the first seats, in order; a seat past the end stays unnamed. */ + val names: List = emptyList(), +) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet { + val seated = ScoreSheet.of(count, mode) + return seated.players.foldIndexed(seated) { index, acc, player -> + names.getOrNull(index)?.let { acc.renamed(player.id, it) } ?: acc + } + } } /** Rubs everything out. */ diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index b01ffd3..e464b49 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -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(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, + 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): List { + val onMenu = stack.last() is Route.Menu + val saved by produceState(emptyList(), onMenu, stack.size) { + value = if (onMenu) withContext(Dispatchers.IO) { env.saves.all() } else emptyList() + } + return saved +} diff --git a/app/src/main/java/org/prolibertate/games/ui/EndGameHandle.kt b/app/src/main/java/org/prolibertate/games/ui/EndGameHandle.kt new file mode 100644 index 0000000..20ba22c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/EndGameHandle.kt @@ -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() } diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 4363df7..68bf362 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -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") } + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 4dda622..50435f0 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -2,10 +2,13 @@ package org.prolibertate.games.ui import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope @@ -85,6 +88,10 @@ import org.prolibertate.games.game.mastermind.MastermindAi import org.prolibertate.games.game.mastermind.MastermindMove import org.prolibertate.games.game.mastermind.MastermindRules import org.prolibertate.games.game.mastermind.MastermindState +import org.prolibertate.games.game.meowdoku.MeowdokuAi +import org.prolibertate.games.game.meowdoku.MeowdokuMove +import org.prolibertate.games.game.meowdoku.MeowdokuRules +import org.prolibertate.games.game.meowdoku.MeowdokuState import org.prolibertate.games.game.morris.MorrisAi import org.prolibertate.games.game.morris.MorrisMove import org.prolibertate.games.game.morris.MorrisRules @@ -112,6 +119,8 @@ import org.prolibertate.games.game.wizard.WizardPhase import org.prolibertate.games.game.wizard.WizardRules import org.prolibertate.games.game.wizard.WizardState import org.prolibertate.games.net.MatchController +import org.prolibertate.games.net.MatchSaver +import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.settings.Settings import org.prolibertate.games.ui.game.BackgammonScreen import org.prolibertate.games.ui.game.catan.CatanScreen @@ -127,6 +136,7 @@ import org.prolibertate.games.ui.game.EuchreScreen import org.prolibertate.games.ui.game.GolfScreen import org.prolibertate.games.ui.game.KaiserScreen import org.prolibertate.games.ui.game.MastermindScreen +import org.prolibertate.games.ui.game.MeowdokuScreen import org.prolibertate.games.ui.game.MorrisScreen import org.prolibertate.games.ui.game.PegSolitaireScreen import org.prolibertate.games.ui.game.SpiderScreen @@ -155,549 +165,582 @@ fun PlayScreen( val scope = rememberCoroutineScope() val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT - when (route.gameId) { - GameCatalog.EUCHRE -> { - val controller = remember(route) { - MatchController( - rules = EuchreRules, - ai = EuchreAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - // A scored hand pauses on screen, then the next one is dealt. - advanceIdle = { state -> - if (state.phase == EuchrePhase.HAND_OVER) { - EuchreRules.nextHand(state) - } else { - null - } - }, - aiThinkingMillis = { settings.scaled(700L) }, - // Keep the finished trick on the table before the next - // card lands on top of it. - holdBeforeNextMove = { state -> - if (state.completedTrick.isNotEmpty()) { - settings.scaled(TRICK_HOLD_MILLIS) - } else { - 0L - } - }, + // The same handle for every table this screen builds, so an End-game dialog + // anywhere below can reach the table that is showing. + val endHandle = remember(route) { EndGameHandle() } + CompositionLocalProvider(LocalEndGameHandle provides endHandle) { + when (route.gameId) { + GameCatalog.EUCHRE -> { + val controller = remember(route) { + MatchController( + rules = EuchreRules, + ai = EuchreAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // A scored hand pauses on screen, then the next one is dealt. + advanceIdle = { state -> + if (state.phase == EuchrePhase.HAND_OVER) { + EuchreRules.nextHand(state) + } else { + null + } + }, + aiThinkingMillis = { settings.scaled(700L) }, + // Keep the finished trick on the table before the next + // card lands on top of it. + holdBeforeNextMove = { state -> + if (state.completedTrick.isNotEmpty()) { + settings.scaled(TRICK_HOLD_MILLIS) + } else { + 0L + } + }, + ) + } + StartMatch(env, controller, route) + EuchreScreen( + controller = controller, + localSeat = route.localSeat, + trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS), + onExit = onExit, ) } - StartMatch(env, controller, route) - EuchreScreen( - controller = controller, - localSeat = route.localSeat, - trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS), - onExit = onExit, - ) - } - GameCatalog.SEQUENCE -> { - val controller = remember(route) { - MatchController( - rules = SequenceRules, - ai = SequenceAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - aiThinkingMillis = { settings.scaled(700L) }, + GameCatalog.SEQUENCE -> { + val controller = remember(route) { + MatchController( + rules = SequenceRules, + ai = SequenceAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + SequenceScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.PRESIDENT -> { + val controller = remember(route) { + MatchController( + rules = PresidentRules, + ai = PresidentAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == PresidentPhase.ROUND_OVER) { + PresidentRules.nextRound(state) + } else { + null + } + }, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + PresidentScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.GOLF -> { + val controller = remember(route) { + MatchController( + rules = GolfRules, + ai = GolfAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == GolfPhase.HOLE_OVER) GolfRules.nextHole(state) else null + }, + // The score card is read before the next hole is dealt. + awaitsConfirmation = { state -> state.phase == GolfPhase.HOLE_OVER }, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.KAISER -> { + val controller = remember(route) { + MatchController( + rules = KaiserRules, + ai = KaiserAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == KaiserPhase.HAND_OVER) { + KaiserRules.nextHand(state) + } else { + null + } + }, + aiThinkingMillis = { settings.scaled(700L) }, + holdBeforeNextMove = { state -> + if (state.completedTrick.isNotEmpty()) { + settings.scaled(TRICK_HOLD_MILLIS) + } else { + 0L + } + }, + ) + } + StartMatch(env, controller, route) + KaiserScreen( + controller = controller, + localSeat = route.localSeat, + trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS), + onExit = onExit, ) } - StartMatch(env, controller, route) - SequenceScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - GameCatalog.PRESIDENT -> { - val controller = remember(route) { - MatchController( - rules = PresidentRules, - ai = PresidentAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - advanceIdle = { state -> - if (state.phase == PresidentPhase.ROUND_OVER) { - PresidentRules.nextRound(state) - } else { - null - } - }, - aiThinkingMillis = { settings.scaled(700L) }, + GameCatalog.WIZARD -> { + val controller = remember(route) { + MatchController( + rules = WizardRules, + ai = WizardAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == WizardPhase.ROUND_OVER) { + WizardRules.nextRound(state) + } else { + null + } + }, + // Bids against tricks taken are read before the next deal. + awaitsConfirmation = { state -> state.phase == WizardPhase.ROUND_OVER }, + aiThinkingMillis = { settings.scaled(700L) }, + holdBeforeNextMove = { state -> + if (state.completedTrick.isNotEmpty()) { + settings.scaled(TRICK_HOLD_MILLIS) + } else { + 0L + } + }, + ) + } + StartMatch(env, controller, route) + WizardScreen( + controller = controller, + localSeat = route.localSeat, + trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS), + onExit = onExit, ) } - StartMatch(env, controller, route) - PresidentScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - GameCatalog.GOLF -> { - val controller = remember(route) { - MatchController( - rules = GolfRules, - ai = GolfAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - advanceIdle = { state -> - if (state.phase == GolfPhase.HOLE_OVER) GolfRules.nextHole(state) else null - }, - // The score card is read before the next hole is dealt. - awaitsConfirmation = { state -> state.phase == GolfPhase.HOLE_OVER }, - aiThinkingMillis = { settings.scaled(700L) }, + GameCatalog.CRAZY_EIGHTS -> { + val controller = remember(route) { + MatchController( + rules = CrazyEightsRules, + ai = CrazyEightsAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == CrazyEightsPhase.ROUND_OVER) { + CrazyEightsRules.nextRound(state) + } else { + null + } + }, + awaitsConfirmation = { state -> + state.phase == CrazyEightsPhase.ROUND_OVER + }, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.CRIBBAGE -> { + val controller = remember(route) { + MatchController( + rules = CribbageRules, + ai = CribbageAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == CribbagePhase.SHOW) { + CribbageRules.nextHand(state) + } else { + null + } + }, + // The show is the half of cribbage that is read rather than + // played, so the table stops on it until somebody says go on. + awaitsConfirmation = { state -> state.phase == CribbagePhase.SHOW }, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + CribbageScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.TAYU -> { + val controller = remember(route) { + MatchController( + rules = TayuRules, + // As with chess, the strength is part of the table's own + // options, so this does not have to decode anything. + ai = TayuAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.CHECKERS -> { + val controller = remember(route) { + MatchController( + rules = CheckersRules, + ai = CheckersAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(250L) }, + ) + } + StartMatch(env, controller, route) + CheckersScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.BACKGAMMON -> { + val controller = remember(route) { + MatchController( + rules = BackgammonRules, + ai = BackgammonAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // A checker at a time, so the pause is per checker rather + // than per turn — half of it, or a double would take an age. + aiThinkingMillis = { settings.scaled(350L) }, + ) + } + StartMatch(env, controller, route) + BackgammonScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.MASTERMIND -> { + val controller = remember(route) { + MatchController( + rules = MastermindRules, + ai = MastermindAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(900L) }, + ) + } + StartMatch(env, controller, route) + MastermindScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.PIRATES -> { + val controller = remember(route) { + MatchController( + rules = PiratesRules, + ai = PiratesAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(250L) }, + ) + } + StartMatch(env, controller, route) + PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.HEARTS -> { + val controller = remember(route) { + MatchController( + rules = HeartsRules, + ai = HeartsAi, + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + advanceIdle = { state -> + if (state.phase == HeartsPhase.ROUND_OVER) { + HeartsRules.startNextRound(state) + } else { + null + } + }, + // What a round cost is read before the next deal. + awaitsConfirmation = { state -> state.phase == HeartsPhase.ROUND_OVER }, + aiThinkingMillis = { settings.scaled(700L) }, + holdBeforeNextMove = { state -> + if (state.completedTrick.isNotEmpty()) { + settings.scaled(TRICK_HOLD_MILLIS) + } else { + 0L + } + }, + ) + } + StartMatch(env, controller, route) + HeartsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.PEG_SOLITAIRE -> { + // Playing again means a fresh board rather than a fresh round, and + // it cannot go through advanceIdle: driveIdleSeats returns as soon + // as rules.isFinished is true, and a peg board with no jumps left + // genuinely is finished. Rebuilding the controller is the honest + // way to deal again, and the board is deterministic, so the retry + // is the same puzzle. + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = PegSolitaireRules, + // Never consulted while a person holds the only seat. + ai = PegSolitaireAi, + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + PegSolitaireScreen( + controller = controller, + onRestart = { generation++ }, + onExit = onExit, ) } - StartMatch(env, controller, route) - GolfScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - GameCatalog.KAISER -> { - val controller = remember(route) { - MatchController( - rules = KaiserRules, - ai = KaiserAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - advanceIdle = { state -> - if (state.phase == KaiserPhase.HAND_OVER) { - KaiserRules.nextHand(state) - } else { - null - } - }, - aiThinkingMillis = { settings.scaled(700L) }, - holdBeforeNextMove = { state -> - if (state.completedTrick.isNotEmpty()) { - settings.scaled(TRICK_HOLD_MILLIS) - } else { - 0L - } - }, + GameCatalog.MEOWDOKU -> { + // Like peg solitaire the controller is rebuilt rather than + // reset — a finished board has nothing left to do — but unlike + // it a new board is dealt every time: the same cats in the same + // colours twice would only repeat the last puzzle's answer. + // Moving the seed moves the deal, and each deal is still + // reproducible from its own seed. + var attempt by remember(route) { mutableLongStateOf(0L) } + val controller = remember(route, attempt) { + MatchController( + rules = MeowdokuRules, + // Never consulted while a person holds the only seat. + ai = MeowdokuAi, + config = route.config.copy(seed = route.config.seed + attempt), + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + MeowdokuScreen( + controller = controller, + onNewPuzzle = { attempt++ }, + onExit = onExit, ) } - StartMatch(env, controller, route) - KaiserScreen( - controller = controller, - localSeat = route.localSeat, - trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS), - onExit = onExit, - ) - } - GameCatalog.WIZARD -> { - val controller = remember(route) { - MatchController( - rules = WizardRules, - ai = WizardAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - advanceIdle = { state -> - if (state.phase == WizardPhase.ROUND_OVER) { - WizardRules.nextRound(state) - } else { - null - } - }, - // Bids against tricks taken are read before the next deal. - awaitsConfirmation = { state -> state.phase == WizardPhase.ROUND_OVER }, - aiThinkingMillis = { settings.scaled(700L) }, - holdBeforeNextMove = { state -> - if (state.completedTrick.isNotEmpty()) { - settings.scaled(TRICK_HOLD_MILLIS) - } else { - 0L - } - }, + GameCatalog.YAHTZEE -> { + val controller = remember(route) { + MatchController( + rules = YahtzeeRules, + ai = YahtzeeAi, + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // Long enough to watch the dice land, short enough that a + // six-handed game is not an evening of waiting. + aiThinkingMillis = { settings.scaled(500L) }, + ) + } + StartMatch(env, controller, route) + YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.KLONDIKE -> { + // Dealing again rebuilds the controller, for the same reason peg + // solitaire does: a blocked deal is genuinely finished, so + // driveIdleSeats has already stopped and advanceIdle never runs. + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = KlondikeRules, + // Only ever asked for a hint; the seat is the player's. + ai = KlondikeAi, + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + KlondikeScreen( + controller = controller, + onRestart = { generation++ }, + onExit = onExit, ) } - StartMatch(env, controller, route) - WizardScreen( - controller = controller, - localSeat = route.localSeat, - trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS), - onExit = onExit, - ) - } - GameCatalog.CRAZY_EIGHTS -> { - val controller = remember(route) { - MatchController( - rules = CrazyEightsRules, - ai = CrazyEightsAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - advanceIdle = { state -> - if (state.phase == CrazyEightsPhase.ROUND_OVER) { - CrazyEightsRules.nextRound(state) - } else { - null - } - }, - awaitsConfirmation = { state -> - state.phase == CrazyEightsPhase.ROUND_OVER - }, - aiThinkingMillis = { settings.scaled(700L) }, + GameCatalog.FREECELL -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = FreeCellRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + FreeCellScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.SPIDER -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = SpiderRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + SpiderScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.PYRAMID -> { + var generation by remember(route) { mutableIntStateOf(0) } + val controller = remember(route, generation) { + MatchController( + rules = PyramidRules, + ai = FirstLegalAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + ) + } + StartMatch(env, controller, route) + PyramidScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) + } + + GameCatalog.MORRIS -> { + val controller = remember(route) { + MatchController( + rules = MorrisRules, + // As with chess, the strength is part of the table's own + // options, so this does not have to decode anything. + ai = MorrisAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // The search is the thinking time; anything added on top of + // it would only make the computer look slower than it is. + aiThinkingMillis = { settings.scaled(250L) }, + ) + } + StartMatch(env, controller, route) + MorrisScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.CHESS -> { + val controller = remember(route) { + MatchController( + rules = ChessRules, + // Strength comes from the table's own options, so the setup + // screen sets it without this having to decode anything. + ai = ChessAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // The search is the thinking time; adding more on top of it + // would only make the computer look slower than it is. + aiThinkingMillis = { settings.scaled(250L) }, + ) + } + StartMatch(env, controller, route) + ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.CATAN -> { + val controller = remember(route) { + MatchController( + rules = CatanRules, + ai = CatanAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // A computer's turn is several small moves (roll, build, + // trade, end), so the pause is per move, not per turn. + aiThinkingMillis = { settings.scaled(450L) }, + ) + } + StartMatch(env, controller, route) + CatanScreen( + controller = controller, + localSeat = route.localSeat, + seatNames = route.config.seats.sortedBy { it.seat }.map { it.name }, + onExit = onExit, ) } - StartMatch(env, controller, route) - CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - GameCatalog.CRIBBAGE -> { - val controller = remember(route) { - MatchController( - rules = CribbageRules, - ai = CribbageAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - advanceIdle = { state -> - if (state.phase == CribbagePhase.SHOW) { - CribbageRules.nextHand(state) - } else { - null - } - }, - // The show is the half of cribbage that is read rather than - // played, so the table stops on it until somebody says go on. - awaitsConfirmation = { state -> state.phase == CribbagePhase.SHOW }, - aiThinkingMillis = { settings.scaled(700L) }, - ) - } - StartMatch(env, controller, route) - CribbageScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + else -> Text("That game isn't playable yet.") } - - GameCatalog.TAYU -> { - val controller = remember(route) { - MatchController( - rules = TayuRules, - // As with chess, the strength is part of the table's own - // options, so this does not have to decode anything. - ai = TayuAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - aiThinkingMillis = { settings.scaled(700L) }, - ) - } - StartMatch(env, controller, route) - TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.CHECKERS -> { - val controller = remember(route) { - MatchController( - rules = CheckersRules, - ai = CheckersAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - aiThinkingMillis = { settings.scaled(250L) }, - ) - } - StartMatch(env, controller, route) - CheckersScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.BACKGAMMON -> { - val controller = remember(route) { - MatchController( - rules = BackgammonRules, - ai = BackgammonAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - // A checker at a time, so the pause is per checker rather - // than per turn — half of it, or a double would take an age. - aiThinkingMillis = { settings.scaled(350L) }, - ) - } - StartMatch(env, controller, route) - BackgammonScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.MASTERMIND -> { - val controller = remember(route) { - MatchController( - rules = MastermindRules, - ai = MastermindAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - aiThinkingMillis = { settings.scaled(900L) }, - ) - } - StartMatch(env, controller, route) - MastermindScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.PIRATES -> { - val controller = remember(route) { - MatchController( - rules = PiratesRules, - ai = PiratesAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - aiThinkingMillis = { settings.scaled(250L) }, - ) - } - StartMatch(env, controller, route) - PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.HEARTS -> { - val controller = remember(route) { - MatchController( - rules = HeartsRules, - ai = HeartsAi, - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - advanceIdle = { state -> - if (state.phase == HeartsPhase.ROUND_OVER) { - HeartsRules.startNextRound(state) - } else { - null - } - }, - // What a round cost is read before the next deal. - awaitsConfirmation = { state -> state.phase == HeartsPhase.ROUND_OVER }, - aiThinkingMillis = { settings.scaled(700L) }, - holdBeforeNextMove = { state -> - if (state.completedTrick.isNotEmpty()) { - settings.scaled(TRICK_HOLD_MILLIS) - } else { - 0L - } - }, - ) - } - StartMatch(env, controller, route) - HeartsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.PEG_SOLITAIRE -> { - // Playing again means a fresh board rather than a fresh round, and - // it cannot go through advanceIdle: driveIdleSeats returns as soon - // as rules.isFinished is true, and a peg board with no jumps left - // genuinely is finished. Rebuilding the controller is the honest - // way to deal again, and the board is deterministic, so the retry - // is the same puzzle. - var generation by remember(route) { mutableIntStateOf(0) } - val controller = remember(route, generation) { - MatchController( - rules = PegSolitaireRules, - // Never consulted while a person holds the only seat. - ai = PegSolitaireAi, - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - ) - } - StartMatch(env, controller, route) - PegSolitaireScreen( - controller = controller, - onRestart = { generation++ }, - onExit = onExit, - ) - } - - GameCatalog.YAHTZEE -> { - val controller = remember(route) { - MatchController( - rules = YahtzeeRules, - ai = YahtzeeAi, - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - // Long enough to watch the dice land, short enough that a - // six-handed game is not an evening of waiting. - aiThinkingMillis = { settings.scaled(500L) }, - ) - } - StartMatch(env, controller, route) - YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.KLONDIKE -> { - // Dealing again rebuilds the controller, for the same reason peg - // solitaire does: a blocked deal is genuinely finished, so - // driveIdleSeats has already stopped and advanceIdle never runs. - var generation by remember(route) { mutableIntStateOf(0) } - val controller = remember(route, generation) { - MatchController( - rules = KlondikeRules, - // Only ever asked for a hint; the seat is the player's. - ai = KlondikeAi, - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - ) - } - StartMatch(env, controller, route) - KlondikeScreen( - controller = controller, - onRestart = { generation++ }, - onExit = onExit, - ) - } - - GameCatalog.FREECELL -> { - var generation by remember(route) { mutableIntStateOf(0) } - val controller = remember(route, generation) { - MatchController( - rules = FreeCellRules, - ai = FirstLegalAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - ) - } - StartMatch(env, controller, route) - FreeCellScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) - } - - GameCatalog.SPIDER -> { - var generation by remember(route) { mutableIntStateOf(0) } - val controller = remember(route, generation) { - MatchController( - rules = SpiderRules, - ai = FirstLegalAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - ) - } - StartMatch(env, controller, route) - SpiderScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) - } - - GameCatalog.PYRAMID -> { - var generation by remember(route) { mutableIntStateOf(0) } - val controller = remember(route, generation) { - MatchController( - rules = PyramidRules, - ai = FirstLegalAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - ) - } - StartMatch(env, controller, route) - PyramidScreen(controller = controller, onRestart = { generation++ }, onExit = onExit) - } - - GameCatalog.MORRIS -> { - val controller = remember(route) { - MatchController( - rules = MorrisRules, - // As with chess, the strength is part of the table's own - // options, so this does not have to decode anything. - ai = MorrisAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - // The search is the thinking time; anything added on top of - // it would only make the computer look slower than it is. - aiThinkingMillis = { settings.scaled(250L) }, - ) - } - StartMatch(env, controller, route) - MorrisScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.CHESS -> { - val controller = remember(route) { - MatchController( - rules = ChessRules, - // Strength comes from the table's own options, so the setup - // screen sets it without this having to decode anything. - ai = ChessAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - // The search is the thinking time; adding more on top of it - // would only make the computer look slower than it is. - aiThinkingMillis = { settings.scaled(250L) }, - ) - } - StartMatch(env, controller, route) - ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) - } - - GameCatalog.CATAN -> { - val controller = remember(route) { - MatchController( - rules = CatanRules, - ai = CatanAi(), - config = route.config, - scope = scope, - role = role, - localSeats = setOf(route.localSeat), - primarySeat = route.localSeat, - // A computer's turn is several small moves (roll, build, - // trade, end), so the pause is per move, not per turn. - aiThinkingMillis = { settings.scaled(450L) }, - ) - } - StartMatch(env, controller, route) - CatanScreen( - controller = controller, - localSeat = route.localSeat, - seatNames = route.config.seats.sortedBy { it.seat }.map { it.name }, - onExit = onExit, - ) - } - - else -> Text("That game isn't playable yet.") } } @@ -707,8 +750,38 @@ private fun StartMatch( controller: MatchController, route: Route.Play, ) { + val scope = rememberCoroutineScope() + val endHandle = LocalEndGameHandle.current + + // A game against the computer (or on one device) is kept as it is played, so + // it can be picked up after the app has been closed. A table with another + // device at it is not: the other player's seat cannot be held for them. + val saveable = route.hosting && route.config.seats.none { it.kind == PlayerKind.HUMAN_REMOTE } + val saver = remember(controller) { + if (saveable) { + MatchSaver(env.saves, route.gameId, route.config, route.localSeat, scope) + } else { + null + } + } + // The position to carry on from belongs to the first table only. Dealing + // again (solitaire's "new deal") builds a fresh controller under the same + // route, and that one must not be put back to where the last one stood. + val resume = remember(route) { mutableStateOf(route.resumeState) } + LaunchedEffect(controller) { if (route.hosting) { + resume.value?.let { saved -> + resume.value = null + // A save this version cannot read — one from an older build — is + // not worth keeping; the table is dealt afresh instead. + if (!controller.restore(saved)) env.saves.clear(route.gameId) + } + if (saver != null) { + saver.start() + controller.onHostState = saver::offer + endHandle.onEnd = saver::discard + } controller.startAsHost(env.lobby.hostConnections) } else { env.lobby.clientConnection?.let { controller.startAsClient(it) } @@ -721,6 +794,10 @@ private fun StartMatch( env.lobby.rebinder = controller onDispose { env.lobby.rebinder = null + // Whatever the writer has not caught up with is written before the + // table goes, so leaving mid-move does not lose the last move. + saver?.flush() + saver?.stop() controller.close() } } @@ -734,11 +811,14 @@ private fun StartMatch( // sent. So the table is asked for again on the way back in, over a fresh // link if the old one did not survive. val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, route.hosting) { + DisposableEffect(lifecycleOwner, route.hosting, saver) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START && !route.hosting) { env.lobby.restoreLink() } + // The app going to the background may be the last the process hears: + // Android can end it without a word, so the position is written now. + if (event == Lifecycle.Event.ON_STOP) saver?.flush() } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } diff --git a/app/src/main/java/org/prolibertate/games/ui/SavedGameText.kt b/app/src/main/java/org/prolibertate/games/ui/SavedGameText.kt new file mode 100644 index 0000000..b4e0e8f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/SavedGameText.kt @@ -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)}" diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt index e4b40e3..1757024 100644 --- a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -114,6 +115,8 @@ fun ScorekeeperScreen( local = repository.sheet.first() loaded = true } + val recentNames by repository.recentNames.collectAsState(initial = emptyList()) + val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } } val shared by session.state.collectAsState() val role = shared.role @@ -220,7 +223,7 @@ fun ScorekeeperScreen( role == Role.GUEST -> Column(modifier = modifier) { ConnectionBanner(shared = shared, onSync = { session.sync() }) - Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change) + Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed) } joining && role == Role.NONE -> @@ -228,11 +231,16 @@ fun ScorekeeperScreen( !sheet.started -> SetupPane( modifier = modifier, - onStart = { count, mode -> change(StartSheet(count, mode)) }, + recentNames = recentNames, + onStart = { count, mode, names -> + change(StartSheet(count, mode, names)) + // Last first, so the list reads in seating order. + names.reversed().forEach(nameUsed) + }, onJoin = { joining = true }, ) - else -> Scoring(modifier = modifier, sheet = sheet, onOp = change) + else -> Scoring(modifier, sheet, change, recentNames, nameUsed) } } @@ -289,9 +297,46 @@ fun ScorekeeperScreen( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) { +private fun SetupPane( + modifier: Modifier, + recentNames: List, + onStart: (Int, ScoringMode, List) -> Unit, + onJoin: () -> Unit, +) { var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) } var mode by remember { mutableStateOf(ScoringMode.ROUNDS) } + // -1 while the options are showing; otherwise the seat being named. + var naming by remember { mutableStateOf(-1) } + val names = remember { mutableStateListOf() } + + if (naming >= 0) { + NamePane( + modifier = modifier, + seat = naming, + count = count, + name = names.getOrElse(naming) { "" }, + // Offered once only: two players on one sheet with the same name + // are indistinguishable. + suggestions = recentNames.filter { recent -> + names.withIndex().none { (seat, taken) -> + seat != naming && taken.trim().equals(recent.trim(), ignoreCase = true) + } + }, + onName = { typed -> + while (names.size <= naming) names.add("") + names[naming] = typed + }, + onBack = { naming -= 1 }, + onNext = { + if (naming + 1 < count) { + naming += 1 + } else { + onStart(count, mode, List(count) { names.getOrElse(it) { "" }.trim() }) + } + }, + ) + return + } Column( modifier = modifier.verticalScroll(rememberScrollState()), @@ -339,33 +384,98 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o } } Text( - text = "Names come next: tap a column head to name it. Players can be " + - "added, removed or dragged into a different order at any point " + - "without losing anybody's score.", + text = "You will be asked for each name next, or skip them and tap a " + + "column head later. Players can be added, removed or dragged into " + + "a different order at any point without losing anybody's score.", style = MaterialTheme.typography.bodySmall, ) - PrimaryAction(text = "Start scoring") { onStart(count, mode) } + PrimaryAction(text = "Next: name the players") { naming = 0 } OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) { Text("Join a shared sheet nearby") } } } +/** One seat's name, asked for on its own; a blank leaves the seat as "Player N". */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun NamePane( + modifier: Modifier, + seat: Int, + count: Int, + name: String, + suggestions: List, + onName: (String) -> Unit, + onBack: () -> Unit, + onNext: () -> Unit, +) { + val focus = remember { FocusRequester() } + LaunchedEffect(seat) { focus.requestFocus() } + val last = seat + 1 >= count + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Player ${seat + 1} of $count", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = name, + onValueChange = onName, + placeholder = { Text("Player ${seat + 1}") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = if (last) ImeAction.Done else ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + if (suggestions.isNotEmpty()) { + Text("Recent names", style = MaterialTheme.typography.labelMedium) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + suggestions.forEach { recent -> + FilterChip( + selected = recent.equals(name.trim(), ignoreCase = true), + onClick = { onName(recent) }, + label = { Text(recent) }, + ) + } + } + } + PrimaryAction(text = if (last) "Start scoring" else "Next") { onNext() } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onBack) { Text("Back") } + TextButton(onClick = { onName(""); onNext() }) { Text(if (last) "Skip and start" else "Skip") } + } + } +} + // --------------------------------------------------------------------------- // The sheet: a column per player, a row per round, totals ruled off at the foot // --------------------------------------------------------------------------- @Composable -private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { +private fun Scoring( + modifier: Modifier, + sheet: ScoreSheet, + onOp: (SheetOp) -> Unit, + recentNames: List, + onNameUsed: (String) -> Unit, +) { if (sheet.mode == ScoringMode.TALLY) { - TallySheet(modifier = modifier, sheet = sheet, onOp = onOp) + TallySheet(modifier, sheet, onOp, recentNames, onNameUsed) } else { - Sheet(modifier = modifier, sheet = sheet, onOp = onOp) + Sheet(modifier, sheet, onOp, recentNames, onNameUsed) } } @Composable -private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { +private fun Sheet( + modifier: Modifier, + sheet: ScoreSheet, + onOp: (SheetOp) -> Unit, + recentNames: List, + onNameUsed: (String) -> Unit, +) { // One scroll state shared by the head, the rows and the totals, so all three // move together: a column and the name over it must never come apart. Every // row is built the same way and is therefore exactly the same width, which @@ -384,6 +494,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit var lastTyped by remember { mutableStateOf(-1) } // The cell to put the cursor in when a round is opened by tapping it. var focusPlayer by remember { mutableStateOf(-1) } + // Bumped when a round is finished, to put the cursor back in the first cell + // of the row that is now being filled in. + var nextRoundFocus by remember { mutableStateOf(0) } val editing = if (correcting >= 0) correction else pending @@ -513,7 +626,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit ) }, trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) }, - ) { _, player -> + ) { index, player -> if (correcting >= 0) { // A round is being corrected above; what is typed here // is kept, but only one row is edited at a time. @@ -521,6 +634,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit } else { PointsField( text = pending[player.id].orEmpty(), + autoFocus = index == 0 && nextRoundFocus > 0, + focusRequest = nextRoundFocus, onText = { pending[player.id] = it lastTyped = player.id @@ -563,6 +678,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit onOp(AddRound(amountsAsPoints(pending))) pending.clear() } + nextRoundFocus += 1 lastTyped = -1 focusPlayer = -1 }, @@ -587,6 +703,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit RenameDialog( sheet = sheet, player = renamed, + recentNames = recentNames, + onNameUsed = onNameUsed, onName = { onOp(RenamePlayer(renamed.id, it)) }, onRemove = { onOp(RemovePlayer(renamed.id)) @@ -607,7 +725,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit * sheet, so tapping one in the list takes it back off. */ @Composable -private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { +private fun TallySheet( + modifier: Modifier, + sheet: ScoreSheet, + onOp: (SheetOp) -> Unit, + recentNames: List, + onNameUsed: (String) -> Unit, +) { var renaming by remember { mutableStateOf(-1) } Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -676,6 +800,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> RenameDialog( sheet = sheet, player = renamed, + recentNames = recentNames, + onNameUsed = onNameUsed, onName = { onOp(RenamePlayer(renamed.id, it)) }, onRemove = { onOp(RemovePlayer(renamed.id)) @@ -961,9 +1087,14 @@ private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Uni /** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */ @Composable -private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) { +private fun PointsField( + text: String, + onText: (String) -> Unit, + autoFocus: Boolean = false, + focusRequest: Int = 0, +) { val focus = remember { FocusRequester() } - LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() } + LaunchedEffect(autoFocus, focusRequest) { if (autoFocus) focus.requestFocus() } BasicTextField( value = text, onValueChange = { onText(sanitiseAmount(it)) }, @@ -1064,16 +1195,27 @@ private fun ActionBar( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun RenameDialog( sheet: ScoreSheet, player: ScorePlayer, + recentNames: List, + onNameUsed: (String) -> Unit, onName: (String) -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit, ) { + // Closing the dialog is what files the name away, so a name is remembered + // once rather than once per keystroke. + val finish = { + onNameUsed(player.name) + onDismiss() + } + val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() } + val suggestions = recentNames.filter { it.trim().lowercase() !in taken } AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = finish, title = { Text("Name this column") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -1084,6 +1226,21 @@ private fun RenameDialog( singleLine = true, modifier = Modifier.fillMaxWidth(), ) + if (suggestions.isNotEmpty()) { + Text("Recent names", style = MaterialTheme.typography.labelMedium) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + suggestions.forEach { name -> + FilterChip( + selected = name.equals(player.name.trim(), ignoreCase = true), + onClick = { onName(name) }, + label = { Text(name) }, + ) + } + } + } TextButton( onClick = onRemove, // The last two columns stay: a sheet needs somebody on it. @@ -1091,7 +1248,7 @@ private fun RenameDialog( ) { Text("Remove this player and their score") } } }, - confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + confirmButton = { TextButton(onClick = finish) { Text("Done") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/BackgammonScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/BackgammonScreen.kt index 4b79af0..f86085d 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/BackgammonScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/BackgammonScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt index b296437..e0e0443 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/CardTable.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/CheckersScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/CheckersScreen.kt index e389222..a1eebbb 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/CheckersScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/CheckersScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt index 16d8a80..aef0e54 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt index 53bbcb2..d7c7c6e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/HeartsScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt index af7920b..f8a7e09 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/KlondikeScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt index d7c05d8..30e3086 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt new file mode 100644 index 0000000..6fcb32a --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt @@ -0,0 +1,379 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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 +import kotlin.math.abs +import kotlin.math.roundToInt + +private const val TITLE = "Meowdoku" + +/** + * How long the second tap of a double tap may take to arrive. + * + * 350 ms, the number the original game ships: long enough that a deliberate + * one-two still reads as one question, short enough that nobody pairs two + * taps they meant for two separate squares. + */ +private const val DOUBLE_TAP_WINDOW_MILLIS = 350L + +/** + * The region colours, one per cat the board can hold. + * + * 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 +) + +/** + * The puzzle board, tapped the way the original's is. + * + * One tap crosses a square over; a second tap on the same square within + * [DOUBLE_TAP_WINDOW_MILLIS] asks the board whether a cat lives there. Drag + * strokes paint or erase crosses along the line the finger takes, which is + * how a row gets crossed out in one gesture rather than eight taps. Cats are + * permanent: tapping one does nothing, and a stroke skips over it. + */ +@Composable +fun MeowdokuScreen( + controller: MatchController, + onNewPuzzle: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + 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().map { it.square }.toSet() + val pencils = legal.filterIsInstance().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." + else -> "${current.catsFound} of ${current.side} cats found. " + + "Tap to cross a square, tap it again for a cat." + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Hearts " + "♥".repeat(current.heartsLeft) + + " · Hints " + current.hintsLeft, + style = MaterialTheme.typography.bodySmall, + ) + + Board( + state = current, + // Moves are refused rather than hidden: an attempt or a note + // the board would reject (a tap on a cat, mostly) has to die + // here, in the gesture, where the player can be doing nothing + // else the same instant. + onNote = { square -> if (square in pencils) controller.submit(TogglePencil(square)) }, + onAttempt = { square -> if (square in places) controller.submit(PlaceCat(square)) }, + ) + + 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 } + } +} + +/** + * The board as one gesture surface. + * + * Cells are drawn by weight inside a square frame, but input is not per-cell: + * a stroke is one continuous gesture, so the pointer works in board + * coordinates and maps positions to squares itself. The cell list renders + * underneath, and the live state is read through [rememberUpdatedState] + * because a gesture outlives the recompositions it lands in. + */ +@Composable +private fun Board( + state: MeowdokuState, + onNote: (Square) -> Unit, + onAttempt: (Square) -> Unit, +) { + val side = state.side + val current by rememberUpdatedState(state) + val note by rememberUpdatedState(onNote) + val attempt by rememberUpdatedState(onAttempt) + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + // One cell's edge, so a cat is always about half a square whatever + // board was dealt. + val cell = maxWidth / side + Box( + modifier = Modifier + .aspectRatio(1f) + .pointerInput(side) { + val cellPx = size.width.toFloat() / side + var lastTap: Square? = null + var lastTapAt = 0L + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val board = current + val start = squareAt(down.position, cellPx, side) + ?: return@awaitEachGesture + if (start in board.cats) return@awaitEachGesture + + val now = System.currentTimeMillis() + if (start == lastTap && now - lastTapAt < DOUBLE_TAP_WINDOW_MILLIS) { + // The second tap of a pair: the cat attempt, and + // no note — the cross it lands on was drawn by + // this pair's first tap, and the board replaces it. + lastTap = null + attempt(start) + do { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + change?.consume() + } while (change != null && change.pressed) + return@awaitEachGesture + } + + // The first tap: a note, and the stroke that may follow + // paints the same note everywhere it goes. + lastTap = start + lastTapAt = now + val erasing = start in board.marks + note(start) + val marks = board.marks.toMutableSet() + marks.flip(start) + var previous = start + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null || !change.pressed) break + change.consume() + val here = squareAt(change.position, cellPx, side) ?: continue + if (here == previous) continue + for (square in path(previous, here)) { + if (square in board.cats) continue + // `marks` is the stroke's own mirror: the base + // board plus every note this stroke has + // already submitted. The state flow lags a + // frame behind submissions, and a stroke that + // crosses a square twice must leave it at the + // stroke's target, not flip it back. + // + // Erasing means "flip everything still + // crossed"; painting means "flip everything + // still blank" — which is the same test read + // two ways: flip while marked == erasing. + if ((square in marks) == erasing) { + note(square) + marks.flip(square) + } + } + previous = here + } + } + } + ) { + Column(modifier = Modifier.aspectRatio(1f)) { + for (row in 0 until side) { + Row(modifier = Modifier.weight(1f)) { + for (col in 0 until side) { + CellView( + square = Square(row, col), + state = current, + glyphSize = (cell.value * 0.55f).sp, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } +} + +/** Which square a pointer position is over, or null if it has left the board. */ +private fun squareAt(position: Offset, cellPx: Float, side: Int): Square? { + val col = (position.x / cellPx).toInt() + val row = (position.y / cellPx).toInt() + if (row !in 0 until side || col !in 0 until side) return null + return Square(row, col) +} + +/** Add the square, or drop it if it was already there — the stroke's own toggle. */ +private fun MutableSet.flip(square: Square) { + if (!add(square)) remove(square) +} + +/** + * The squares between two consecutive pointer reads, endpoints not repeated. + * + * A fast stroke publishes fewer positions than it crosses cells, so the line + * between two reads is walked in whole-cell steps. [path] returns at most one + * square per cell crossed, in order, which is what lets a stroke cross a row + * in one drag without missing the squares between two events. + */ +private fun path(from: Square, to: Square): List { + val dr = to.row - from.row + val dc = to.col - from.col + val steps = maxOf(abs(dr), abs(dc)) + if (steps <= 1) return listOf(to) + return (1..steps).map { i -> + Square( + from.row + (dr * i.toFloat() / steps).roundToInt(), + from.col + (dc * i.toFloat() / steps).roundToInt(), + ) + } +} + +@Composable +private fun CellView( + square: Square, + state: MeowdokuState, + glyphSize: TextUnit, + modifier: Modifier, +) { + 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 + ), + 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") } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MorrisScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MorrisScreen.kt index 03b979c..ee8db50 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/MorrisScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/MorrisScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt index bb27414..b2dfc7e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/PegSolitaireScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/PiratesScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/PiratesScreen.kt index 4056c47..78ad3ed 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/PiratesScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/PiratesScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/TayuScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/TayuScreen.kt index 6499537..84e363b 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/TayuScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/TayuScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt index 7c0e401..61e4caf 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/YahtzeeScreen.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt index b9f4c78..d04693a 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -61,6 +61,11 @@ fun CatanBoardView( rolledHexes: Set = emptySet(), /** Cities & Knights: the knight whose menu is open or that is about to move. */ selectedKnight: Int? = null, + /** A placement lined up but not yet confirmed (or being dragged over): drawn as a solid ghost. */ + stagedVertex: Int? = null, + stagedEdge: Int? = null, + /** The seat that will own the staged piece, for its colour. */ + stagedSeat: Int? = null, ) { val measurer = rememberTextMeasurer() var size by remember { mutableStateOf(IntSize.Zero) } @@ -118,6 +123,7 @@ fun CatanBoardView( drawKnightTargets(targets, board, style) drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style) drawHexTargets(targets, board, style) + drawStaged(stagedVertex, stagedEdge, stagedSeat, board, style) } } @@ -642,3 +648,21 @@ private fun DrawScope.drawCentered( topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f), ) } + +/** The ghost of a placement waiting for ✓: a solid piece in the player's colour with a bright ring. */ +private fun DrawScope.drawStaged(vertex: Int?, edge: Int?, seat: Int?, board: BoardLayout, style: CatanStyle) { + val colour = seat?.let { style.seats.getOrNull(it) } ?: style.target + if (edge != null) { + val (a, b) = board.edgeEnds(edge) + val from = lerp(a, b, 0.1f) + val to = lerp(a, b, 0.9f) + drawLine(style.targetSelected, from.offset(), to.offset(), board.radius * style.roadWidth * 2.1f, StrokeCap.Round) + drawLine(colour, from.offset(), to.offset(), board.radius * style.roadWidth * 1.3f, StrokeCap.Round) + } + if (vertex != null) { + val at = board.vertex(vertex).offset() + val radius = board.radius * style.targetRadius + drawCircle(colour, radius * 1.3f, at) + drawCircle(style.targetSelected, radius * 1.9f, at, style = Stroke(width = board.radius * 0.1f)) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt index 56868b5..552a332 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt @@ -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") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt new file mode 100644 index 0000000..a8180ad --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt @@ -0,0 +1,458 @@ +package org.prolibertate.games.ui.game.catan + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/* + * The heads-up display pieces of the landscape Katan screen: the controls the + * Catan Universe match screen uses (see docs/RE-catan-universe.md), redrawn in + * 2D. Stateless, like everything in CatanPanels.kt: values in, lambdas out. + */ + +/** A round HUD button: a glyph or short word, optionally with a one-word caption. */ +@Composable +fun HudRoundButton( + glyph: String, + modifier: Modifier = Modifier, + caption: String? = null, + enabled: Boolean = true, + primary: Boolean = false, + tint: Color = Color.Unspecified, + diameter: Dp = 56.dp, + onClick: () -> Unit, +) { + val container = when { + tint != Color.Unspecified -> tint + primary -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.secondaryContainer + } + val ink = if (primary || tint != Color.Unspecified) Color.White else MaterialTheme.colorScheme.onSecondaryContainer + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(diameter) + .clip(CircleShape) + .background(container.copy(alpha = if (enabled) 1f else 0.35f)) + .border(2.dp, Color(0x55000000), CircleShape) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier), + contentAlignment = Alignment.Center, + ) { + Text(glyph, color = ink.copy(alpha = if (enabled) 1f else 0.6f), fontSize = 18.sp, fontWeight = FontWeight.Bold) + } + // Always laid out, so the button column never changes height. + Text(caption ?: " ", fontSize = 10.sp, maxLines = 1) + } +} + +/** + * The left-hand build column: one icon per piece. **Drag one onto the board** and + * release over a lit spot to stage it; or tap to arm the board and tap a spot. The + * column reports pointer positions in window coordinates so the screen can find the + * spot under the finger whatever the board's zoom and pan. + */ +@Composable +fun HudBuildColumn( + kinds: List, + canBuild: Set, + armed: BuildKind?, + modifier: Modifier = Modifier, + onTap: (BuildKind) -> Unit, + onDragStart: (BuildKind, Offset) -> Unit, + onDrag: (Offset) -> Unit, + onDragEnd: () -> Unit, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(3.dp), horizontalAlignment = Alignment.CenterHorizontally) { + kinds.forEach { kind -> + BuildChip(kind, enabled = kind in canBuild, armed = armed == kind, onTap, onDragStart, onDrag, onDragEnd) + } + } +} + +@Composable +private fun BuildChip( + kind: BuildKind, + enabled: Boolean, + armed: Boolean, + onTap: (BuildKind) -> Unit, + onDragStart: (BuildKind, Offset) -> Unit, + onDrag: (Offset) -> Unit, + onDragEnd: () -> Unit, +) { + var coords by remember { mutableStateOf(null) } + val start by rememberUpdatedState(onDragStart) + val move by rememberUpdatedState(onDrag) + val end by rememberUpdatedState(onDragEnd) + val tap by rememberUpdatedState(onTap) + val shape = RoundedCornerShape(12.dp) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(46.dp) + .clip(shape) + .background( + when { + armed -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = if (enabled) 1f else 0.35f) + }, + ) + .border(2.dp, Color(0x55000000), shape) + .onGloballyPositioned { coords = it } + .pointerInput(enabled, kind) { + if (!enabled) return@pointerInput + detectDragGestures( + onDragStart = { local -> + coords?.localToWindow(local)?.let { start(kind, it) } + }, + onDrag = { change, _ -> + change.consume() + coords?.localToWindow(change.position)?.let { move(it) } + }, + onDragEnd = { end() }, + onDragCancel = { end() }, + ) + } + .then(if (enabled) Modifier.clickable { tap(kind) } else Modifier), + contentAlignment = Alignment.Center, + ) { + Text( + CatanHudGlyphs.of(kind), + fontSize = 22.sp, + color = if (armed) Color.White else MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Text(CatanHudGlyphs.label(kind), fontSize = 10.sp, maxLines = 1) + } +} + +/** Glyphs and captions of the build column. Kept apart so the art can be swapped for drawables. */ +object CatanHudGlyphs { + fun of(kind: BuildKind): String = when (kind) { + BuildKind.ROAD -> "━" + BuildKind.SETTLEMENT -> "⌂" + BuildKind.CITY -> "▲" + BuildKind.WALL -> "▦" + BuildKind.KNIGHT -> "♞" + } + + fun label(kind: BuildKind): String = when (kind) { + BuildKind.ROAD -> CatanText.ROAD + BuildKind.SETTLEMENT -> CatanText.SETTLEMENT + BuildKind.CITY -> CatanText.CITY + BuildKind.WALL -> CatanText.WALL + BuildKind.KNIGHT -> CatanText.KNIGHT + } +} + +/** ✓ beside ✗: confirm or cancel a staged placement. Always laid out; enabled only while something is staged. */ +@Composable +fun HudConfirmButtons( + staged: Boolean, + modifier: Modifier = Modifier, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.Bottom) { + HudRoundButton("✓", caption = CatanText.CONFIRM_BUILD, enabled = staged, tint = Color(0xFF2E7D32), diameter = 48.dp, onClick = onConfirm) + HudRoundButton("✕", caption = CatanText.CANCEL_BUILD, enabled = staged, tint = Color(0xFFC62828), diameter = 48.dp, onClick = onCancel) + } +} + +/** + * Pan with one finger, pinch to zoom, like the Universe board camera. The content + * is drawn through a graphics layer, so taps inside it arrive already in the + * content's own coordinates and the board's hit-testing needs no changes. + */ +@Composable +fun ZoomPanBox( + modifier: Modifier = Modifier, + minScale: Float = 1f, + maxScale: Float = 3f, + content: @Composable () -> Unit, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + Box( + modifier = modifier + .clip(RoundedCornerShape(0.dp)) + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + val newScale = (scale * zoom).coerceIn(minScale, maxScale) + val centre = Offset(size.width / 2f, size.height / 2f) + // The content point under the fingers stays under them while zooming; then pan. + val world = (centroid - centre - offset) / scale + val next = centroid - centre - world * newScale + pan + val maxX = size.width * (newScale - 1f) / 2f + val maxY = size.height * (newScale - 1f) / 2f + offset = Offset(next.x.coerceIn(-maxX, maxX), next.y.coerceIn(-maxY, maxY)) + scale = newScale + } + }, + ) { + Box( + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + }, + contentAlignment = Alignment.Center, + ) { content() } + } +} + +/** A small caption pill, for the drag hint and the staged-move text. */ +@Composable +fun HudPill(text: String, modifier: Modifier = Modifier) { + Text( + text, + color = Color.White, + fontSize = 12.sp, + maxLines = 1, + modifier = modifier + .clip(RoundedCornerShape(50)) + .background(Color(0xCC000000)) + .padding(horizontal = 12.dp, vertical = 4.dp), + ) +} + +/** + * The landscape screen, laid out like the Catan Universe match view: + * + * ``` + * ┌ player strip ───────────────────────────────────────────────┐ + * │ prompt + dice │ + * │ [build] the island (pinch / pan) [✓] [✕] │ + * │ [column] │ + * │ [log] ┌ your cards ┐ [buy][play] [⇄][●]│ + * └─────────────┴────────────┴───────────────────────────────────┘ + * ``` + * + * Everything here is wiring around the existing panels; the island is handed in as [board]. + */ +@Composable +fun CatanHud( + state: org.prolibertate.games.game.catan.CatanState, + seats: CatanSeats, + style: CatanStyle, + actions: CatanActions, + prompt: String, + notice: String?, + legal: List, + localSeat: Int, + targets: BoardTargets, + pointsOf: (Int) -> Int, + staged: org.prolibertate.games.game.catan.CatanMove?, + dragKind: BuildKind?, + dragAt: Offset?, + showLog: Boolean, + boardCoords: LayoutCoordinates?, + building: BuildKind?, + onBoardCoords: (LayoutCoordinates) -> Unit, + onToggleLog: () -> Unit, + onArm: (BuildKind) -> Unit, + onDragStart: (BuildKind, Offset) -> Unit, + onDrag: (Offset) -> Unit, + onDragEnd: () -> Unit, + onConfirm: () -> Unit, + onCancelStaged: () -> Unit, + onRoll: () -> Unit, + onEndTurn: () -> Unit, + onEndFreeRoads: () -> Unit, + onEndSmith: () -> Unit, + onRepickSetup: () -> Unit, + onBuyCard: () -> Unit, + onPlayCard: () -> Unit, + onTrade: () -> Unit, + onImprove: () -> Unit, + onProgress: () -> Unit, + board: @Composable (Modifier) -> Unit, +) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + HudPlayerStrip(state, seats, pointsOf, style) + Box(Modifier.weight(1f).fillMaxWidth()) { + // The island fills the whole area; controls float over its sea margin. + ZoomPanBox(Modifier.fillMaxSize().padding(start = 64.dp, end = 64.dp, top = 30.dp, bottom = 44.dp)) { + board(Modifier.onGloballyPositioned(onBoardCoords)) + } + + // What is going on, and the dice. Two lines, always. + Row( + Modifier.align(Alignment.TopCenter).padding(horizontal = 64.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)) + .padding(horizontal = 8.dp, vertical = 1.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = notice ?: prompt, + style = MaterialTheme.typography.bodySmall, + color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified, + minLines = 2, + maxLines = 2, + modifier = Modifier.weight(1f), + ) + CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie) + } + + // Left: pieces to drag onto the island. + HudBuildColumn( + kinds = actions.buildKinds, + canBuild = actions.canBuild, + armed = building, + modifier = Modifier.align(Alignment.CenterStart).padding(start = 6.dp), + onTap = onArm, + onDragStart = onDragStart, + onDrag = onDrag, + onDragEnd = onDragEnd, + ) + + // Right, middle: the card actions. + Column( + Modifier.align(Alignment.CenterEnd).padding(end = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (actions.ck) { + HudRoundButton("⬆", caption = CatanText.IMPROVE, enabled = actions.canImprove, diameter = 44.dp, onClick = onImprove) + HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayProgress, diameter = 44.dp, onClick = onProgress) + } else { + HudRoundButton("+", caption = CatanText.BUY_SHORT, enabled = actions.canBuyCard, diameter = 44.dp, onClick = onBuyCard) + HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayCard, diameter = 44.dp, onClick = onPlayCard) + } + } + + // Bottom centre: your cards. + Box( + Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp, start = 80.dp, end = 270.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)) + .horizontalScroll(rememberScrollState()) + .padding(6.dp), + ) { CatanHand(state, localSeat) } + + // Bottom left: the log. + Column(Modifier.align(Alignment.BottomStart).padding(start = 6.dp, bottom = 4.dp)) { + if (showLog) { + Surface( + modifier = Modifier.padding(bottom = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + tonalElevation = 4.dp, + ) { + CatanLog(state, Modifier.padding(8.dp).height(150.dp).width(260.dp)) + } + } + HudRoundButton("☰", caption = CatanText.LOG_BUTTON, diameter = 40.dp, onClick = onToggleLog) + } + + // Bottom right: place / cancel a lined-up piece, trade, and the turn button. + Row( + Modifier.align(Alignment.BottomEnd).padding(end = 6.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Bottom, + ) { + HudConfirmButtons(staged = staged != null, onConfirm = onConfirm, onCancel = onCancelStaged) + HudRoundButton("⇄", caption = CatanText.TRADE, enabled = actions.canTrade, diameter = 48.dp, onClick = onTrade) + PrimaryTurnButton(actions, onRoll, onEndTurn, onEndFreeRoads, onEndSmith, onRepickSetup) + } + + if (dragKind != null && dragAt == null) HudPill(CatanText.DRAG_HINT, Modifier.align(Alignment.TopCenter)) + } + } +} + +/** The one big button: roll, end the turn, finish roads, or step back from a setup choice. */ +@Composable +private fun PrimaryTurnButton( + actions: CatanActions, + onRoll: () -> Unit, + onEndTurn: () -> Unit, + onEndFreeRoads: () -> Unit, + onEndSmith: () -> Unit, + onRepickSetup: () -> Unit, +) { + val (glyph, caption, enabled, click) = when { + actions.canRoll -> Quad("🎲", CatanText.ROLL_SHORT, true, onRoll) + actions.canEndFreeRoads -> Quad("✔", CatanText.DONE_SHORT, true, onEndFreeRoads) + actions.canEndSmith -> Quad("✔", CatanText.DONE_SHORT, true, onEndSmith) + actions.canRepickSetup -> Quad("↺", CatanText.CANCEL, true, onRepickSetup) + else -> Quad("➜", CatanText.END_SHORT, actions.canEndTurn, onEndTurn) + } + HudRoundButton(glyph, caption = caption, enabled = enabled, primary = true, diameter = 72.dp, onClick = click) +} + +private data class Quad(val glyph: String, val caption: String, val enabled: Boolean, val click: () -> Unit) + +/** Everyone's colour, name, points and card count in one thin row across the top. */ +@Composable +private fun HudPlayerStrip( + state: org.prolibertate.games.game.catan.CatanState, + seats: CatanSeats, + pointsOf: (Int) -> Int, + style: CatanStyle, +) { + Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + (0 until state.playerCount).forEach { seat -> + val onTurn = seat == state.turn + val shape = RoundedCornerShape(10.dp) + Row( + Modifier.weight(1f) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(if (onTurn) 2.dp else 1.dp, if (onTurn) MaterialTheme.colorScheme.primary else Color(0x33000000), shape) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + Modifier.size(14.dp).clip(CircleShape).background(style.seats[seat]).border(1.dp, style.pieceOutline, CircleShape), + ) + Text(seats.label(seat), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, modifier = Modifier.weight(1f)) + Text("${pointsOf(seat)} VP · ${state.resourceCount(seat)}🂠", fontSize = 12.sp, maxLines = 1) + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt index 5054be0..7fcd113 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -142,6 +142,42 @@ data class DevCardInHand(val card: DevCard, val plays: List) { */ object CatanInteraction { + // --- Staged placements and drag-to-build ------------------------------------ + + /** A place on the board a dragged piece is over: one crossing or one edge. */ + data class Spot(val vertex: Int? = null, val edge: Int? = null) + + /** + * The spot a finger at [at] (board pixels) is nearest to, among [targets]. [reach] is + * generous (fingers cover what they point at), and edges are only offered when no + * crossing is lit, matching what a tap would do. + */ + fun snap(layout: BoardLayout, targets: BoardTargets, at: Pt, reach: Float): Spot? = when { + targets.tappableVertices.isNotEmpty() -> + layout.nearestVertex(at, reach, targets.tappableVertices)?.let { Spot(vertex = it) } + + targets.edges.isNotEmpty() -> layout.nearestEdge(at, reach, targets.edges)?.let { Spot(edge = it) } + else -> null + } + + /** The move a build tap or drop would make, if it is a placement that waits for ✓. */ + fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? { + if (building == null || outcome !is TapOutcome.Submit) return null + return outcome.move.takeIf { + it is BuildRoad || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight + } + } + + /** Where a staged move sits on the board, for drawing its ghost. */ + fun spotOf(move: CatanMove?): Spot = when (move) { + is BuildRoad -> Spot(edge = move.edge) + is BuildSettlement -> Spot(vertex = move.vertex) + is BuildCity -> Spot(vertex = move.vertex) + is BuildWall -> Spot(vertex = move.vertex) + is HireKnight -> Spot(vertex = move.vertex) + else -> Spot() + } + // --- Board --------------------------------------------------------------- fun targets( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 5eb3685..d449965 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -9,6 +9,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.background +import androidx.compose.material3.Surface +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntSize +import org.prolibertate.games.game.catan.BuildRoad import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -22,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -104,6 +114,18 @@ fun CatanScreen( var armed by remember { mutableStateOf(null) } var showImprove by remember { mutableStateOf(false) } var showProgress by remember { mutableStateOf(false) } + // The landscape HUD: a placement waiting for its ✓, the piece being dragged from the + // build column (and where the finger is, in window pixels), the log popup, and the board. + var staged by remember { mutableStateOf(null) } + var dragKind by remember { mutableStateOf(null) } + var dragAt by remember { mutableStateOf(null) } + var showLog by remember { mutableStateOf(false) } + var boardCoords by remember { mutableStateOf(null) } + + val config = LocalConfiguration.current + // Landscape gets the heads-up display; portrait keeps the stacked layout. + val wide = config.screenWidthDp > config.screenHeightDp * 1.1f + val staging = wide val dark = isSystemInDarkTheme() val style = remember(dark) { catanStyle(dark) } @@ -114,6 +136,7 @@ fun CatanScreen( // settlement that is no longer pending). LaunchedEffect(legal) { if (building != null && building !in CatanInteraction.canBuild(legal)) building = null + if (staged != null && staged !in legal) staged = null if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) { setupVertex = null } @@ -128,6 +151,7 @@ fun CatanScreen( knightMenu = null movingKnight = null armed = null + staged = null } } @@ -143,6 +167,9 @@ fun CatanScreen( knightMenu = null movingKnight = null armed = null + staged = null + dragKind = null + dragAt = null } val endGameAction: @Composable () -> Unit = { @@ -192,13 +219,17 @@ fun CatanScreen( ) val handle: (TapOutcome) -> Unit = { outcome -> - when (outcome) { + val wait = if (staging) CatanInteraction.stageable(outcome, building) else null + when { + wait != null -> staged = wait + else -> when (outcome) { TapOutcome.Ignored -> Unit is TapOutcome.Submit -> submit(outcome.move) is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex is TapOutcome.PickRobberVictim -> victimChoice = outcome is TapOutcome.PickKnight -> knightMenu = outcome.vertex is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex) + } } } @@ -210,10 +241,14 @@ fun CatanScreen( CompositionLocalProvider(LocalCatanStyle provides style) { ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier -> BoxWithConstraints(modifier = modifier) { - val wide = maxWidth > maxHeight * 1.1f // Phones (either way up) get the one-button build menu. val compactBuild = !wide || maxWidth < 600.dp + val hoverSpot = if (dragKind != null) dragSpot(boardCoords, dragAt, targets) else null + val stagedSpot = CatanInteraction.spotOf(staged) + val ghostVertex = hoverSpot?.vertex ?: stagedSpot.vertex + val ghostEdge = hoverSpot?.edge ?: stagedSpot.edge + val board: @Composable (Modifier) -> Unit = { boardModifier -> CatanBoardView( state = current, @@ -233,6 +268,9 @@ fun CatanScreen( style = style, rolledHexes = rolledHexes, selectedKnight = movingKnight ?: knightMenu, + stagedVertex = ghostVertex, + stagedEdge = ghostEdge, + stagedSeat = localSeat, ) } val players: @Composable (Boolean) -> Unit = { grid -> @@ -265,21 +303,61 @@ fun CatanScreen( } if (wide) { - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) { - board(Modifier) - } - Column( - modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - players(true) - if (current.isCk) CatanBarbarians(current, style = style) - promptRow() - handAndActions() - CatanLog(current, Modifier.fillMaxWidth().height(120.dp)) - } - } + CatanHud( + state = current, + seats = seats, + style = style, + actions = actions, + prompt = prompt, + notice = notice, + legal = legal, + localSeat = localSeat, + targets = targets, + pointsOf = pointsOf, + staged = staged, + dragKind = dragKind, + dragAt = dragAt, + showLog = showLog, + boardCoords = boardCoords, + building = building, + onBoardCoords = { boardCoords = it }, + onToggleLog = { showLog = !showLog }, + onArm = { kind -> building = if (building == kind) null else kind; staged = null }, + onDragStart = { kind, at -> + building = kind + staged = null + dragKind = kind + dragAt = at + }, + onDrag = { dragAt = it }, + onDragEnd = { + val spot = dragSpot(boardCoords, dragAt, targets) + dragKind = null + dragAt = null + val outcome = when { + spot?.vertex != null -> CatanInteraction.onVertexTap( + spot.vertex, legal, building, setupVertex, + ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(), + ) + spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed) + else -> TapOutcome.Ignored + } + handle(outcome) + }, + onConfirm = { staged?.let(submit) }, + onCancelStaged = { staged = null; building = null }, + onRoll = { submit(RollDice()) }, + onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) }, + onEndFreeRoads = { legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit) }, + onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) }, + onRepickSetup = { setupVertex = null }, + onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) }, + onPlayCard = { showCards = true }, + onTrade = { showTrade = true }, + onImprove = { showImprove = true }, + onProgress = { showProgress = true }, + board = { m -> board(m) }, + ) } else { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { players(false) @@ -291,6 +369,39 @@ fun CatanScreen( handAndActions() } } + + // The trade board slides up over the bottom of the island (base game; Cities & + // Knights trades goods and still uses its dialogs). + val offerToMe = current.pendingTrade?.takeIf { + current.phase == CatanPhase.TRADE_PENDING && it.asking == localSeat && !current.isCk + } + val boardsAllowed = abandoned == null && !confirmingEnd && current.phase != CatanPhase.GAME_OVER + if (boardsAllowed && offerToMe != null) { + CatanTradeReplyBoard( + state = current, + seat = localSeat, + offer = offerToMe, + seats = seats, + canAccept = CatanInteraction.tradeResponses(legal).first, + onAccept = { submit(AcceptTrade) }, + onDecline = { submit(DeclineTrade) }, + onCounter = submit, + modifier = Modifier.align(Alignment.BottomCenter), + style = style, + ) + } else if (boardsAllowed && showTrade && !current.isCk) { + CatanTradeBoard( + state = current, + seat = localSeat, + seats = seats, + legal = legal, + onBank = submit, + onOffer = submit, + onClose = { showTrade = false }, + modifier = Modifier.align(Alignment.BottomCenter), + style = style, + ) + } } CatanDialogHost( @@ -432,7 +543,6 @@ private fun CatanDialogHost( onCancelCards: () -> Unit, onCancelTrade: () -> Unit, ) { - val offer = state.pendingTrade val goodsOffer = state.ck?.goodsOffer when { abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay) @@ -473,18 +583,6 @@ private fun CatanDialogHost( onNoDeal = { onSubmit(DismissCounters) }, ) - state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat -> - CatanTradeResponseDialog( - state = state, - seat = localSeat, - offer = offer, - seats = seats, - canAccept = CatanInteraction.tradeResponses(legal).first, - onAccept = { onSubmit(AcceptTrade) }, - onDecline = { onSubmit(DeclineTrade) }, - onCounter = onSubmit, - ) - state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() -> CatanCounterChoiceDialog( counters = CatanInteraction.counterChoices(state, legal), @@ -541,17 +639,6 @@ private fun CatanDialogHost( onOffer = onSubmit, onCancel = onCancelTrade, ) - - showTrade -> - CatanTradeDialog( - state = state, - seat = localSeat, - seats = seats, - legal = legal, - onBank = onSubmit, - onOffer = onSubmit, - onCancel = onCancelTrade, - ) } } @@ -612,3 +699,11 @@ private fun ChoreDialog( else -> Unit } } + +/** The board spot under a dragged finger ([at], in window pixels), or null off the board. */ +private fun dragSpot(coords: LayoutCoordinates?, at: Offset?, targets: BoardTargets): CatanInteraction.Spot? { + if (coords == null || at == null || !coords.isAttached) return null + val local = coords.windowToLocal(at) + val layout = CatanGeometry.fit(coords.size.width.toFloat(), coords.size.height.toFloat()) + return CatanInteraction.snap(layout, targets, Pt(local.x, local.y), layout.radius * 1.2f) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt index 8694f2f..7f8d4db 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -139,6 +139,28 @@ object CatanText { const val YOU_GIVE = "You give" const val YOU_GET = "You get" const val SEND_OFFER = "Send offer" + + // --- Trade board and HUD ----------------------------------------------------- + const val TAB_PLAYER = "Player" + const val TAB_BANK = "Bank" + const val YOU_RECEIVE_ROW = "You get" + const val YOU_GIVE_ROW = "You give" + const val TRADE_HINT_PLAYER = "Tap cards to build an offer. Tap a player to address it to them alone." + const val TRADE_HINT_BANK = "Tap what you give, then what you want." + const val TRADE_HINT_BANK_EMPTY = "You do not hold enough of any one resource to trade with the bank." + const val TRADE_HINT_COUNTER = "Change the terms, then send them back." + const val HIDE = "Hide" + const val DENY = "Deny" + const val CONFIRM_BUILD = "Place" + const val CANCEL_BUILD = "Cancel" + const val DRAG_HINT = "Drag a piece onto the board" + const val LOG_BUTTON = "Log" + const val CARDS_SHORT = "Cards" + const val BUY_SHORT = "Buy" + const val ROLL_SHORT = "Roll" + const val END_SHORT = "End" + const val DONE_SHORT = "Done" + const val LOG_TITLE = "Table log" const val NO_LOG = "Nothing has happened yet." const val END_GAME = "End game" const val LOOK_AT_BOARD = "Look at the board" diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanTradeBoard.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanTradeBoard.kt new file mode 100644 index 0000000..9c4a263 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanTradeBoard.kt @@ -0,0 +1,415 @@ +package org.prolibertate.games.ui.game.catan + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.alpha +import androidx.compose.ui.draw.clip +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 org.prolibertate.games.game.catan.CatanMove +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.CounterTrade +import org.prolibertate.games.game.catan.MaritimeTrade +import org.prolibertate.games.game.catan.OfferTrade +import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.TradeOffer + +/* + * The trade board: the 2D version of Catan Universe's trade panel (see + * docs/RE-catan-universe.md). One panel slides up over the bottom of the board: + * + * header [Player | Bank] hint rival buttons + * top row the cards you GET, tinted with the other side's colour + * arrows + * bottom row the cards you GIVE, tinted with your colour + * right ▾ hide ✕ deny ✓ accept + * + * Like every Katan panel it decides nothing: what may be offered is asked of + * [CatanInteraction], and what a bank trade costs comes from the engine's legal list. + */ + +private enum class TradeMode { PLAYER, BANK } + +private fun Map.cards(): List = + Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } } + +/** The panel for starting a trade: with the bank, or an offer to one rival or everyone. */ +@Composable +fun CatanTradeBoard( + state: CatanState, + seat: Int, + seats: CatanSeats, + legal: List, + onBank: (MaritimeTrade) -> Unit, + onOffer: (OfferTrade) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, + style: CatanStyle = LocalCatanStyle.current, +) { + var mode by remember { mutableStateOf(TradeMode.BANK) } + var to by remember { mutableStateOf(null) } + var give by remember { mutableStateOf(emptyMap()) } + var get by remember { mutableStateOf(emptyMap()) } + var bankGive by remember { mutableStateOf(null) } + var bankGet by remember { mutableStateOf(null) } + + val bank = CatanInteraction.bankTrades(legal) + val giveCards = give.cards() + val getCards = get.cards() + val canSend = CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards) + val bankMove = bankGive?.let { g -> bankGet?.takeIf { it in bank[g].orEmpty() }?.let { MaritimeTrade(g, it) } } + + val rivals = (0 until state.playerCount).filter { it != seat } + val headerRight: @Composable () -> Unit = { + if (mode == TradeMode.PLAYER) { + RivalButtons(rivals, to, style, seats) { to = if (to == it) null else it } + } + } + + TradeBoardFrame( + modifier = modifier, + topTint = if (mode == TradeMode.PLAYER && to != null) style.seats[to!!] else Color.Gray, + bottomTint = style.seats[seat], + header = { + TradeTab(CatanText.TAB_BANK, mode == TradeMode.BANK) { mode = TradeMode.BANK } + TradeTab(CatanText.TAB_PLAYER, mode == TradeMode.PLAYER) { mode = TradeMode.PLAYER } + }, + headline = when { + mode == TradeMode.PLAYER -> CatanText.TRADE_HINT_PLAYER + bank.isEmpty() -> CatanText.TRADE_HINT_BANK_EMPTY + else -> CatanText.TRADE_HINT_BANK + }, + headerRight = headerRight, + topLabel = CatanText.YOU_RECEIVE_ROW, + bottomLabel = CatanText.YOU_GIVE_ROW, + topRow = { + if (mode == TradeMode.PLAYER) { + TradeCardRow( + counts = get, + limit = { 9 }, + onChange = { get = it }, + ) + } else { + val choices = bankGive?.let { bank[it] }.orEmpty().toSet() + TradeCardRow( + counts = bankGet?.let { mapOf(it to 1) } ?: emptyMap(), + limit = { if (it in choices) 1 else 0 }, + enabledOf = { it in choices }, + onChange = {}, + pick = { bankGet = if (bankGet == it) null else it }, + ) + } + }, + bottomRow = { + if (mode == TradeMode.PLAYER) { + TradeCardRow( + counts = give, + limit = { state.resources[seat][it] ?: 0 }, + onChange = { give = it }, + ) + } else { + TradeCardRow( + counts = bank.keys.associateWith { CatanInteraction.bankRatio(state, seat, it) }, + limit = { 0 }, + enabledOf = { it in bank }, + selected = bankGive, + caption = { r -> if (r in bank) "${CatanInteraction.bankRatio(state, seat, r)} : 1" else " " }, + onChange = {}, + pick = { r -> + bankGive = if (bankGive == r) null else r + bankGet = null + }, + ) + } + }, + acceptEnabled = if (mode == TradeMode.PLAYER) canSend else bankMove != null, + onAccept = { + if (mode == TradeMode.PLAYER) { + onOffer(OfferTrade(to, giveCards, getCards)) + } else { + bankMove?.let(onBank) + } + }, + denyEnabled = true, + onDeny = { + give = emptyMap() + get = emptyMap() + bankGive = null + bankGet = null + to = null + }, + onHide = onClose, + ) +} + +/** + * The panel for an offer made to the local player: the same layout, read-only. ✓ accepts, + * ✕ declines, and *Counter…* turns the rows editable, pre-filled with the mirror of the offer. + */ +@Composable +fun CatanTradeReplyBoard( + state: CatanState, + seat: Int, + offer: TradeOffer, + seats: CatanSeats, + canAccept: Boolean, + onAccept: () -> Unit, + onDecline: () -> Unit, + onCounter: (CounterTrade) -> Unit, + modifier: Modifier = Modifier, + style: CatanStyle = LocalCatanStyle.current, +) { + var countering by remember(offer) { mutableStateOf(false) } + var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) } + var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) } + val counter = CounterTrade(give.cards(), get.cards()) + + TradeBoardFrame( + modifier = modifier, + topTint = style.seats[offer.by], + bottomTint = style.seats[seat], + header = { + TradeTab(CatanText.COUNTER, countering) { countering = !countering } + }, + headline = if (countering) { + CatanText.TRADE_HINT_COUNTER + } else { + CatanText.tradeSummary( + by = seats.label(offer.by), + to = if (offer.to == null) "the table" else "you", + give = offer.give, + get = offer.get, + ) + }, + headerRight = {}, + topLabel = CatanText.YOU_RECEIVE_ROW, + bottomLabel = CatanText.YOU_GIVE_ROW, + topRow = { + if (countering) { + TradeCardRow(get, limit = { state.resources[offer.by][it] ?: 0 }, onChange = { get = it }) + } else { + TradeCardRow(offer.give.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {}) + } + }, + bottomRow = { + if (countering) { + TradeCardRow(give, limit = { state.resources[seat][it] ?: 0 }, onChange = { give = it }) + } else { + TradeCardRow(offer.get.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {}) + } + }, + acceptEnabled = if (countering) { + CatanInteraction.counterAllowed(state, seat, counter.give, counter.get) + } else { + canAccept + }, + onAccept = { if (countering) onCounter(counter) else onAccept() }, + denyEnabled = true, + onDeny = onDecline, + onHide = null, + ) +} + +// --------------------------------------------------------------------------- +// Pieces +// --------------------------------------------------------------------------- + +@Composable +private fun TradeBoardFrame( + topTint: Color, + bottomTint: Color, + header: @Composable () -> Unit, + headline: String, + headerRight: @Composable () -> Unit, + topLabel: String, + bottomLabel: String, + topRow: @Composable () -> Unit, + bottomRow: @Composable () -> Unit, + acceptEnabled: Boolean, + onAccept: () -> Unit, + denyEnabled: Boolean, + onDeny: () -> Unit, + onHide: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f), + tonalElevation = 6.dp, + shadowElevation = 12.dp, + ) { + Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + header() + Text( + headline, + style = MaterialTheme.typography.bodySmall, + // Two lines, always: the panel keeps its height whatever is said. + minLines = 2, + maxLines = 2, + modifier = Modifier.weight(1f), + ) + headerRight() + } + HorizontalDivider() + TintedRow(topTint, topLabel, topRow) + Text("⇣ ⇣ ⇣", modifier = Modifier.align(Alignment.CenterHorizontally), fontSize = 14.sp) + TintedRow(bottomTint, bottomLabel, bottomRow) + } + Column( + modifier = Modifier.padding(start = 10.dp).width(72.dp), + verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Always three slots, so the column does not shift when there is nothing to hide. + if (onHide != null) { + HudRoundButton("▾", caption = CatanText.HIDE, diameter = 48.dp, onClick = onHide) + } else { + Box(Modifier.size(48.dp)) + } + HudRoundButton("✕", caption = CatanText.DENY, enabled = denyEnabled, tint = Color(0xFFC62828), diameter = 52.dp, onClick = onDeny) + HudRoundButton("✓", caption = CatanText.ACCEPT, enabled = acceptEnabled, tint = Color(0xFF2E7D32), diameter = 60.dp, onClick = onAccept) + } + } + } +} + +@Composable +private fun TintedRow(tint: Color, label: String, content: @Composable () -> Unit) { + val shape = RoundedCornerShape(12.dp) + Column( + Modifier + .fillMaxWidth() + .clip(shape) + .background(tint.copy(alpha = 0.18f)) + .border(1.dp, tint.copy(alpha = 0.5f), shape) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text(label, fontSize = 10.sp, fontWeight = FontWeight.Bold) + Box(Modifier.horizontalScroll(rememberScrollState())) { content() } + } +} + +/** + * Five resource cards in a row. Tapping a card adds one (up to [limit]); the "−" under it + * takes one back. A bank row passes [pick] instead, and a tap selects the card. + */ +@Composable +private fun TradeCardRow( + counts: Map, + limit: (Resource) -> Int, + onChange: (Map) -> Unit, + enabledOf: (Resource) -> Boolean = { true }, + selected: Resource? = null, + caption: ((Resource) -> String)? = null, + pick: ((Resource) -> Unit)? = null, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Resource.entries.forEach { resource -> + val n = counts[resource] ?: 0 + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ResourceTile( + resource = resource, + count = n, + selected = selected == resource, + enabled = enabledOf(resource) && (pick != null || n > 0 || limit(resource) > 0), + onClick = { + if (pick != null) { + pick(resource) + } else if (n < limit(resource)) { + onChange(counts + (resource to n + 1)) + } + }, + ) + if (caption != null) { + Text(caption(resource), fontSize = 11.sp, fontWeight = FontWeight.Bold) + } else { + // Always laid out so the row never changes height. + Text( + "−", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .alpha(if (n > 0 && pick == null && limit(resource) >= 0) 1f else 0f) + .clickable(enabled = n > 0 && pick == null) { + onChange(counts + (resource to n - 1)) + } + .padding(horizontal = 14.dp), + ) + } + } + } + } +} + +@Composable +private fun TradeTab(label: String, selected: Boolean, onClick: () -> Unit) { + val shape = RoundedCornerShape(50) + Text( + label, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .clip(shape) + .background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) +} + +/** One round button per rival: tap to address the offer to them alone, tap again for everyone. */ +@Composable +private fun RivalButtons( + rivals: List, + chosen: Int?, + style: CatanStyle, + seats: CatanSeats, + onTap: (Int) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + rivals.forEach { rival -> + val picked = chosen == rival + Box( + Modifier + .size(40.dp) + .clip(CircleShape) + .background(style.seats[rival]) + .border(if (picked) 4.dp else 1.dp, if (picked) Color.White else Color(0x55000000), CircleShape) + .clickable { onTap(rival) }, + contentAlignment = Alignment.Center, + ) { + Text(seats.label(rival).take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 4e30831..c3043ec 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -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)) diff --git a/app/src/test/java/org/prolibertate/games/game/engine/SavedGameStoreTest.kt b/app/src/test/java/org/prolibertate/games/game/engine/SavedGameStoreTest.kt new file mode 100644 index 0000000..d745316 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/engine/SavedGameStoreTest.kt @@ -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) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt new file mode 100644 index 0000000..9521bf1 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt @@ -0,0 +1,343 @@ +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 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 crossed square still takes a cat attempt`() { + // The double tap is one gesture pair: the first tap crosses the square + // and the second asks it. The cross the pair drew cannot veto the + // question that follows it. + val state = dealt(6) + val truth = Square(0, state.catCols[0]) + val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(truth)) + assertTrue(truth in crossed.marks) + assertTrue( + "the attempt is offered over its own cross", + PlaceCat(truth) in MeowdokuRules.legalMoves(crossed, 0), + ) + + val placed = MeowdokuRules.applyMove(crossed, 0, PlaceCat(truth)) + assertTrue(truth in placed.cats) + assertFalse("the cat replaces the cross", truth in placed.marks) + assertEquals(3, placed.heartsLeft) + assertEquals(0, placed.wrongCats) + } + + @Test + fun `a wrong pair keeps its cross and spends a heart`() { + val state = dealt(6) + val wrongCol = if (state.catCols[1] == 0) 1 else 0 + val wrong = Square(1, wrongCol) + val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(wrong)) + val judged = MeowdokuRules.applyMove(crossed, 0, PlaceCat(wrong)) + assertTrue(wrong in judged.marks) + assertFalse(wrong in judged.cats) + assertEquals(2, judged.heartsLeft) + assertEquals(1, judged.wrongCats) + } + + @Test + 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) } + } +} diff --git a/app/src/test/java/org/prolibertate/games/net/MatchSaverTest.kt b/app/src/test/java/org/prolibertate/games/net/MatchSaverTest.kt new file mode 100644 index 0000000..9dd970a --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/net/MatchSaverTest.kt @@ -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() + + @After + fun tearDown() { + scopes.forEach { it.cancel() } + } + + private data class Counter(val n: Int, val secret: String) + + private object Rules : GameRules { + 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 = MatchController( + rules = Rules, + ai = object : GameAi { + override fun chooseMove(state: Counter, seat: Int, legal: List) = 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) = + 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 + } +} diff --git a/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt b/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt new file mode 100644 index 0000000..fe09ef8 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt @@ -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 exercise( + gameId: String, + rules: GameRules, + ai: GameAi, + 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 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, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt b/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt index 7f10647..fbcd846 100644 --- a/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt +++ b/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt @@ -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 } diff --git a/app/src/test/java/org/prolibertate/games/score/RecentNamesTest.kt b/app/src/test/java/org/prolibertate/games/score/RecentNamesTest.kt new file mode 100644 index 0000000..cb8d160 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/score/RecentNamesTest.kt @@ -0,0 +1,30 @@ +package org.prolibertate.games.score + +import org.junit.Test +import org.junit.Assert.assertEquals + +class RecentNamesTest { + @Test + fun newestComesFirst() { + assertEquals(listOf("Bo", "Al"), RecentNames.with(listOf("Al"), "Bo")) + } + + @Test + fun repeatMovesToFrontIgnoringCase() { + assertEquals(listOf("al", "Bo"), RecentNames.with(listOf("Bo", "Al"), " al ")) + } + + @Test + fun blankChangesNothing() { + assertEquals(listOf("Al"), RecentNames.with(listOf("Al"), " ")) + } + + @Test + fun keepsOnlyTen() { + val names = (1..10).map { "n$it" } + val result = RecentNames.with(names, "new") + assertEquals(10, result.size) + assertEquals("new", result.first()) + assertEquals("n9", result.last()) + } +} diff --git a/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt index cd6502b..1ab3414 100644 --- a/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt +++ b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt @@ -199,6 +199,14 @@ class ScoreSheetTest { assertEquals(ScoringMode.ROUNDS, StartSheet(3).applyTo(ScoreSheet()).mode) } + @Test + fun startingWithNamesSeatsThemInOrderAndLeavesTheRestUnnamed() { + val sheet = StartSheet(3, names = listOf("Ann", "")).applyTo(ScoreSheet()) + assertEquals(listOf("Ann", "", ""), sheet.players.map { it.name }) + assertEquals("Ann", sheet.displayName(sheet.players[0])) + assertEquals("Player 2", sheet.displayName(sheet.players[1])) + } + @Test fun `tally entries add up per player and can be taken back`() { val sheet = ScoreSheet.of(2, ScoringMode.TALLY) diff --git a/app/src/test/java/org/prolibertate/games/ui/SavedGameTextTest.kt b/app/src/test/java/org/prolibertate/games/ui/SavedGameTextTest.kt new file mode 100644 index 0000000..b47cb1d --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/SavedGameTextTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanHudTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanHudTest.kt new file mode 100644 index 0000000..acbd9e2 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanHudTest.kt @@ -0,0 +1,56 @@ +package org.prolibertate.games.ui.game.catan + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.prolibertate.games.game.catan.BuildRoad +import org.prolibertate.games.game.catan.BuildSettlement +import org.prolibertate.games.game.catan.EndTurn + +/** The pure half of the landscape HUD: drag-to-build snapping and the staged ✓ / ✗ placement. */ +class CatanHudTest { + + @Test + fun `a build tap is staged, not sent, but only while a build mode is armed`() { + val road = BuildRoad(0) + + val outcome = TapOutcome.Submit(road) + assertEquals(road, CatanInteraction.stageable(outcome, BuildKind.ROAD)) + assertNull(CatanInteraction.stageable(outcome, null)) + // Ending the turn is never a placement. + assertNull(CatanInteraction.stageable(TapOutcome.Submit(EndTurn), BuildKind.ROAD)) + assertNull(CatanInteraction.stageable(TapOutcome.Ignored, BuildKind.ROAD)) + } + + @Test + fun `the ghost of a staged move sits on its crossing or its edge`() { + assertEquals(CatanInteraction.Spot(vertex = 4), CatanInteraction.spotOf(BuildSettlement(4))) + assertEquals(CatanInteraction.Spot(edge = 9), CatanInteraction.spotOf(BuildRoad(9))) + assertEquals(CatanInteraction.Spot(), CatanInteraction.spotOf(null)) + } + + @Test + fun `a dragged finger snaps to the nearest lit crossing within reach, and off the board to nothing`() { + val layout = CatanGeometry.fit(800f, 600f) + val lit = setOf(3, 10, 21) + val targets = BoardTargets(vertices = lit) + val target = layout.vertex(10) + + // A little off the crossing, well inside reach. + val near = Pt(target.x + layout.radius * 0.3f, target.y - layout.radius * 0.2f) + assertEquals(CatanInteraction.Spot(vertex = 10), CatanInteraction.snap(layout, targets, near, layout.radius * 1.2f)) + + // Far from every lit crossing. + assertNull(CatanInteraction.snap(layout, targets, Pt(-500f, -500f), layout.radius * 1.2f)) + } + + @Test + fun `with edges lit and no crossings, the drop snaps to an edge`() { + val layout = CatanGeometry.fit(800f, 600f) + val targets = BoardTargets(edges = setOf(5, 6)) + val mid = layout.edgeMidpoint(6) + assertNotNull(CatanInteraction.snap(layout, targets, mid, layout.radius)) + assertEquals(CatanInteraction.Spot(edge = 6), CatanInteraction.snap(layout, targets, mid, layout.radius)) + } +} diff --git a/docs/RE-catan-universe.md b/docs/RE-catan-universe.md new file mode 100644 index 0000000..01d09d7 --- /dev/null +++ b/docs/RE-catan-universe.md @@ -0,0 +1,73 @@ +# Catan Universe 2.7.5 — what the reverse engineering showed + +Source: `Catan+Universe_2.7.5_APKPure.xapk` (Unity, IL2CPP). Method: the Unity scenes +(`level3` is the match scene) were parsed with UnityPy for the uGUI hierarchy (names, +anchors, sizes, which objects start hidden), and `global-metadata.dat` was searched for +class, method and game-action names. Code bodies were not decompiled; behaviour below +that is not visible in a hierarchy is marked *inferred*. + +The game is **landscape-only**; the canvas is laid out against a 1920×1080 reference. + +## Match HUD (`InGameUI_mobile`) + +| Region | Contents | +| --- | --- | +| Top | `PlayerFrames`: one frame per player (avatar, colour, VP, card count, timer), a "shuffle stats" button at the right | +| Bottom bar | `ResourceContainer`, centred (anchors 25–75 % of the width, 90 px tall): one entry per resource (Lumber, Brick, Wool, Grain, Ore; Cloth, Coins, Paper for C&K; Fish, Feather, Coca for other expansions, hidden otherwise) | +| Bottom right | `BtnTurn` (140 px, roll / end turn), `BtnTrade` immediately to its left (counter-offer and "blocked" badge variants), `BtnAddDevCard`, a chat/log `BubblePanel` | +| Left edge | `Drag&DropBuild`: a column of piece icons. **The player drags a piece from it onto the board** (`Drag&DropBuildRemove` is the drop target that cancels) | +| Mid right | `ConfirmButtonGroup`: a ✓ (`CatanConfirmBuildButton`) over a ✗ (`CatanCancelBuildButton`). A drop only *previews*; the ✓ commits | +| Board | `CandidateFactory` makes highlight markers for every legal spot (corner, edge, face); `QuickBuildTrigger{Corner,Edge,Face}` are hit zones for a one-tap/hold build; a candidate ticker shows the one-line instruction | +| Centre | Played-card panel, progress-card chooser, discard panel (`CardPanel` slides up over the bottom bar with a header) | + +Gestures (`CatanGestureHandler`, `BaseGesture`, `TapHoldHandler`): one finger pans the +board, two-finger pinch zooms (`GetPinchZoomMagnitude`, `AutomaticZoom`), tap selects a +candidate, hold opens quick-build. Camera is a fixed-pitch table view. + +## Trade board (`TradeBoard_mobile`) + +A full-width panel that slides up over the bottom of the screen (`Animator`, collapsible): + +``` +┌────────────────────────────────────────────────────────────┬──────┐ +│ [Player] [Bank] headline / description ☐ Block trade │ P1 P2│ header +├────────────────────────────────────────────────────────────┤ P3 P4│ (135 px) +│ OPPONENT row — cards you get (tinted by the opponent's │ │ +│ colour) │ Hide │ +│ ⇣⇣⇣ arrows (or the bank ratio chips 4:1 3:1 2:1) │ Deny │ +│ PLAYER row — cards you give (tinted by your colour) │Accept│ +└────────────────────────────────────────────────────────────┴──────┘ +``` + +* **Header.** A *Player / Bank* toggle (extra toggles for the Inka 3:2 / 2:1 rules). A + **Block trade** checkbox refuses all incoming offers. One round button per rival + (`Player1…5`, with a selected border) picks whom the offer is addressed to; none + selected means everyone. +* **Content.** Two rows of resource cards separated by arrows. The top row is what you + *want*, the bottom row what you *give*. Tapping a card in a row adds one; tapping a + selected card removes one (*inferred from `AddFieldToTradePayment`/`ApplyCancelTradeResource`*). + In Bank mode a ratio row (`CardRatios`) shows the best rate per resource (harbours). +* **Right column (180 px).** Hide/collapse, **Deny** (✗), **Accept** (✓). When you + are the one receiving an offer, ✓ accepts and the chip-icon variant `Icon_CounterOffer` + turns the ✓ into a *counter-offer* send. A circular turn timer wraps the ✓. +* **Incoming offers** (`IncomingTradeContainer`) show in the same panel; the offerer's + colour tints the opponent row. Counter-offers land back in the proposer's panel with + an `AcceptCounterOfferButtonImage`. + +## Seafarers game actions (from the metadata) + +`CatanSeafarer_*_GameAction` names show what the mode adds to the base game: + +* **Ships:** `BuildShip`, `MoveShip` (only the ship at the open end of a route, not on + the turn it was built), `BuildFreeRoad`; roads and ships share `AvailableCandidatesRoadAndShip`. +* **Pirate** alongside the robber: `RobberPirat_Move`, `RobberPirat_SelectPlayer`; + ships cannot be built or moved past a pirate-blocked edge. +* **Gold fields:** `ChooseGoldfieldResource` (a hex that pays any resource of choice). +* **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a + road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`. +* **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are + **not** part of the physical Seafarers rules; they are out of scope here. +* **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip` + (number tokens placed per scenario), `Shuffle_Harbors`. +* **Island bonus:** special victory points for the first settlement on a new island + (`AllIslandIds`, `CatanIslandState`). diff --git a/docs/README.md b/docs/README.md index 3a93b26..4e9fca3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/RULES-meowdoku.md b/docs/RULES-meowdoku.md new file mode 100644 index 0000000..571d1c2 --- /dev/null +++ b/docs/RULES-meowdoku.md @@ -0,0 +1,98 @@ +# 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 + +Controls are the original's, and they carry the game's whole psychology: + +- **One tap crosses a square over.** Free, instant, erasable — the note you + make while thinking. +- **A second tap on the same square within 350 ms is the cat attempt.** The + board judges it against the answer it was dealt: correct, the cross is + replaced by a cat; wrong, the cross stays and a heart goes. This is why a + guess feels like a decision: your own note has to survive one more tap + before it becomes a bet. +- **Drag to cross a line.** The stroke paints the same note every square it + enters: start on a blank square and it crosses the row or column you drag + along; start on a crossed square and the stroke erases them instead. Cats + are skipped — placed cats are permanent. + +A square you pencilled over can still be tapped for a cat, and the cross is +forgiven: the attempt replaces it if it is right. The one thing no gesture +undoes is a cat once placed. + +The original ships a fifth gesture — a **draft** scratchpad mode, a tool you +buy, where crosses and cats are laid down without judgement and applied all at +once — and it is left out here with the rest of the shop: a scratchpad whose +notes cost nothing is what tapping already is. + +A **Hint** spends a token and shows the cat in the first row that still lacks +one, replacing any cross drawn over it. The original sells these as tools; here +they are simply counted, three per board by default, which keeps the hint a +decision rather than a reflex. + +## The heart + +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. diff --git a/docs/SAVED-GAMES.md b/docs/SAVED-GAMES.md new file mode 100644 index 0000000..e99f298 --- /dev/null +++ b/docs/SAVED-GAMES.md @@ -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. diff --git a/docs/UI-catan.md b/docs/UI-catan.md index 2ec24fa..f1c2d05 100644 --- a/docs/UI-catan.md +++ b/docs/UI-catan.md @@ -22,6 +22,8 @@ truth, so the UI can only ever offer a move the rules would accept. | **Board drawing** | `CatanBoardView.kt` | The `Canvas`: tiles, tokens, ports, robber, roads, pieces, highlights | Change how a piece is drawn (art, shapes, animation) | | **Panels** | `CatanPanels.kt` | Resource tiles, hand, player cards, dice, action bar, log | Restyle or rearrange widgets | | **Dialogs** | `CatanDialogs.kt` | Discard, robber victim, play card, trade, trade reply, game over | Change a dialog's flow or look | +| **HUD** | `CatanHud.kt` | The landscape controls: build column, ✓ / ✗, round turn / trade buttons, pinch-pan board | Move or restyle a landscape control | +| **Trade board** | `CatanTradeBoard.kt` | The base-game trade panel (offer, bank, reply) | Change how trades are composed or answered | | **Screen** | `CatanScreen.kt` | Wiring to `MatchController`, portrait/landscape layout, which dialog shows | Change the layout or add a new element | | **Previews** | `CatanPreviews.kt` | Android Studio previews of real positions | Iterate on the look without a match | @@ -169,3 +171,24 @@ Not done yet, in rough priority order: 4. A timer or cancel button on offers (the engine has neither). 5. A board that shows the *last* move (a faint highlight of what just changed). 6. Landscape polish on large tablets; the right-hand panel scrolls but is plain. + +## Landscape HUD and trade board (from Catan Universe) + +Landscape screens use the control scheme of Catan Universe's match view, redrawn in 2D +(what was learned, and from where: [RE-catan-universe.md](RE-catan-universe.md)). Portrait keeps the stacked layout. + +* **Drag to build.** The left column holds Road / Settlement / City (and Wall / Knight in + Cities & Knights). Drag a piece onto the island: lit spots snap under the finger + (`CatanInteraction.snap`), release over one to **stage** it, then press **✓** to place or **✕** + to cancel (`CatanInteraction.stageable`, `spotOf`). A tap on a piece still arms the board for + tapping a spot. Setup placements, free roads and chores are not staged: they commit on tap. +* **Pinch and pan** the island (`ZoomPanBox`). The board is drawn through a graphics layer, so + taps and drops arrive in board coordinates; hit-testing is unchanged. +* **Round buttons**, bottom right: the big one is roll / end turn / done (whatever the engine + currently allows); ⇄ is trade; the small pair is buy / play card (Improve / Cards in C&K). +* **Trade board** (`CatanTradeBoard`, base game): a panel over the bottom of the island with a + Bank / Player toggle, a round button per rival to address an offer to one player, a *get* row on + top, a *give* row below, and ▾ hide / ✕ clear / ✓ send on the right. Bank mode shows each resource's + ratio under the *give* card. An offer made to you opens the same panel read-only: ✓ accepts, + ✕ declines, *Counter…* makes the rows editable. + Cities & Knights goods trades and the counter-offer chooser still use dialogs.