From e370aa0ceb2ba1df8a7ac71638cc4569471a4339 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:04:47 +0000 Subject: [PATCH 1/8] Save state for every game between sessions A game against the computer is kept as it is played (the host's true state plus its table config, one file per game) and can be resumed from the main menu or the game's setup screen. Leaving keeps the position; End game discards it; a finished game is not kept; a networked table is not saved. MatchController gains restore() and an onHostState listener; MatchSaver writes off the main thread and flushes on leaving and ON_STOP. Tests cover the store, the saver, resuming, and a save/resume round trip of every game in the catalogue at every move. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ --- .../org/prolibertate/games/MainActivity.kt | 3 + .../games/game/engine/SavedGame.kt | 101 ++ .../prolibertate/games/net/MatchController.kt | 47 +- .../org/prolibertate/games/net/MatchSaver.kt | 95 ++ .../java/org/prolibertate/games/ui/AppRoot.kt | 80 +- .../prolibertate/games/ui/EndGameHandle.kt | 21 + .../prolibertate/games/ui/GameSetupScreen.kt | 45 + .../org/prolibertate/games/ui/PlayScreen.kt | 1096 +++++++++-------- .../prolibertate/games/ui/SavedGameText.kt | 24 + .../games/ui/game/BackgammonScreen.kt | 8 +- .../prolibertate/games/ui/game/CardTable.kt | 8 +- .../games/ui/game/CheckersScreen.kt | 8 +- .../prolibertate/games/ui/game/ChessScreen.kt | 8 +- .../games/ui/game/HeartsScreen.kt | 8 +- .../games/ui/game/KlondikeScreen.kt | 8 +- .../games/ui/game/MastermindScreen.kt | 8 +- .../games/ui/game/MorrisScreen.kt | 8 +- .../games/ui/game/PegSolitaireScreen.kt | 8 +- .../games/ui/game/PiratesScreen.kt | 8 +- .../prolibertate/games/ui/game/TayuScreen.kt | 8 +- .../games/ui/game/YahtzeeScreen.kt | 8 +- .../games/ui/game/catan/CatanDialogs.kt | 8 +- .../games/game/engine/SavedGameStoreTest.kt | 119 ++ .../prolibertate/games/net/MatchSaverTest.kt | 191 +++ .../games/net/SaveRoundTripAllGamesTest.kt | 177 +++ .../games/ui/SavedGameTextTest.kt | 27 + docs/README.md | 2 + docs/SAVED-GAMES.md | 43 + 28 files changed, 1627 insertions(+), 548 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/engine/SavedGame.kt create mode 100644 app/src/main/java/org/prolibertate/games/net/MatchSaver.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/EndGameHandle.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/SavedGameText.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/engine/SavedGameStoreTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/net/MatchSaverTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/ui/SavedGameTextTest.kt create mode 100644 docs/SAVED-GAMES.md 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/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/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/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..0303f70 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 @@ -83,6 +87,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()) } @@ -149,6 +157,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) @@ -233,6 +245,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), @@ -1216,3 +1234,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..7b806d9 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,12 @@ 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberCoroutineScope @@ -112,6 +114,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 @@ -155,549 +159,554 @@ 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.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) - 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.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) - 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) }, - ) - } - StartMatch(env, controller, route) - CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + else -> Text("That game isn't playable yet.") } - - 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, - ) - } - - 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 +716,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 +760,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 +777,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/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/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/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/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/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/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/docs/README.md b/docs/README.md index 3a93b26..934bd34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,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/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. -- 2.47.3 From a8ff9863a6c6d0f2fcca822b1ea8e965d87190e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:52:44 +0000 Subject: [PATCH 2/8] Fix a race in ScorekeeperSessionTest's admittedGuest helper A guest counts as connected before the host's sheet has arrived over the socket, so tests that read the guest's players straight away could see an empty list. The helper now waits for the guest to hold the host's sheet. 0 failures in 12 runs (was ~1 in 3). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ --- .../org/prolibertate/games/net/ScorekeeperSessionTest.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 } -- 2.47.3 From d568ff77502a17fda9059128a3f5ac3cb7fac03e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:32:39 +0000 Subject: [PATCH 3/8] Meowdoku: one cat per row, column and colour, no two touching --- .../org/prolibertate/games/game/Catalog.kt | 13 + .../games/game/meowdoku/MeowdokuAi.kt | 46 +++ .../games/game/meowdoku/MeowdokuGenerator.kt | 371 ++++++++++++++++++ .../games/game/meowdoku/MeowdokuModel.kt | 117 ++++++ .../games/game/meowdoku/MeowdokuRules.kt | 192 +++++++++ .../prolibertate/games/ui/GameSetupScreen.kt | 36 ++ .../org/prolibertate/games/ui/PlayScreen.kt | 34 ++ .../games/ui/game/MeowdokuScreen.kt | 273 +++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 28 +- .../games/game/meowdoku/MeowdokuRulesTest.kt | 323 +++++++++++++++ docs/README.md | 1 + docs/RULES-meowdoku.md | 87 ++++ 12 files changed, 1513 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuGenerator.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt create mode 100644 docs/RULES-meowdoku.md 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/meowdoku/MeowdokuAi.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt new file mode 100644 index 0000000..93bd59e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt @@ -0,0 +1,46 @@ +package org.prolibertate.games.game.meowdoku + +import org.prolibertate.games.game.engine.GameAi + +/** + * Plays the board for the one seat. + * + * Like peg solitaire's AI, nothing normally asks it to: the single seat belongs + * to the person holding the phone. It exists because an empty seat has to be + * playable by something, and because "the computer can finish this board" is a + * property worth testing — a generated puzzle that only a human could unpack is + * a broken promise about uniqueness. + * + * It reads the answer from the state. The rules hold it, the seat is alone with + * it, and a solver that deduced its way there would be the same length as the + * hint engine in the original game without making this seat any more honest. + */ +object MeowdokuAi : GameAi { + + 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 pencil cross in front of it is the + // player's own note being wrong, so erase it; a hint spends one token + // and reveals the square regardless. + for (row in 0 until state.side) { + val missing = state.cats.none { it.row == row } + if (!missing) continue + val square = Square(row, state.catCols[row]) + places[square]?.let { return it } + if (square in state.marks) { + if (TakeHint in legal && state.hintsLeft > 0) return TakeHint + legal.filterIsInstance() + .firstOrNull { it.square == square } + ?.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..cae5e88 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt @@ -0,0 +1,117 @@ +package org.prolibertate.games.game.meowdoku + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// Meowdoku hides N cats in an N by N board: exactly one cat in every row, every +// column and every colour, and no two cats sharing even a corner. The player +// taps the squares they believe hold a cat; the board only ever accepts a +// correct tap, so a cat that lands is a cat that was always going to be there. +// A wrong tap costs a heart and crosses the square over, which is what makes +// pencil marks a choice rather than a necessity. + +/** One square of the board. */ +@Serializable +data class Square(val row: Int, val col: Int) + +@Serializable +data class MeowdokuOptions( + /** The board is [side] by [side], and the answer is [side] cats. */ + val side: Int = 6, + /** + * How many wrong taps the board forgives. The original game deals three, + * and the hearts on a level are part of its difficulty rather than a + * courtesy. + */ + val hearts: Int = 3, + /** How many cats the board will show on demand. */ + val hints: Int = 3, +) { + init { + require(side in MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE) { + "No board of $side squares a side" + } + require(hearts in 1..9) { "A board with no hearts is lost before it is dealt" } + require(hints in 0..9) { "Hints must read on the board, not scroll" } + } +} + +/** + * The dealt board plus the marks on it. + * + * [cats] and [marks] are sets rather than a state per square because a square's + * story is exactly those two memberships: nothing is a cat and crossed, and the + * empty squares carry no information worth storing. + */ +@Serializable +data class MeowdokuState( + val options: MeowdokuOptions, + /** The region id of every square, row by row. Ids run 0 until [side]. */ + val regions: List>, + /** 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 + +/** + * Taps a square expecting a cat. + * + * One move rather than two, because that is what the player is doing: the board + * decides whether the tap found a cat or cost a heart. A square that is already + * crossed over has to be un-crossed first — the tap that made the cross is + * exactly the tap this would repeat. + */ +@Serializable +@SerialName("cat") +data class PlaceCat(val square: Square) : MeowdokuMove + +/** + * Draws or erases a pencil cross on a square with no cat on it. + * + * Free, and it cannot be undone by the board: crosses are the player's notes, + * not the puzzle's. A cross on a square that actually holds a cat costs nothing + * to erase, which is why placing a cat never consults the marks. + */ +@Serializable +@SerialName("pencil") +data class TogglePencil(val square: Square) : MeowdokuMove + +/** + * Spends a hint to show the cat in the first row that lacks one. + * + * The original game sells hints; here they are simply counted, and the one + * choice that has to be made about them — a hint is placed by the board, not + * guessed at by the player — is the same either way. + */ +@Serializable +@SerialName("hint") +data object TakeHint : MeowdokuMove 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..be53e8e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt @@ -0,0 +1,192 @@ +package org.prolibertate.games.game.meowdoku + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig + +/** + * Meowdoku as a pure state machine. See RULES-meowdoku.md. + * + * The engine is a second single-seat game, alongside peg solitaire, and it + * inherits the same freedom: there is nobody to hide anything from, so + * [viewFor] hands out the whole board including the answer. The answer lives in + * the state rather than being re-derived because judging a tap against a + * deduction the player could have made is a different thing from judging it + * against what the board was dealt — and the deduction engine (crossing out + * rows, columns and colours) belongs to the player, not the referee. + */ +object MeowdokuRules : GameRules { + + 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. + * + * Cats go on squares that are neither cat nor cross; pencils work any + * square without a cat on it, adding or erasing a cross; a hint is offered + * while there are hints left and cats still missing. The list is written + * row by row, which is also the order the hint engine uses. + */ + override fun legalMoves(state: MeowdokuState, seat: Int): List { + 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 + if (square !in state.marks) cats += PlaceCat(square) + pencils += TogglePencil(square) + } + } + val hint = if (state.hintsLeft > 0) listOf(TakeHint) else emptyList() + return cats + pencils + hint + } + + override fun applyMove( + state: MeowdokuState, + seat: Int, + move: MeowdokuMove, + ): MeowdokuState { + require(seat == SOLO_SEAT) { "Meowdoku has one seat" } + require(move in legalMoves(state, seat)) { "$move is not a legal move" } + return when (move) { + is PlaceCat -> tap(state, move.square) + is TogglePencil -> pencil(state, move.square) + TakeHint -> hint(state) + } + } + + /** + * The tap that is the whole game: found a cat, or lost a heart. + * + * The square was already crossed? Then the tap is not offered at all — see + * [legalMoves] — so a player who pencilled over the truth has to notice and + * erase before tapping. That is the one way a board can be lost that the + * board itself did not do. + */ + private fun tap(state: MeowdokuState, square: Square): MeowdokuState { + if (state.isCatSquare(square)) { + val next = state.copy( + cats = state.cats + square, + lastSquare = square, + lastWrong = false, + ) + val note = if (next.solved) { + "All ${next.side} cats, ${next.wrongCats} wrong taps." + } else { + "Found a cat at row ${square.row + 1}, column ${square.col + 1}." + } + return next.copy(log = next.log + note) + } + val hearts = state.heartsLeft - 1 + val next = state.copy( + marks = state.marks + square, + heartsLeft = hearts, + wrongCats = state.wrongCats + 1, + lastSquare = square, + lastWrong = true, + ) + return if (hearts == 0) { + next.copy(log = next.log + "Out of hearts, with ${next.side - next.catsFound} cats still hiding.") + } else { + next + } + } + + private fun pencil(state: MeowdokuState, square: Square): MeowdokuState { + val marks = if (square in state.marks) state.marks - square else state.marks + square + return state.copy(marks = marks, lastSquare = square, lastWrong = false) + } + + /** + * Shows the cat in the first row without one. + * + * A revealed cat lands like a found one — and erases a pencil cross on its + * square, because the point of a hint is that the player stops having to + * work out whether the board contradicts their own notes. + */ + private fun hint(state: MeowdokuState): MeowdokuState { + val row = (0 until state.side).first { r -> state.cats.none { it.row == r } } + val square = Square(row, state.catCols[row]) + return state.copy( + cats = state.cats + square, + marks = state.marks - square, + hintsLeft = state.hintsLeft - 1, + lastSquare = square, + lastWrong = false, + log = state.log + "A hint shows the cat in row ${row + 1}.", + ) + } + + /** A board is finished when it is solved or when the hearts run out. */ + override fun isFinished(state: MeowdokuState): Boolean = state.solved || state.lost + + override fun currentSeat(state: MeowdokuState): Int? = + if (isFinished(state)) null else SOLO_SEAT + + override fun summary(state: MeowdokuState): String = when { + state.solved -> "Solved with ${state.heartsLeft} hearts to spare" + state.lost -> "Lost with ${state.catsFound} of ${state.side} cats found" + else -> "${state.catsFound} of ${state.side} cats, ${state.heartsLeft} hearts left" + } + + /** + * Nothing to strip. One person plays, and the answer in the state is there + * for the referee, not as a secret. Should a second seat ever race through + * the same board, this is where [MeowdokuState.catCols] gets hidden. + */ + override fun viewFor(state: MeowdokuState, seat: Int): MeowdokuState = state + + override fun encodeState(state: MeowdokuState): String = json.encodeToString(state) + override fun decodeState(json: String): MeowdokuState = this.json.decodeFromString(json) + override fun encodeMove(move: MeowdokuMove): String = json.encodeToString(move) + override fun decodeMove(json: String): MeowdokuMove = this.json.decodeFromString(json) +} 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 0303f70..68bf362 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -50,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 @@ -109,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()) } @@ -140,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) @@ -205,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 } @@ -1134,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)) { 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 7b806d9..50435f0 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -7,6 +7,7 @@ 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 @@ -87,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 @@ -131,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 @@ -544,6 +550,34 @@ fun PlayScreen( ) } + 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, + ) + } + GameCatalog.YAHTZEE -> { val controller = remember(route) { MatchController( 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..87b5bdf --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt @@ -0,0 +1,273 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.TextUnit +import org.prolibertate.games.game.meowdoku.MeowdokuMove +import org.prolibertate.games.game.meowdoku.MeowdokuState +import org.prolibertate.games.game.meowdoku.PlaceCat +import org.prolibertate.games.game.meowdoku.Square +import org.prolibertate.games.game.meowdoku.TakeHint +import org.prolibertate.games.game.meowdoku.TogglePencil +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.LocalEndGameHandle +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.theme.CardRed + +private const val TITLE = "Meowdoku" + +/** + * The region colours, one per cat the board can hold. + * + * The board never needs two regions of one colour — a side-ten board has ten + * regions and the palette has ten — so a region's colour is just its id, and + * nothing has to be computed to keep neighbours apart. The palette reads as + * watercolour on purpose: cats are dark ink on top of it, and a square the + * player has crossed over is too. + */ +private val RegionColours = listOf( + Color(0xFFF2C6C2), // rose + Color(0xFFF7E0A6), // butter + Color(0xFFCDE8C5), // mint + Color(0xFFC5E3F0), // sky + Color(0xFFD8CDEB), // lilac + Color(0xFFEBC9A0), // apricot + Color(0xFFBFE3D0), // spruce + Color(0xFFF0D7E3), // blossom + Color(0xFFCFCFE8), // periwinkle + Color(0xFFE3E7C1), // lime +) + +/** What a tap does. The original game has a pencil tool too, and this is it. */ +private enum class TapMode { CATS, PENCIL } + +/** + * The puzzle board. + * + * Cells are laid out by weight inside a square frame, which keeps the grid + * exactly as wide as it is tall on any screen, and scales the glyphs with the + * cell rather than the font setting: a cat is half a square no matter how big + * the square is. + */ +@Composable +fun MeowdokuScreen( + controller: MatchController, + onNewPuzzle: () -> Unit, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + var mode by remember { mutableStateOf(TapMode.CATS) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false } + } + return + } + + val places = legal.filterIsInstance().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." + mode == TapMode.CATS -> "${current.catsFound} of ${current.side} cats found. Tap where a cat lives." + else -> "Tap to cross a square over, or to erase a cross." + }, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Hearts " + "♥".repeat(current.heartsLeft) + + " · Hints " + current.hintsLeft, + style = MaterialTheme.typography.bodySmall, + ) + + Board( + state = current, + places = places, + pencils = pencils, + onTap = { square -> + when { + mode == TapMode.CATS && square in places -> + controller.submit(PlaceCat(square)) + + mode == TapMode.PENCIL && square in pencils -> + controller.submit(TogglePencil(square)) + } + }, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = mode == TapMode.CATS, + onClick = { mode = TapMode.CATS }, + label = { Text("Cats") }, + ) + FilterChip( + selected = mode == TapMode.PENCIL, + onClick = { mode = TapMode.PENCIL }, + label = { Text("Pencil") }, + ) + if (TakeHint in legal) { + FilterChip( + selected = false, + onClick = { controller.submit(TakeHint) }, + label = { Text("Hint") }, + ) + } + } + + if (finished) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onNewPuzzle) { Text("New puzzle") } + TextButton(onClick = onExit) { Text("Leave") } + } + } + + current.log.takeLast(2).forEach { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + + abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false } + } +} + +@Composable +private fun Board( + state: MeowdokuState, + places: Set, + pencils: Set, + onTap: (Square) -> Unit, +) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + // One cell's edge, so a cat is always about half a square whatever + // board was dealt. + val cell = maxWidth / state.side + Column(modifier = Modifier.aspectRatio(1f)) { + for (row in 0 until state.side) { + Row(modifier = Modifier.weight(1f)) { + for (col in 0 until state.side) { + val square = Square(row, col) + CellView( + square = square, + state = state, + tappable = square in places || square in pencils, + glyphSize = (cell.value * 0.55f).sp, + modifier = Modifier.weight(1f), + onTap = { onTap(square) }, + ) + } + } + } + } + } +} + +@Composable +private fun CellView( + square: Square, + state: MeowdokuState, + tappable: Boolean, + glyphSize: TextUnit, + modifier: Modifier, + onTap: () -> Unit, +) { + val isCat = square in state.cats + val isMark = square in state.marks + val justWrong = state.lastWrong && state.lastSquare == square + Box( + modifier = modifier + .aspectRatio(1f) + .border(1.dp, Color(0x33000000)) + .background(RegionColours[state.regionOf(square) % RegionColours.size]) + .then( + if (justWrong) Modifier.border(3.dp, CardRed) else Modifier + ) + .clickable(enabled = tappable) { onTap() }, + contentAlignment = Alignment.Center, + ) { + when { + isCat -> Text("🐱", fontSize = glyphSize) + isMark -> Text( + "✕", + color = Color(0xAA1C1B1F), + fontWeight = FontWeight.Bold, + fontSize = glyphSize, + ) + } + } +} + +@Composable +private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { Text("The board is left as it stands.") }, + confirmButton = { + val handle = LocalEndGameHandle.current + TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") } + }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +private fun LeftBoardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text(notice) }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } }, + ) +} 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/meowdoku/MeowdokuRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt new file mode 100644 index 0000000..8f34d28 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt @@ -0,0 +1,323 @@ +package org.prolibertate.games.game.meowdoku + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * The puzzle, the generator, and the referee. + * + * The generator is where a wrong board would hurt most — a second answer makes + * the deduction unfair and the hearts a lottery — so the generation tests run + * over seeds rather than one lucky one. + */ +class MeowdokuRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun soloConfig(options: MeowdokuOptions, seed: Long = 7L) = TableConfig( + gameId = "meowdoku", + seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)), + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun dealt(side: Int, seed: Long = 7L) = + MeowdokuRules.initialState(soloConfig(MeowdokuOptions(side = side), seed)) + + /** + * A puzzle that must exist, named so the assertions below read as facts + * rather than as unwrapping. + */ + private fun required(value: T?, message: String): T { + assertNotNull(message, value) + @Suppress("UNCHECKED_CAST") + return value as T + } + + // ---- generator --------------------------------------------------------- + + @Test + fun `every dealt board has exactly one answer`() { + for (side in 4..8) { + for (seed in 1L..6L) { + val puzzle = required( + MeowdokuGenerator.generate(side, seed), + "no puzzle for side $side seed $seed", + ) + assertEquals( + "the $side board dealt from seed $seed has more than one answer", + 1, + MeowdokuGenerator.countSolutions(side, puzzle.regions), + ) + } + } + } + + @Test + fun `the same seed deals the same board`() { + val a = MeowdokuGenerator.generate(6, 12345L) + val b = MeowdokuGenerator.generate(6, 12345L) + assertEquals(a, b) + } + + @Test + fun `the answer satisfies the cats' own rules`() { + for (seed in 0L..20L) { + val puzzle = required(MeowdokuGenerator.generate(7, seed), "no puzzle for seed $seed") + val cats = puzzle.solutionSquares() + assertEquals(7, cats.size) + assertEquals(7, cats.map { it.first }.distinct().size) + assertEquals(7, cats.map { it.second }.distinct().size) + for (i in cats.indices) { + for (j in i + 1 until cats.size) { + val (r1, c1) = cats[i] + val (r2, c2) = cats[j] + val touches = kotlin.math.abs(r1 - r2) <= 1 && kotlin.math.abs(c1 - c2) <= 1 + assertFalse("cats $cats touch", touches) + assertFalse("two cats share a colour", puzzle.regionOf(r1, c1) == puzzle.regionOf(r2, c2)) + } + } + } + } + + @Test + fun `every colour owns exactly one cat and is contiguous`() { + val puzzle = required(MeowdokuGenerator.generate(6, 99L), "no puzzle for seed 99") + val cells = (0 until 6).flatMap { r -> (0 until 6).map { c -> r to c } } + + val owned = (0 until 6).map { region -> + cells.filter { (r, c) -> puzzle.regionOf(r, c) == region } + } + owned.forEach { cellsOfRegion -> + assertTrue("an empty colour", cellsOfRegion.isNotEmpty()) + val cats = cellsOfRegion.count { (r, c) -> puzzle.catCols[r] == c } + assertEquals("a colour with ${cats}cats", 1, cats) + + // Contiguous: a breadth-first walk from the first cell reaches all. + val rest = cellsOfRegion.toMutableSet() + val queue = ArrayDeque(listOf(rest.first())) + val reached = mutableSetOf(queue.first()) + while (queue.isNotEmpty()) { + val (r, c) = queue.removeFirst() + for ((dr, dc) in listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0)) { + val next = (r + dr) to (c + dc) + if (next in rest && reached.add(next)) queue.add(next) + } + } + assertEquals("a colour in two pieces", rest.size, reached.size) + } + } + + @Test + fun `the cats can always be settled`() { + // A four board is the smallest deal, and its answer is the point where + // no-touch and one-per-line first stop contradicting each other. + val rng = SeededRng(42) + val cols = MeowdokuGenerator.placeCats(4, rng) + assertEquals(4, cols.size) + // The placement the generator returns is internally consistent. + for (row in 0 until 4) { + for (earlier in 0 until row) { + assertFalse(MeowdokuGenerator.catFitsHere(row, cols[earlier], cols)) + } + } + } + + // ---- the referee ------------------------------------------------------- + + @Test + fun `the board opens empty and seated for one`() { + val state = dealt(6) + assertTrue(state.cats.isEmpty()) + assertTrue(state.marks.isEmpty()) + assertEquals(6, state.side) + assertEquals(3, state.heartsLeft) + assertEquals(0, state.catsFound) + assertEquals(0, MeowdokuRules.currentSeat(state)) + assertFalse(MeowdokuRules.isFinished(state)) + assertTrue(MeowdokuRules.legalMoves(state, 1).isEmpty()) + } + + @Test + fun `a correct tap lands a cat and a wrong tap costs a heart`() { + val state = dealt(6) + val square = Square(0, state.catCols[0]) + val found = MeowdokuRules.applyMove(state, 0, PlaceCat(square)) + assertTrue(square in found.cats) + assertEquals(3, found.heartsLeft) + + val wrongRow = (0 until state.side).first { it != square.row } + val wrong = Square(wrongRow, if (state.catCols[wrongRow] == 0) 1 else 0) + val missed = MeowdokuRules.applyMove(found, 0, PlaceCat(wrong)) + assertTrue(wrong in missed.marks) + assertFalse(wrong in missed.cats) + assertEquals(2, missed.heartsLeft) + assertEquals(1, missed.wrongCats) + } + + @Test + fun `a board is solved by finding every cat`() { + var state = dealt(5) + for (row in 0 until 5) { + state = MeowdokuRules.applyMove(state, 0, PlaceCat(Square(row, state.catCols[row]))) + } + assertTrue(state.solved) + assertTrue(MeowdokuRules.isFinished(state)) + assertNull(MeowdokuRules.currentSeat(state)) + assertTrue(MeowdokuRules.legalMoves(state, 0).isEmpty()) + assertTrue(MeowdokuRules.summary(state).startsWith("Solved")) + } + + @Test + fun `running out of hearts loses the board`() { + val state = dealt(8, seed = 3L) + var lost = state + var row = 0 + while (lost.heartsLeft > 0) { + // A wrong square: not the answer's column, in a row with no cat. + val wrongCol = if (state.catCols[row] == 0) 1 else 0 + val before = lost.catsFound + lost = MeowdokuRules.applyMove(lost, 0, PlaceCat(Square(row, wrongCol))) + assertEquals(before, lost.catsFound) + row = (row + 1) % state.side + } + assertTrue(lost.lost) + assertTrue(MeowdokuRules.isFinished(lost)) + assertEquals(0, lost.catsFound) + assertTrue(MeowdokuRules.summary(lost).startsWith("Lost")) + } + + @Test + fun `a tapped square that is crossed over is not offered`() { + val state = dealt(6) + val wrongRow = 1 + val wrongCol = if (state.catCols[wrongRow] == 0) 1 else 0 + val marked = MeowdokuRules.applyMove(state, 0, PlaceCat(Square(wrongRow, wrongCol))) + assertThrows(IllegalArgumentException::class.java) { + MeowdokuRules.applyMove(marked, 0, PlaceCat(Square(wrongRow, wrongCol))) + } + // A pencil erases the cross and opens the tap again. + val erased = MeowdokuRules.applyMove(marked, 0, TogglePencil(Square(wrongRow, wrongCol))) + assertFalse(Square(wrongRow, wrongCol) in erased.marks) + } + + @Test + fun `pencil marks are free notes`() { + val state = dealt(6) + val square = Square(2, if (state.catCols[2] == 5) 4 else 5) + val marked = MeowdokuRules.applyMove(state, 0, TogglePencil(square)) + assertTrue(square in marked.marks) + assertEquals(3, marked.heartsLeft) + assertEquals(0, marked.wrongCats) + + val erased = MeowdokuRules.applyMove(marked, 0, TogglePencil(square)) + assertTrue(erased.marks.isEmpty()) + } + + @Test + fun `a cat square is never a pencil square`() { + val state = dealt(6) + val cat = Square(0, state.catCols[0]) + val found = MeowdokuRules.applyMove(state, 0, PlaceCat(cat)) + assertFalse( + "legal moves offer pencil on a cat", + MeowdokuRules.legalMoves(found, 0).any { + it is TogglePencil && it.square == cat + }, + ) + } + + @Test + fun `a hint shows a missing cat and costs one token`() { + val state = dealt(6) + val hinted = MeowdokuRules.applyMove(state, 0, TakeHint) + assertEquals(1, hinted.catsFound) + assertEquals(2, hinted.hintsLeft) + + // Out of hints the move stops being offered. + var drained = MeowdokuRules.applyMove( + MeowdokuRules.applyMove(hinted, 0, TakeHint), + 0, + TakeHint, + ) + assertEquals(0, drained.hintsLeft) + // A note on a square that is not a cat, so the pencil move is legal. + val note = Square(0, if (state.catCols[0] == 0) 1 else 0) + drained = MeowdokuRules.applyMove(drained, 0, TogglePencil(note)) + assertFalse(TakeHint in MeowdokuRules.legalMoves(drained, 0)) + } + + @Test + fun `a hint over a wrong pencil reveals the cat anyway`() { + val state = dealt(6) + val truth = Square(0, state.catCols[0]) + val pencilled = MeowdokuRules.applyMove(state, 0, TogglePencil(truth)) + val revealed = MeowdokuRules.applyMove(pencilled, 0, TakeHint) + assertTrue(truth in revealed.cats) + assertFalse(truth in revealed.marks) + } + + @Test + fun `the computer can finish any board it is handed`() { + var state = dealt(7) + var guard = 0 + while (!MeowdokuRules.isFinished(state) && guard++ < 100) { + val legal = MeowdokuRules.legalMoves(state, 0) + assertFalse("stuck with cats still missing", legal.isEmpty()) + state = MeowdokuRules.applyMove(state, 0, MeowdokuAi.chooseMove(state, 0, legal)) + } + assertTrue("the AI did not solve the board", state.solved) + } + + // ---- state across the wire and the save slot --------------------------- + + @Test + fun `state and moves survive their json`() { + val state = dealt(6) + val decoded = MeowdokuRules.decodeState(MeowdokuRules.encodeState(state)) + assertEquals(state, decoded) + + val move: MeowdokuMove = PlaceCat(Square(1, 1)) + assertEquals(move, MeowdokuRules.decodeMove(MeowdokuRules.encodeMove(move))) + assertEquals(TakeHint, MeowdokuRules.decodeMove(MeowdokuRules.encodeMove(TakeHint))) + } + + @Test + fun `nothing is hidden from the only seat`() { + val state = dealt(6) + assertEquals(state, MeowdokuRules.viewFor(state, 0)) + } + + @Test + fun `a table with two seats is refused`() { + val config = TableConfig( + gameId = "meowdoku", + seats = listOf( + PlayerSlot(0, "P0", PlayerKind.HUMAN_LOCAL, 0), + PlayerSlot(1, "P1", PlayerKind.AI, 1), + ), + optionsJson = json.encodeToString(MeowdokuOptions()), + seed = 1L, + ) + assertThrows(IllegalArgumentException::class.java) { + MeowdokuRules.initialState(config) + } + } + + @Test + fun `the options guard their own board`() { + assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(side = 3) } + assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(side = 11) } + assertThrows(IllegalArgumentException::class.java) { MeowdokuOptions(hearts = 0) } + } +} diff --git a/docs/README.md b/docs/README.md index 934bd34..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) | diff --git a/docs/RULES-meowdoku.md b/docs/RULES-meowdoku.md new file mode 100644 index 0000000..15531e1 --- /dev/null +++ b/docs/RULES-meowdoku.md @@ -0,0 +1,87 @@ +# Meowdoku + +A board of colours, and a cat in every row, column and colour — no two sharing +even a corner. + +## The puzzle + +The board is **N by N** and coloured into **N regions**. Exactly one cat hides +in every row, every column and every region, and no two cats may touch — not +along a side and not at a corner, so a cat rules out all eight squares around +itself. Those four constraints together always leave exactly one arrangement. + +Uniqueness is not decoration. The game is deduction, and a second answer means +a square the player cannot prove either way. Every board is therefore generated +and then searched: build the colours, count the ways a board can be answered, +and if it is more than one, throw the board away and deal another. The count is +a plain depth-first search over the rows with the obvious exclusions propagated, +and it sees only the colours and the rules — never the cats it was grown from. + +## Playing it + +**Tap a square where a cat lives.** A correct tap lands the cat; a wrong one +costs a heart and crosses the square over, because being wrong here is +information and the board keeps it for you. Finding all N cats wins; losing the +hearts ends the board with the rest still hidden. + +The **Pencil** toggle is for the marks you make yourself. They are free, they +cost nothing to erase, and the board does not judge them. The one trap they +carry: a square crossed over is a square you have decided against, so tapping a +cat onto it is refused until the pencil erases the note. The original game +draws crosses for you on a wrong tap for the same reason — a wrong square +crossed twice is the same mistake twice — and here the same thing happens +whether the cross came from the heart or from the pencil. + +A **Hint** spends a token and shows the cat in the first row that still lacks +one. The original sells these as tools; here they are simply counted, three per +board by default, which keeps the hint a decision rather than a reflex. + +## The heart + +Why a wrong tap costs anything at all: the puzzle is only hard if guessing is. +A board with no penalty is a board you can solve by tapping every square, and +the hearts are what make the deduction on a colour you have not reasoned about +yet feel like a choice. Three is the number the original deals, and it stays +three by default. + +## Options + +| | | +| --- | --- | +| **Board** | 4×4 up to 10×10; the number of cats equals the side | +| **Hearts** | 1 to 5, default 3 | +| **Hints** | 0 to 3, default 3 | + +Boards are dealt from the table's seed, so a saved game resumes to the same +cats in the same colours, and **New puzzle** moves the seed to deal a different +board. Smaller sides are gentler: a 4×4 is the shortest way to learn what +no-touch does to a corner, and the ten-wide boards are for people who have +already made that mistake. + +## How a board is dealt + +The order matters, and it is the order the original game uses: + +1. **Settle the cats first.** One per row, one per column, no two touching, + chosen by backtracking over shuffled column orders. +2. **Grow the colours around them.** Each region starts as its cat's square and + expands outwards one square at a time; some regions are grown as straight + bars along the cat's row or column, which is what stops the board reading as + texture, and the rest take whichever neighbour is smallest so nothing + sprawls while another dies young. +3. **Check the answer.** Count solutions up to two; keep the board only when + the count comes back exactly one. + +The generator's rejection loop is why the same seed can take a few attempts: +most boards are unique on the first try, and the odd seed has to walk until it +finds one. + +## Where this engine differs from the original + +The app is a game collection, not a live-service puzzle, so what sits around +the original's gameplay is left behind: lives-as-monetisation (watching an ad +for a heart), daily streaks and leaderboards, an Elo-matched bank of +hand-authored levels, and the cosmetic layer (cat animations, combo feedback, +confetti). This engine **deals its boards from a seed** instead of shipping a +level bank, and the rules are the part worth keeping: the cats, the colours, +the no-touch, the hearts, the one answer. -- 2.47.3 From fae031d2b5aca6171e9e4d008b7b979cc6574817 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:49:18 +0000 Subject: [PATCH 4/8] Katan: Catan Universe style landscape HUD and trade board Drag-to-build with confirm/cancel, pinch/pan board, round turn and trade buttons, and a trade board panel (bank, player, reply). Findings from the Catan Universe 2.7.5 reverse engineering are in docs/RE-catan-universe.md. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WrH771kLJmTHnEfcmV2YDx --- .../games/ui/game/catan/CatanBoardView.kt | 24 + .../games/ui/game/catan/CatanHud.kt | 452 ++++++++++++++++++ .../games/ui/game/catan/CatanInteraction.kt | 36 ++ .../games/ui/game/catan/CatanScreen.kt | 177 +++++-- .../games/ui/game/catan/CatanText.kt | 22 + .../games/ui/game/catan/CatanTradeBoard.kt | 415 ++++++++++++++++ .../games/ui/game/catan/CatanHudTest.kt | 56 +++ docs/RE-catan-universe.md | 73 +++ docs/UI-catan.md | 23 + 9 files changed, 1237 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanTradeBoard.kt create mode 100644 app/src/test/java/org/prolibertate/games/ui/game/catan/CatanHudTest.kt create mode 100644 docs/RE-catan-universe.md 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/CatanHud.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt new file mode 100644 index 0000000..45c929b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt @@ -0,0 +1,452 @@ +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(6.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(52.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 + } +} + +/** ✓ over ✗: 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, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + HudRoundButton("✓", caption = CatanText.CONFIRM_BUILD, enabled = staged, tint = Color(0xFF2E7D32), onClick = onConfirm) + HudRoundButton("✕", caption = CatanText.CANCEL_BUILD, enabled = staged, tint = Color(0xFFC62828), 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(4.dp)) { + HudPlayerStrip(state, seats, pointsOf, style) + Box(Modifier.weight(1f).fillMaxWidth()) { + ZoomPanBox(Modifier.fillMaxSize().padding(start = 76.dp, end = 76.dp, top = 52.dp, bottom = 96.dp)) { + board(Modifier.onGloballyPositioned(onBoardCoords)) + } + + // What is going on, and the dice. Two lines, always. + Row( + Modifier.align(Alignment.TopCenter).fillMaxWidth().padding(horizontal = 8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.78f)) + .padding(horizontal = 10.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + androidx.compose.material3.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 = 8.dp, top = 40.dp), + onTap = onArm, + onDragStart = onDragStart, + onDrag = onDrag, + onDragEnd = onDragEnd, + ) + + // Right, middle: place or cancel the lined-up piece. + HudConfirmButtons( + staged = staged != null, + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 8.dp, top = 40.dp), + onConfirm = onConfirm, + onCancel = onCancelStaged, + ) + + // Bottom centre: your cards. + Box( + Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp, start = 120.dp, end = 250.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)) + .horizontalScroll(rememberScrollState()) + .padding(8.dp), + ) { CatanHand(state, localSeat) } + + // Bottom left: the log. + Column(Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 10.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 = 44.dp, onClick = onToggleLog) + } + + // Bottom right: the turn button, trade, and the card actions. + Column( + Modifier.align(Alignment.BottomEnd).padding(end = 8.dp, bottom = 10.dp), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + 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) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Bottom) { + HudRoundButton("⇄", caption = CatanText.TRADE, enabled = actions.canTrade, diameter = 56.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 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), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + (0 until state.playerCount).forEach { seat -> + CatanPlayerCard( + state = state, + seat = seat, + seats = seats, + points = pointsOf(seat), + modifier = Modifier.weight(1f), + style = style, + compact = true, + ) + } + } +} 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/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/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. -- 2.47.3 From ccf8d5a1ba06fbc2c79a28c20ca44e937aefab61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:58:58 +0000 Subject: [PATCH 5/8] Katan HUD: compact landscape layout for phone heights Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WrH771kLJmTHnEfcmV2YDx --- .../games/ui/game/catan/CatanHud.kt | 116 +++++++++--------- 1 file changed, 61 insertions(+), 55 deletions(-) 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 index 45c929b..a8180ad 100644 --- 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 @@ -101,7 +101,7 @@ fun HudBuildColumn( onDrag: (Offset) -> Unit, onDragEnd: () -> Unit, ) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.CenterHorizontally) { + 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) } @@ -127,7 +127,7 @@ private fun BuildChip( Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = Modifier - .size(52.dp) + .size(46.dp) .clip(shape) .background( when { @@ -183,7 +183,7 @@ object CatanHudGlyphs { } } -/** ✓ over ✗: confirm or cancel a staged placement. Always laid out; enabled only while something is staged. */ +/** ✓ beside ✗: confirm or cancel a staged placement. Always laid out; enabled only while something is staged. */ @Composable fun HudConfirmButtons( staged: Boolean, @@ -191,9 +191,9 @@ fun HudConfirmButtons( onConfirm: () -> Unit, onCancel: () -> Unit, ) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { - HudRoundButton("✓", caption = CatanText.CONFIRM_BUILD, enabled = staged, tint = Color(0xFF2E7D32), onClick = onConfirm) - HudRoundButton("✕", caption = CatanText.CANCEL_BUILD, enabled = staged, tint = Color(0xFFC62828), onClick = onCancel) + 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) } } @@ -309,23 +309,24 @@ fun CatanHud( onProgress: () -> Unit, board: @Composable (Modifier) -> Unit, ) { - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) { HudPlayerStrip(state, seats, pointsOf, style) Box(Modifier.weight(1f).fillMaxWidth()) { - ZoomPanBox(Modifier.fillMaxSize().padding(start = 76.dp, end = 76.dp, top = 52.dp, bottom = 96.dp)) { + // 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).fillMaxWidth().padding(horizontal = 8.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.78f)) - .padding(horizontal = 10.dp, vertical = 2.dp), + 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), ) { - androidx.compose.material3.Text( + Text( text = notice ?: prompt, style = MaterialTheme.typography.bodySmall, color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified, @@ -341,32 +342,39 @@ fun CatanHud( kinds = actions.buildKinds, canBuild = actions.canBuild, armed = building, - modifier = Modifier.align(Alignment.CenterStart).padding(start = 8.dp, top = 40.dp), + modifier = Modifier.align(Alignment.CenterStart).padding(start = 6.dp), onTap = onArm, onDragStart = onDragStart, onDrag = onDrag, onDragEnd = onDragEnd, ) - // Right, middle: place or cancel the lined-up piece. - HudConfirmButtons( - staged = staged != null, - modifier = Modifier.align(Alignment.CenterEnd).padding(end = 8.dp, top = 40.dp), - onConfirm = onConfirm, - onCancel = onCancelStaged, - ) + // 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 = 10.dp, start = 120.dp, end = 250.dp) - .clip(RoundedCornerShape(14.dp)) + 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(8.dp), + .padding(6.dp), ) { CatanHand(state, localSeat) } // Bottom left: the log. - Column(Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 10.dp)) { + Column(Modifier.align(Alignment.BottomStart).padding(start = 6.dp, bottom = 4.dp)) { if (showLog) { Surface( modifier = Modifier.padding(bottom = 6.dp), @@ -377,28 +385,18 @@ fun CatanHud( CatanLog(state, Modifier.padding(8.dp).height(150.dp).width(260.dp)) } } - HudRoundButton("☰", caption = CatanText.LOG_BUTTON, diameter = 44.dp, onClick = onToggleLog) + HudRoundButton("☰", caption = CatanText.LOG_BUTTON, diameter = 40.dp, onClick = onToggleLog) } - // Bottom right: the turn button, trade, and the card actions. - Column( - Modifier.align(Alignment.BottomEnd).padding(end = 8.dp, bottom = 10.dp), - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(4.dp), + // 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, ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - 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) - } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Bottom) { - HudRoundButton("⇄", caption = CatanText.TRADE, enabled = actions.canTrade, diameter = 56.dp, onClick = onTrade) - PrimaryTurnButton(actions, onRoll, onEndTurn, onEndFreeRoads, onEndSmith, onRepickSetup) - } + 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)) @@ -428,7 +426,7 @@ private fun PrimaryTurnButton( 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 row across the top. */ +/** 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, @@ -436,17 +434,25 @@ private fun HudPlayerStrip( pointsOf: (Int) -> Int, style: CatanStyle, ) { - Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { (0 until state.playerCount).forEach { seat -> - CatanPlayerCard( - state = state, - seat = seat, - seats = seats, - points = pointsOf(seat), - modifier = Modifier.weight(1f), - style = style, - compact = true, - ) + 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) + } } } } -- 2.47.3 From 7942adeaf7684c807df6ae7ea1d03cf1201f4a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 01:14:45 +0000 Subject: [PATCH 6/8] Meowdoku: rules, AI and screen refinements Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ --- .../games/game/meowdoku/MeowdokuAi.kt | 17 +- .../games/game/meowdoku/MeowdokuModel.kt | 28 ++- .../games/game/meowdoku/MeowdokuRules.kt | 25 +- .../games/ui/game/MeowdokuScreen.kt | 226 +++++++++++++----- .../games/game/meowdoku/MeowdokuRulesTest.kt | 40 +++- docs/RULES-meowdoku.md | 37 ++- 6 files changed, 255 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt index 93bd59e..b7c2cd3 100644 --- a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuAi.kt @@ -25,19 +25,12 @@ object MeowdokuAi : GameAi { require(legal.isNotEmpty()) { "No move available" } val places = legal.filterIsInstance().associateBy { it.square } - // The first cat still missing. A pencil cross in front of it is the - // player's own note being wrong, so erase it; a hint spends one token - // and reveals the square regardless. + // The first cat still missing. A cross on its square is no obstacle: + // crossed squares take attempts like anything else, and a correct one + // replaces the cross. for (row in 0 until state.side) { - val missing = state.cats.none { it.row == row } - if (!missing) continue - val square = Square(row, state.catCols[row]) - places[square]?.let { return it } - if (square in state.marks) { - if (TakeHint in legal && state.hintsLeft > 0) return TakeHint - legal.filterIsInstance() - .firstOrNull { it.square == square } - ?.let { return it } + if (state.cats.none { it.row == row }) { + places[Square(row, state.catCols[row])]?.let { return it } } } diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt index cae5e88..089426e 100644 --- a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuModel.kt @@ -4,11 +4,11 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable // Meowdoku hides N cats in an N by N board: exactly one cat in every row, every -// column and every colour, and no two cats sharing even a corner. The player -// taps the squares they believe hold a cat; the board only ever accepts a -// correct tap, so a cat that lands is a cat that was always going to be there. -// A wrong tap costs a heart and crosses the square over, which is what makes -// pencil marks a choice rather than a necessity. +// column and every colour, and no two cats sharing even a corner. A tap crosses +// a square over; a second tap on that square asks the board whether a cat +// lives there. A right answer lands the cat, a wrong one costs a heart and +// keeps the cross — so the crossing over is both the player's note and the +// first half of every guess, and the hearts are what stop it being a lottery. /** One square of the board. */ @Serializable @@ -83,23 +83,25 @@ data class MeowdokuState( sealed interface MeowdokuMove /** - * Taps a square expecting a cat. + * The second tap of a double tap: the cat attempt. * * One move rather than two, because that is what the player is doing: the board - * decides whether the tap found a cat or cost a heart. A square that is already - * crossed over has to be un-crossed first — the tap that made the cross is - * exactly the tap this would repeat. + * decides whether the tap found a cat or cost a heart. The square may carry the + * cross its own first tap just drew — that cross is part of the double tap, + * not a decision against it — and a correct attempt replaces it. */ @Serializable @SerialName("cat") data class PlaceCat(val square: Square) : MeowdokuMove /** - * Draws or erases a pencil cross on a square with no cat on it. + * Draws or erases a cross on a square with no cat on it. * - * Free, and it cannot be undone by the board: crosses are the player's notes, - * not the puzzle's. A cross on a square that actually holds a cat costs nothing - * to erase, which is why placing a cat never consults the marks. + * This is the first tap of a tap pair and every cell of a drag stroke, so it is + * the note-taking half of the scheme: free, unrewarded, and the board cannot + * lose a heart over it. A cross drawn here can still be tapped through with a + * cat attempt — the note is the player's, and only they get to change their + * mind about it. */ @Serializable @SerialName("pencil") diff --git a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt index be53e8e..ed2a0a4 100644 --- a/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/meowdoku/MeowdokuRules.kt @@ -69,10 +69,14 @@ object MeowdokuRules : GameRules { /** * Every tap the board would take. * - * Cats go on squares that are neither cat nor cross; pencils work any - * square without a cat on it, adding or erasing a cross; a hint is offered - * while there are hints left and cats still missing. The list is written - * row by row, which is also the order the hint engine uses. + * The scheme is the original's: the first tap crosses a square over and + * opens a short window, and a second tap in that window is the cat attempt + * — so an attempt is offered on every square without a cat, crossed or + * not. The cross the attempt replaces is exactly the cross the first tap + * drew. Cats, once placed, are never tapped again: they are not a guess + * you take back. + * + * The list is written row by row, which is also the order the hint uses. */ override fun legalMoves(state: MeowdokuState, seat: Int): List { if (seat != SOLO_SEAT || isFinished(state)) return emptyList() @@ -82,7 +86,7 @@ object MeowdokuRules : GameRules { for (col in 0 until state.side) { val square = Square(row, col) if (square in state.cats) continue - if (square !in state.marks) cats += PlaceCat(square) + cats += PlaceCat(square) pencils += TogglePencil(square) } } @@ -105,17 +109,18 @@ object MeowdokuRules : GameRules { } /** - * The tap that is the whole game: found a cat, or lost a heart. + * The second tap of a double tap: found a cat, or lost a heart. * - * The square was already crossed? Then the tap is not offered at all — see - * [legalMoves] — so a player who pencilled over the truth has to notice and - * erase before tapping. That is the one way a board can be lost that the - * board itself did not do. + * A correct tap clears the cross the first tap left behind — the cat + * replaces it. A wrong one leaves the cross where it is and spends a + * heart, because a wrong square crossed twice is the same mistake twice, + * and the board remembers it either way. */ private fun tap(state: MeowdokuState, square: Square): MeowdokuState { if (state.isCatSquare(square)) { val next = state.copy( cats = state.cats + square, + marks = state.marks - square, lastSquare = square, lastWrong = false, ) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt index 87b5bdf..6fcb32a 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/MeowdokuScreen.kt @@ -2,7 +2,6 @@ package org.prolibertate.games.ui.game import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.FilterChip @@ -23,10 +24,13 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -41,9 +45,20 @@ import org.prolibertate.games.net.MatchController import org.prolibertate.games.ui.LocalEndGameHandle import org.prolibertate.games.ui.ScreenScaffold import org.prolibertate.games.ui.theme.CardRed +import kotlin.math.abs +import kotlin.math.roundToInt private const val TITLE = "Meowdoku" +/** + * How long the second tap of a double tap may take to arrive. + * + * 350 ms, the number the original game ships: long enough that a deliberate + * one-two still reads as one question, short enough that nobody pairs two + * taps they meant for two separate squares. + */ +private const val DOUBLE_TAP_WINDOW_MILLIS = 350L + /** * The region colours, one per cat the board can hold. * @@ -66,16 +81,14 @@ private val RegionColours = listOf( Color(0xFFE3E7C1), // lime ) -/** What a tap does. The original game has a pencil tool too, and this is it. */ -private enum class TapMode { CATS, PENCIL } - /** - * The puzzle board. + * The puzzle board, tapped the way the original's is. * - * Cells are laid out by weight inside a square frame, which keeps the grid - * exactly as wide as it is tall on any screen, and scales the glyphs with the - * cell rather than the font setting: a cat is half a square no matter how big - * the square is. + * One tap crosses a square over; a second tap on the same square within + * [DOUBLE_TAP_WINDOW_MILLIS] asks the board whether a cat lives there. Drag + * strokes paint or erase crosses along the line the finger takes, which is + * how a row gets crossed out in one gesture rather than eight taps. Cats are + * permanent: tapping one does nothing, and a stroke skips over it. */ @Composable fun MeowdokuScreen( @@ -87,7 +100,6 @@ fun MeowdokuScreen( val legal by controller.legalMoves.collectAsState() val abandoned by controller.abandoned.collectAsState() - var mode by remember { mutableStateOf(TapMode.CATS) } var confirmingEnd by remember { mutableStateOf(false) } val endGameAction: @Composable () -> Unit = { @@ -118,8 +130,8 @@ fun MeowdokuScreen( current.solved -> "Found every cat" + if (current.wrongCats == 0) ", not one tap wasted." else ", with ${current.wrongCats} wrong taps." current.lost -> "Out of hearts, with ${current.side - current.catsFound} cats still hiding." - mode == TapMode.CATS -> "${current.catsFound} of ${current.side} cats found. Tap where a cat lives." - else -> "Tap to cross a square over, or to erase a cross." + else -> "${current.catsFound} of ${current.side} cats found. " + + "Tap to cross a square, tap it again for a cat." }, fontWeight = FontWeight.Bold, ) @@ -131,37 +143,20 @@ fun MeowdokuScreen( Board( state = current, - places = places, - pencils = pencils, - onTap = { square -> - when { - mode == TapMode.CATS && square in places -> - controller.submit(PlaceCat(square)) - - mode == TapMode.PENCIL && square in pencils -> - controller.submit(TogglePencil(square)) - } - }, + // Moves are refused rather than hidden: an attempt or a note + // the board would reject (a tap on a cat, mostly) has to die + // here, in the gesture, where the player can be doing nothing + // else the same instant. + onNote = { square -> if (square in pencils) controller.submit(TogglePencil(square)) }, + onAttempt = { square -> if (square in places) controller.submit(PlaceCat(square)) }, ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (TakeHint in legal) { FilterChip( - selected = mode == TapMode.CATS, - onClick = { mode = TapMode.CATS }, - label = { Text("Cats") }, + selected = false, + onClick = { controller.submit(TakeHint) }, + label = { Text("Hint") }, ) - FilterChip( - selected = mode == TapMode.PENCIL, - onClick = { mode = TapMode.PENCIL }, - label = { Text("Pencil") }, - ) - if (TakeHint in legal) { - FilterChip( - selected = false, - onClick = { controller.submit(TakeHint) }, - label = { Text("Hint") }, - ) - } } if (finished) { @@ -181,30 +176,110 @@ fun MeowdokuScreen( } } +/** + * The board as one gesture surface. + * + * Cells are drawn by weight inside a square frame, but input is not per-cell: + * a stroke is one continuous gesture, so the pointer works in board + * coordinates and maps positions to squares itself. The cell list renders + * underneath, and the live state is read through [rememberUpdatedState] + * because a gesture outlives the recompositions it lands in. + */ @Composable private fun Board( state: MeowdokuState, - places: Set, - pencils: Set, - onTap: (Square) -> Unit, + onNote: (Square) -> Unit, + onAttempt: (Square) -> Unit, ) { + val side = state.side + val current by rememberUpdatedState(state) + val note by rememberUpdatedState(onNote) + val attempt by rememberUpdatedState(onAttempt) + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { // One cell's edge, so a cat is always about half a square whatever // board was dealt. - val cell = maxWidth / state.side - Column(modifier = Modifier.aspectRatio(1f)) { - for (row in 0 until state.side) { - Row(modifier = Modifier.weight(1f)) { - for (col in 0 until state.side) { - val square = Square(row, col) - CellView( - square = square, - state = state, - tappable = square in places || square in pencils, - glyphSize = (cell.value * 0.55f).sp, - modifier = Modifier.weight(1f), - onTap = { onTap(square) }, - ) + val cell = maxWidth / side + Box( + modifier = Modifier + .aspectRatio(1f) + .pointerInput(side) { + val cellPx = size.width.toFloat() / side + var lastTap: Square? = null + var lastTapAt = 0L + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val board = current + val start = squareAt(down.position, cellPx, side) + ?: return@awaitEachGesture + if (start in board.cats) return@awaitEachGesture + + val now = System.currentTimeMillis() + if (start == lastTap && now - lastTapAt < DOUBLE_TAP_WINDOW_MILLIS) { + // The second tap of a pair: the cat attempt, and + // no note — the cross it lands on was drawn by + // this pair's first tap, and the board replaces it. + lastTap = null + attempt(start) + do { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + change?.consume() + } while (change != null && change.pressed) + return@awaitEachGesture + } + + // The first tap: a note, and the stroke that may follow + // paints the same note everywhere it goes. + lastTap = start + lastTapAt = now + val erasing = start in board.marks + note(start) + val marks = board.marks.toMutableSet() + marks.flip(start) + var previous = start + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null || !change.pressed) break + change.consume() + val here = squareAt(change.position, cellPx, side) ?: continue + if (here == previous) continue + for (square in path(previous, here)) { + if (square in board.cats) continue + // `marks` is the stroke's own mirror: the base + // board plus every note this stroke has + // already submitted. The state flow lags a + // frame behind submissions, and a stroke that + // crosses a square twice must leave it at the + // stroke's target, not flip it back. + // + // Erasing means "flip everything still + // crossed"; painting means "flip everything + // still blank" — which is the same test read + // two ways: flip while marked == erasing. + if ((square in marks) == erasing) { + note(square) + marks.flip(square) + } + } + previous = here + } + } + } + ) { + Column(modifier = Modifier.aspectRatio(1f)) { + for (row in 0 until side) { + Row(modifier = Modifier.weight(1f)) { + for (col in 0 until side) { + CellView( + square = Square(row, col), + state = current, + glyphSize = (cell.value * 0.55f).sp, + modifier = Modifier.weight(1f), + ) + } } } } @@ -212,14 +287,46 @@ private fun Board( } } +/** Which square a pointer position is over, or null if it has left the board. */ +private fun squareAt(position: Offset, cellPx: Float, side: Int): Square? { + val col = (position.x / cellPx).toInt() + val row = (position.y / cellPx).toInt() + if (row !in 0 until side || col !in 0 until side) return null + return Square(row, col) +} + +/** Add the square, or drop it if it was already there — the stroke's own toggle. */ +private fun MutableSet.flip(square: Square) { + if (!add(square)) remove(square) +} + +/** + * The squares between two consecutive pointer reads, endpoints not repeated. + * + * A fast stroke publishes fewer positions than it crosses cells, so the line + * between two reads is walked in whole-cell steps. [path] returns at most one + * square per cell crossed, in order, which is what lets a stroke cross a row + * in one drag without missing the squares between two events. + */ +private fun path(from: Square, to: Square): List { + val dr = to.row - from.row + val dc = to.col - from.col + val steps = maxOf(abs(dr), abs(dc)) + if (steps <= 1) return listOf(to) + return (1..steps).map { i -> + Square( + from.row + (dr * i.toFloat() / steps).roundToInt(), + from.col + (dc * i.toFloat() / steps).roundToInt(), + ) + } +} + @Composable private fun CellView( square: Square, state: MeowdokuState, - tappable: Boolean, glyphSize: TextUnit, modifier: Modifier, - onTap: () -> Unit, ) { val isCat = square in state.cats val isMark = square in state.marks @@ -231,8 +338,7 @@ private fun CellView( .background(RegionColours[state.regionOf(square) % RegionColours.size]) .then( if (justWrong) Modifier.border(3.dp, CardRed) else Modifier - ) - .clickable(enabled = tappable) { onTap() }, + ), contentAlignment = Alignment.Center, ) { when { diff --git a/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt index 8f34d28..9521bf1 100644 --- a/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/meowdoku/MeowdokuRulesTest.kt @@ -198,17 +198,37 @@ class MeowdokuRulesTest { } @Test - fun `a tapped square that is crossed over is not offered`() { + fun `a crossed square still takes a cat attempt`() { + // The double tap is one gesture pair: the first tap crosses the square + // and the second asks it. The cross the pair drew cannot veto the + // question that follows it. val state = dealt(6) - val wrongRow = 1 - val wrongCol = if (state.catCols[wrongRow] == 0) 1 else 0 - val marked = MeowdokuRules.applyMove(state, 0, PlaceCat(Square(wrongRow, wrongCol))) - assertThrows(IllegalArgumentException::class.java) { - MeowdokuRules.applyMove(marked, 0, PlaceCat(Square(wrongRow, wrongCol))) - } - // A pencil erases the cross and opens the tap again. - val erased = MeowdokuRules.applyMove(marked, 0, TogglePencil(Square(wrongRow, wrongCol))) - assertFalse(Square(wrongRow, wrongCol) in erased.marks) + val truth = Square(0, state.catCols[0]) + val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(truth)) + assertTrue(truth in crossed.marks) + assertTrue( + "the attempt is offered over its own cross", + PlaceCat(truth) in MeowdokuRules.legalMoves(crossed, 0), + ) + + val placed = MeowdokuRules.applyMove(crossed, 0, PlaceCat(truth)) + assertTrue(truth in placed.cats) + assertFalse("the cat replaces the cross", truth in placed.marks) + assertEquals(3, placed.heartsLeft) + assertEquals(0, placed.wrongCats) + } + + @Test + fun `a wrong pair keeps its cross and spends a heart`() { + val state = dealt(6) + val wrongCol = if (state.catCols[1] == 0) 1 else 0 + val wrong = Square(1, wrongCol) + val crossed = MeowdokuRules.applyMove(state, 0, TogglePencil(wrong)) + val judged = MeowdokuRules.applyMove(crossed, 0, PlaceCat(wrong)) + assertTrue(wrong in judged.marks) + assertFalse(wrong in judged.cats) + assertEquals(2, judged.heartsLeft) + assertEquals(1, judged.wrongCats) } @Test diff --git a/docs/RULES-meowdoku.md b/docs/RULES-meowdoku.md index 15531e1..571d1c2 100644 --- a/docs/RULES-meowdoku.md +++ b/docs/RULES-meowdoku.md @@ -19,22 +19,33 @@ and it sees only the colours and the rules — never the cats it was grown from. ## Playing it -**Tap a square where a cat lives.** A correct tap lands the cat; a wrong one -costs a heart and crosses the square over, because being wrong here is -information and the board keeps it for you. Finding all N cats wins; losing the -hearts ends the board with the rest still hidden. +Controls are the original's, and they carry the game's whole psychology: -The **Pencil** toggle is for the marks you make yourself. They are free, they -cost nothing to erase, and the board does not judge them. The one trap they -carry: a square crossed over is a square you have decided against, so tapping a -cat onto it is refused until the pencil erases the note. The original game -draws crosses for you on a wrong tap for the same reason — a wrong square -crossed twice is the same mistake twice — and here the same thing happens -whether the cross came from the heart or from the pencil. +- **One tap crosses a square over.** Free, instant, erasable — the note you + make while thinking. +- **A second tap on the same square within 350 ms is the cat attempt.** The + board judges it against the answer it was dealt: correct, the cross is + replaced by a cat; wrong, the cross stays and a heart goes. This is why a + guess feels like a decision: your own note has to survive one more tap + before it becomes a bet. +- **Drag to cross a line.** The stroke paints the same note every square it + enters: start on a blank square and it crosses the row or column you drag + along; start on a crossed square and the stroke erases them instead. Cats + are skipped — placed cats are permanent. + +A square you pencilled over can still be tapped for a cat, and the cross is +forgiven: the attempt replaces it if it is right. The one thing no gesture +undoes is a cat once placed. + +The original ships a fifth gesture — a **draft** scratchpad mode, a tool you +buy, where crosses and cats are laid down without judgement and applied all at +once — and it is left out here with the rest of the shop: a scratchpad whose +notes cost nothing is what tapping already is. A **Hint** spends a token and shows the cat in the first row that still lacks -one. The original sells these as tools; here they are simply counted, three per -board by default, which keeps the hint a decision rather than a reflex. +one, replacing any cross drawn over it. The original sells these as tools; here +they are simply counted, three per board by default, which keeps the hint a +decision rather than a reflex. ## The heart -- 2.47.3 From 25e38c4bf8569aea9c5d8c08c657b24c9a08af27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:16:53 +0000 Subject: [PATCH 7/8] Scorekeeper: remember the last 10 player names and offer them when naming a column Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ --- .../prolibertate/games/score/RecentNames.kt | 17 +++++ .../games/score/ScorekeeperRepository.kt | 19 ++++++ .../games/ui/ScorekeeperScreen.kt | 68 ++++++++++++++++--- .../games/score/RecentNamesTest.kt | 30 ++++++++ 4 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/score/RecentNames.kt create mode 100644 app/src/test/java/org/prolibertate/games/score/RecentNamesTest.kt 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/ui/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt index e4b40e3..40bcd82 100644 --- a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -114,6 +114,8 @@ fun ScorekeeperScreen( local = repository.sheet.first() loaded = true } + val recentNames by repository.recentNames.collectAsState(initial = emptyList()) + val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } } val shared by session.state.collectAsState() val role = shared.role @@ -220,7 +222,7 @@ fun ScorekeeperScreen( role == Role.GUEST -> Column(modifier = modifier) { ConnectionBanner(shared = shared, onSync = { session.sync() }) - Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change) + Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed) } joining && role == Role.NONE -> @@ -232,7 +234,7 @@ fun ScorekeeperScreen( onJoin = { joining = true }, ) - else -> Scoring(modifier = modifier, sheet = sheet, onOp = change) + else -> Scoring(modifier, sheet, change, recentNames, nameUsed) } } @@ -356,16 +358,28 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o // --------------------------------------------------------------------------- @Composable -private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { +private fun Scoring( + modifier: Modifier, + sheet: ScoreSheet, + onOp: (SheetOp) -> Unit, + recentNames: List, + 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 @@ -587,6 +601,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit RenameDialog( sheet = sheet, player = renamed, + recentNames = recentNames, + onNameUsed = onNameUsed, onName = { onOp(RenamePlayer(renamed.id, it)) }, onRemove = { onOp(RemovePlayer(renamed.id)) @@ -607,7 +623,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit * sheet, so tapping one in the list takes it back off. */ @Composable -private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { +private fun TallySheet( + modifier: Modifier, + sheet: ScoreSheet, + onOp: (SheetOp) -> Unit, + recentNames: List, + onNameUsed: (String) -> Unit, +) { var renaming by remember { mutableStateOf(-1) } Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -676,6 +698,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> RenameDialog( sheet = sheet, player = renamed, + recentNames = recentNames, + onNameUsed = onNameUsed, onName = { onOp(RenamePlayer(renamed.id, it)) }, onRemove = { onOp(RemovePlayer(renamed.id)) @@ -1064,16 +1088,27 @@ private fun ActionBar( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun RenameDialog( sheet: ScoreSheet, player: ScorePlayer, + recentNames: List, + onNameUsed: (String) -> Unit, onName: (String) -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit, ) { + // Closing the dialog is what files the name away, so a name is remembered + // once rather than once per keystroke. + val finish = { + onNameUsed(player.name) + onDismiss() + } + val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() } + val suggestions = recentNames.filter { it.trim().lowercase() !in taken } AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = finish, title = { Text("Name this column") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -1084,6 +1119,21 @@ private fun RenameDialog( singleLine = true, modifier = Modifier.fillMaxWidth(), ) + if (suggestions.isNotEmpty()) { + Text("Recent names", style = MaterialTheme.typography.labelMedium) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + suggestions.forEach { name -> + FilterChip( + selected = name.equals(player.name.trim(), ignoreCase = true), + onClick = { onName(name) }, + label = { Text(name) }, + ) + } + } + } TextButton( onClick = onRemove, // The last two columns stay: a sheet needs somebody on it. @@ -1091,7 +1141,7 @@ private fun RenameDialog( ) { Text("Remove this player and their score") } } }, - confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + confirmButton = { TextButton(onClick = finish) { Text("Done") } }, ) } 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()) + } +} -- 2.47.3 From 77cc6c5a056234eb15d82039792ad313b7b38cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 04:24:25 +0000 Subject: [PATCH 8/8] Scorekeeper: ask for each player's name at setup; refocus first cell after a round Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ --- .../org/prolibertate/games/score/SheetOp.kt | 14 +- .../games/ui/ScorekeeperScreen.kt | 125 ++++++++++++++++-- .../games/score/ScoreSheetTest.kt | 8 ++ 3 files changed, 136 insertions(+), 11 deletions(-) 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/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt index 40bcd82..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 @@ -230,7 +231,12 @@ 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 }, ) @@ -291,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()), @@ -341,18 +384,71 @@ 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 // --------------------------------------------------------------------------- @@ -398,6 +494,9 @@ private fun Sheet( 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 @@ -527,7 +626,7 @@ private fun Sheet( ) }, 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. @@ -535,6 +634,8 @@ private fun Sheet( } else { PointsField( text = pending[player.id].orEmpty(), + autoFocus = index == 0 && nextRoundFocus > 0, + focusRequest = nextRoundFocus, onText = { pending[player.id] = it lastTyped = player.id @@ -577,6 +678,7 @@ private fun Sheet( onOp(AddRound(amountsAsPoints(pending))) pending.clear() } + nextRoundFocus += 1 lastTyped = -1 focusPlayer = -1 }, @@ -985,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)) }, 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) -- 2.47.3