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.