Merge pull request 'Save state for every game between sessions' (#10) from feature/saved-games into dev
This commit was merged in pull request #10.
This commit is contained in:
@@ -14,7 +14,9 @@ import org.prolibertate.games.ui.AppEnv
|
|||||||
import org.prolibertate.games.ui.AppRoot
|
import org.prolibertate.games.ui.AppRoot
|
||||||
import org.prolibertate.games.ui.theme.ProLibertateTheme
|
import org.prolibertate.games.ui.theme.ProLibertateTheme
|
||||||
import org.prolibertate.games.update.Updater
|
import org.prolibertate.games.update.Updater
|
||||||
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import org.prolibertate.games.game.engine.SavedGameStore
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
deviceId = peerId,
|
deviceId = peerId,
|
||||||
),
|
),
|
||||||
peerId = peerId,
|
peerId = peerId,
|
||||||
|
saves = SavedGameStore(File(filesDir, "saved-games")),
|
||||||
)
|
)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package org.prolibertate.games.game.engine
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A game in progress, kept so it can be picked up again after the app has been
|
||||||
|
* closed.
|
||||||
|
*
|
||||||
|
* The match is stored the way the host holds it: the [config] it was dealt from
|
||||||
|
* (seed, seats, options) and the whole authoritative [stateJson], hidden cards
|
||||||
|
* included, as the game's own rules encode it. Nothing here knows what game it
|
||||||
|
* is; [gameId] is only the name of the slot.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SavedMatch(
|
||||||
|
val gameId: String,
|
||||||
|
val config: TableConfig,
|
||||||
|
/** The seat the person at this device plays. */
|
||||||
|
val localSeat: Int,
|
||||||
|
val stateJson: String,
|
||||||
|
/** Wall-clock milliseconds when the position was written. */
|
||||||
|
val savedAtMillis: Long,
|
||||||
|
/** A line saying how the game stands, from the rules' own summary. */
|
||||||
|
val summary: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One saved game per game, kept as a file each under [directory].
|
||||||
|
*
|
||||||
|
* One slot per game rather than a list: the player is mid-way through a game of
|
||||||
|
* Chess or they are not. Starting a new one replaces it, which is what the
|
||||||
|
* resume prompt on the setup screen says.
|
||||||
|
*
|
||||||
|
* Writes are atomic — the position goes to a temporary file that is then moved
|
||||||
|
* over the real one — so being killed half way through a save leaves the
|
||||||
|
* previous position, not half of the new one. Reads never throw: a file that
|
||||||
|
* will not parse is a game that cannot be resumed, so it is removed and reported
|
||||||
|
* as absent.
|
||||||
|
*/
|
||||||
|
class SavedGameStore(private val directory: File) {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileFor(gameId: String): File {
|
||||||
|
// The ids are catalogue constants, but they become file names, so
|
||||||
|
// nothing that could climb out of the directory is let through.
|
||||||
|
require(gameId.isNotBlank() && gameId.all { it.isLetterOrDigit() || it == '_' || it == '-' }) {
|
||||||
|
"Not a game id: $gameId"
|
||||||
|
}
|
||||||
|
return File(directory, "$gameId.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes [match] over whatever was saved for its game. */
|
||||||
|
@Synchronized
|
||||||
|
fun save(match: SavedMatch) {
|
||||||
|
directory.mkdirs()
|
||||||
|
val target = fileFor(match.gameId)
|
||||||
|
val temp = File(directory, "${match.gameId}.json.tmp")
|
||||||
|
temp.writeText(json.encodeToString(SavedMatch.serializer(), match))
|
||||||
|
if (!temp.renameTo(target)) {
|
||||||
|
// Some file systems will not rename over an existing file.
|
||||||
|
target.delete()
|
||||||
|
check(temp.renameTo(target)) { "Could not write the saved game" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved game for [gameId], or null if there is none or it cannot be read. */
|
||||||
|
@Synchronized
|
||||||
|
fun load(gameId: String): SavedMatch? {
|
||||||
|
val file = fileFor(gameId)
|
||||||
|
if (!file.isFile) return null
|
||||||
|
val match = runCatching {
|
||||||
|
json.decodeFromString(SavedMatch.serializer(), file.readText())
|
||||||
|
}.getOrNull()
|
||||||
|
if (match == null || match.gameId != gameId) {
|
||||||
|
file.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets the saved game for [gameId]. A no-op if there is none. */
|
||||||
|
@Synchronized
|
||||||
|
fun clear(gameId: String) {
|
||||||
|
fileFor(gameId).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every readable saved game, the most recently played first. */
|
||||||
|
@Synchronized
|
||||||
|
fun all(): List<SavedMatch> {
|
||||||
|
val files = directory.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
|
||||||
|
return files
|
||||||
|
.mapNotNull { file -> runCatching { load(file.name.removeSuffix(".json")) }.getOrNull() }
|
||||||
|
.sortedByDescending { it.savedAtMillis }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,27 @@ class MatchController<S : Any, M : Any>(
|
|||||||
|
|
||||||
/** Host only: the true state. Clients never see this. */
|
/** Host only: the true state. Clients never see this. */
|
||||||
private var authoritative: S? = null
|
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 val lock = Mutex()
|
||||||
private var sequence = 0
|
private var sequence = 0
|
||||||
|
|
||||||
@@ -160,7 +181,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
check(role == Role.HOST) { "Not hosting" }
|
check(role == Role.HOST) { "Not hosting" }
|
||||||
clients.forEach { connection -> listenToPeer(connection) }
|
clients.forEach { connection -> listenToPeer(connection) }
|
||||||
scope.launch {
|
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
|
// 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
|
// to take the app down with it: this runs in the screen's own
|
||||||
// scope, where nothing would catch it.
|
// scope, where nothing would catch it.
|
||||||
@@ -175,6 +196,20 @@ class MatchController<S : Any, M : Any>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host: carry on from a saved position instead of dealing a new game.
|
||||||
|
*
|
||||||
|
* Call before [startAsHost]. Returns false, and changes nothing, if
|
||||||
|
* [stateJson] cannot be read by this version of the game — a save made by an
|
||||||
|
* older build, say — and the caller should deal a fresh table instead.
|
||||||
|
*/
|
||||||
|
fun restore(stateJson: String): Boolean {
|
||||||
|
check(role == Role.HOST) { "Only a host can restore a game" }
|
||||||
|
val state = runCatching { rules.decodeState(stateJson) }.getOrNull() ?: return false
|
||||||
|
restored = state
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private fun listenToPeer(connection: Connection) {
|
private fun listenToPeer(connection: Connection) {
|
||||||
peers[connection.peerId] = connection
|
peers[connection.peerId] = connection
|
||||||
peerJobs[connection.peerId] = scope.launch {
|
peerJobs[connection.peerId] = scope.launch {
|
||||||
@@ -449,6 +484,16 @@ class MatchController<S : Any, M : Any>(
|
|||||||
}
|
}
|
||||||
_finished.value = rules.isFinished(current)
|
_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) {
|
for (slot in config.seats) {
|
||||||
val peerId = slot.peerId ?: continue
|
val peerId = slot.peerId ?: continue
|
||||||
val connection = peers[peerId] ?: continue
|
val connection = peers[peerId] ?: continue
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.prolibertate.games.game.engine.SavedGameStore
|
||||||
|
import org.prolibertate.games.game.engine.SavedMatch
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the position of one table on disk as it is played.
|
||||||
|
*
|
||||||
|
* It is fed every position the host reaches ([offer]) and writes the newest one
|
||||||
|
* off the main thread. Positions that arrive while a write is under way are
|
||||||
|
* dropped in favour of the latest — a computer's turn can be several moves in a
|
||||||
|
* second, and only where the game stands matters. A finished game is not kept:
|
||||||
|
* its slot is cleared, so a won game is not offered back as one to resume.
|
||||||
|
*
|
||||||
|
* [flush] writes whatever has not been written yet, on the caller's thread. It
|
||||||
|
* is what makes leaving the table, or the app going to the background, safe: the
|
||||||
|
* background writer may not have caught up, and the process may not be given the
|
||||||
|
* chance to let it.
|
||||||
|
*/
|
||||||
|
class MatchSaver(
|
||||||
|
private val store: SavedGameStore,
|
||||||
|
private val gameId: String,
|
||||||
|
private val config: TableConfig,
|
||||||
|
private val localSeat: Int,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val clock: () -> Long = System::currentTimeMillis,
|
||||||
|
) {
|
||||||
|
private val latest = MutableStateFlow<MatchController.HostSnapshot?>(null)
|
||||||
|
private var worker: Job? = null
|
||||||
|
private var written: MatchController.HostSnapshot? = null
|
||||||
|
private var discarded = false
|
||||||
|
|
||||||
|
/** Starts the background writer. */
|
||||||
|
fun start() {
|
||||||
|
worker = scope.launch(Dispatchers.IO) {
|
||||||
|
latest.filterNotNull().collectLatest { write(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands over the newest position. Cheap: the encoding happens on the writer. */
|
||||||
|
fun offer(snapshot: MatchController.HostSnapshot) {
|
||||||
|
latest.value = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the newest position now if the writer has not already. */
|
||||||
|
fun flush() {
|
||||||
|
latest.value?.let { write(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The game was ended on purpose: forget the saved position and write no more. */
|
||||||
|
@Synchronized
|
||||||
|
fun discard() {
|
||||||
|
discarded = true
|
||||||
|
worker?.cancel()
|
||||||
|
worker = null
|
||||||
|
runCatching { store.clear(gameId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
worker?.cancel()
|
||||||
|
worker = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun write(snapshot: MatchController.HostSnapshot) {
|
||||||
|
if (discarded || snapshot === written) return
|
||||||
|
written = snapshot
|
||||||
|
// Saving is a courtesy to the player: a full disk must not take the game
|
||||||
|
// down with it.
|
||||||
|
runCatching {
|
||||||
|
if (snapshot.finished) {
|
||||||
|
store.clear(gameId)
|
||||||
|
} else {
|
||||||
|
store.save(
|
||||||
|
SavedMatch(
|
||||||
|
gameId = gameId,
|
||||||
|
config = config,
|
||||||
|
localSeat = localSeat,
|
||||||
|
stateJson = snapshot.stateJson(),
|
||||||
|
savedAtMillis = clock(),
|
||||||
|
summary = snapshot.summary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.ui.game.LocalCardSpeed
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
import org.prolibertate.games.update.Updater
|
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. */
|
/** Everything the screens need, assembled once by the activity. */
|
||||||
class AppEnv(
|
class AppEnv(
|
||||||
@@ -60,6 +66,8 @@ class AppEnv(
|
|||||||
/** The shared score sheet, for the Scorekeeper to host or join. */
|
/** The shared score sheet, for the Scorekeeper to host or join. */
|
||||||
val scoreSession: ScorekeeperSession,
|
val scoreSession: ScorekeeperSession,
|
||||||
val peerId: String,
|
val peerId: String,
|
||||||
|
/** Games in progress, kept between sessions. */
|
||||||
|
val saves: SavedGameStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +91,8 @@ sealed interface Route {
|
|||||||
val config: TableConfig,
|
val config: TableConfig,
|
||||||
val hosting: Boolean,
|
val hosting: Boolean,
|
||||||
val localSeat: Int,
|
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
|
) : Route
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +177,10 @@ private fun AppContent(
|
|||||||
// Dev builds show the games that are not finished yet; production
|
// Dev builds show the games that are not finished yet; production
|
||||||
// releases list only what can actually be played.
|
// releases list only what can actually be played.
|
||||||
showComingSoon = showComingSoon,
|
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)) },
|
onPickSection = { push(Route.Section(it)) },
|
||||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||||
onScorekeeper = { push(Route.Scorekeeper) },
|
onScorekeeper = { push(Route.Scorekeeper) },
|
||||||
@@ -194,7 +208,13 @@ private fun AppContent(
|
|||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
)
|
)
|
||||||
|
|
||||||
is Route.Setup -> GameSetupScreen(
|
is Route.Setup -> {
|
||||||
|
// Bumped when the saved game is thrown away, so the banner goes with it.
|
||||||
|
var discards by remember(route) { mutableIntStateOf(0) }
|
||||||
|
val saved by produceState<SavedMatch?>(null, route, discards) {
|
||||||
|
value = withContext(Dispatchers.IO) { env.saves.load(route.gameId) }
|
||||||
|
}
|
||||||
|
GameSetupScreen(
|
||||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||||
playerName = settings.displayName,
|
playerName = settings.displayName,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
@@ -202,7 +222,16 @@ private fun AppContent(
|
|||||||
onHostOnline = { optionsJson ->
|
onHostOnline = { optionsJson ->
|
||||||
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
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(
|
is Route.Lobby -> LobbyScreen(
|
||||||
env = env,
|
env = env,
|
||||||
@@ -231,6 +260,8 @@ private fun AppContent(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainMenuScreen(
|
fun MainMenuScreen(
|
||||||
showComingSoon: Boolean,
|
showComingSoon: Boolean,
|
||||||
|
saved: List<SavedMatch>,
|
||||||
|
onResume: (SavedMatch) -> Unit,
|
||||||
onPickSection: (GameMenu) -> Unit,
|
onPickSection: (GameMenu) -> Unit,
|
||||||
onJoinGame: () -> Unit,
|
onJoinGame: () -> Unit,
|
||||||
onScorekeeper: () -> 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
|
// Below the games, and full width like them, so the column
|
||||||
// stays one column all the way down.
|
// stays one column all the way down.
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -490,3 +538,17 @@ fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
|||||||
Text(text)
|
Text(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The saved games, read off the main thread each time the menu is the screen
|
||||||
|
* showing — which is when a game has just been left and its last position
|
||||||
|
* written, so the list is not read until that has happened.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun savedGames(env: AppEnv, stack: List<Route>): List<SavedMatch> {
|
||||||
|
val onMenu = stack.last() is Route.Menu
|
||||||
|
val saved by produceState(emptyList<SavedMatch>(), onMenu, stack.size) {
|
||||||
|
value = if (onMenu) withContext(Dispatchers.IO) { env.saves.all() } else emptyList()
|
||||||
|
}
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.prolibertate.games.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a screen's "End game" button tells the table to forget the saved game.
|
||||||
|
*
|
||||||
|
* Leaving a table keeps its position so it can be resumed; ending one on purpose
|
||||||
|
* does not. The two arrive at the screen as the same "go back", so the
|
||||||
|
* difference is carried here instead: [PlayScreen] provides one handle, the match
|
||||||
|
* it builds says what ending means for it by setting [onEnd], and each End-game
|
||||||
|
* dialog calls [end] before it leaves.
|
||||||
|
*/
|
||||||
|
class EndGameHandle {
|
||||||
|
/** Set by the table once it knows how to forget its own saved position. */
|
||||||
|
var onEnd: () -> Unit = {}
|
||||||
|
|
||||||
|
fun end() = onEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalEndGameHandle = staticCompositionLocalOf { EndGameHandle() }
|
||||||
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.GameDescriptor
|
||||||
import org.prolibertate.games.game.engine.PlayerKind
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.PlayerSlot
|
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.engine.TableConfig
|
||||||
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||||
@@ -83,6 +87,10 @@ fun GameSetupScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlayOffline: (TableConfig, Int) -> Unit,
|
onPlayOffline: (TableConfig, Int) -> Unit,
|
||||||
onHostOnline: (String) -> 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 euchre by remember { mutableStateOf(EuchreOptions()) }
|
||||||
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
||||||
@@ -149,6 +157,10 @@ fun GameSetupScreen(
|
|||||||
) {
|
) {
|
||||||
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
|
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
|
||||||
|
if (saved != null) {
|
||||||
|
SavedGameBanner(saved, onResume = { onResume(saved) }, onDiscard = onDiscardSaved)
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
Text("House rules", fontWeight = FontWeight.Bold)
|
Text("House rules", fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
@@ -233,6 +245,12 @@ fun GameSetupScreen(
|
|||||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||||
else -> 0
|
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") {
|
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
||||||
onPlayOffline(
|
onPlayOffline(
|
||||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||||
@@ -1216,3 +1234,30 @@ private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOpti
|
|||||||
onSelect = { onChange(options.copy(redeals = it)) },
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package org.prolibertate.games.ui
|
|||||||
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
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.WizardRules
|
||||||
import org.prolibertate.games.game.wizard.WizardState
|
import org.prolibertate.games.game.wizard.WizardState
|
||||||
import org.prolibertate.games.net.MatchController
|
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.settings.Settings
|
||||||
import org.prolibertate.games.ui.game.BackgammonScreen
|
import org.prolibertate.games.ui.game.BackgammonScreen
|
||||||
import org.prolibertate.games.ui.game.catan.CatanScreen
|
import org.prolibertate.games.ui.game.catan.CatanScreen
|
||||||
@@ -155,6 +159,10 @@ fun PlayScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT
|
val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT
|
||||||
|
|
||||||
|
// 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) {
|
when (route.gameId) {
|
||||||
GameCatalog.EUCHRE -> {
|
GameCatalog.EUCHRE -> {
|
||||||
val controller = remember(route) {
|
val controller = remember(route) {
|
||||||
@@ -699,6 +707,7 @@ fun PlayScreen(
|
|||||||
|
|
||||||
else -> Text("That game isn't playable yet.")
|
else -> Text("That game isn't playable yet.")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -707,8 +716,38 @@ private fun <S : Any, M : Any> StartMatch(
|
|||||||
controller: MatchController<S, M>,
|
controller: MatchController<S, M>,
|
||||||
route: Route.Play,
|
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) {
|
LaunchedEffect(controller) {
|
||||||
if (route.hosting) {
|
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)
|
controller.startAsHost(env.lobby.hostConnections)
|
||||||
} else {
|
} else {
|
||||||
env.lobby.clientConnection?.let { controller.startAsClient(it) }
|
env.lobby.clientConnection?.let { controller.startAsClient(it) }
|
||||||
@@ -721,6 +760,10 @@ private fun <S : Any, M : Any> StartMatch(
|
|||||||
env.lobby.rebinder = controller
|
env.lobby.rebinder = controller
|
||||||
onDispose {
|
onDispose {
|
||||||
env.lobby.rebinder = null
|
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()
|
controller.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,11 +777,14 @@ private fun <S : Any, M : Any> StartMatch(
|
|||||||
// sent. So the table is asked for again on the way back in, over a fresh
|
// sent. So the table is asked for again on the way back in, over a fresh
|
||||||
// link if the old one did not survive.
|
// link if the old one did not survive.
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner, route.hosting) {
|
DisposableEffect(lifecycleOwner, route.hosting, saver) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
if (event == Lifecycle.Event.ON_START && !route.hosting) {
|
if (event == Lifecycle.Event.ON_START && !route.hosting) {
|
||||||
env.lobby.restoreLink()
|
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)
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.prolibertate.games.ui
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.engine.SavedMatch
|
||||||
|
|
||||||
|
/** How long ago something happened, in the words a person would use. */
|
||||||
|
fun agoText(nowMillis: Long, thenMillis: Long): String {
|
||||||
|
val seconds = ((nowMillis - thenMillis) / 1000).coerceAtLeast(0)
|
||||||
|
val minutes = seconds / 60
|
||||||
|
val hours = minutes / 60
|
||||||
|
val days = hours / 24
|
||||||
|
return when {
|
||||||
|
seconds < 45 -> "just now"
|
||||||
|
minutes < 2 -> "a minute ago"
|
||||||
|
minutes < 60 -> "$minutes minutes ago"
|
||||||
|
hours < 2 -> "an hour ago"
|
||||||
|
hours < 24 -> "$hours hours ago"
|
||||||
|
days < 2 -> "yesterday"
|
||||||
|
else -> "$days days ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Chess · saved 5 minutes ago" for a menu line. */
|
||||||
|
fun savedLine(title: String, match: SavedMatch, nowMillis: Long): String =
|
||||||
|
"$title · saved ${agoText(nowMillis, match.savedAtMillis)}"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -378,7 +379,12 @@ private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -79,7 +80,12 @@ fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text(what) },
|
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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -314,7 +315,12 @@ private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -215,7 +216,12 @@ private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -268,7 +269,12 @@ private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -342,7 +343,12 @@ private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The deal is left as it stands.") },
|
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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -356,7 +357,12 @@ private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -483,7 +484,12 @@ private fun LeaveBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -242,7 +243,12 @@ private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The board is left as it stands.") },
|
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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -371,7 +372,12 @@ private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left."
|
"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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -668,7 +669,12 @@ private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"table is told you have left."
|
"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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -295,7 +296,12 @@ private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
onDismissRequest = onKeepPlaying,
|
onDismissRequest = onKeepPlaying,
|
||||||
title = { Text("End this game?") },
|
title = { Text("End this game?") },
|
||||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui.game.catan
|
package org.prolibertate.games.ui.game.catan
|
||||||
|
|
||||||
|
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
@@ -603,7 +604,12 @@ fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
|||||||
"at the table is told you have left.",
|
"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") } },
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package org.prolibertate.games.game.engine
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class SavedGameStoreTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val folder = TemporaryFolder()
|
||||||
|
|
||||||
|
private fun store() = SavedGameStore(File(folder.root, "saves"))
|
||||||
|
|
||||||
|
private fun match(gameId: String = "chess", state: String = "{\"turn\":1}", at: Long = 1_000L) = SavedMatch(
|
||||||
|
gameId = gameId,
|
||||||
|
config = TableConfig(
|
||||||
|
gameId = gameId,
|
||||||
|
seats = listOf(
|
||||||
|
PlayerSlot(0, "You", PlayerKind.HUMAN_LOCAL, 0),
|
||||||
|
PlayerSlot(1, "Computer", PlayerKind.AI, 1),
|
||||||
|
),
|
||||||
|
optionsJson = "{}",
|
||||||
|
seed = 42L,
|
||||||
|
),
|
||||||
|
localSeat = 0,
|
||||||
|
stateJson = state,
|
||||||
|
savedAtMillis = at,
|
||||||
|
summary = "White to move",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a saved game comes back exactly as it went in`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match())
|
||||||
|
assertEquals(match(), s.load("chess"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `there is nothing to load before anything is saved`() {
|
||||||
|
assertNull(store().load("chess"))
|
||||||
|
assertTrue(store().all().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a game keeps one slot, and saving again replaces it`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match(state = "one", at = 1))
|
||||||
|
s.save(match(state = "two", at = 2))
|
||||||
|
assertEquals("two", s.load("chess")!!.stateJson)
|
||||||
|
assertEquals(1, s.all().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing forgets the game and leaves the others`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match("chess"))
|
||||||
|
s.save(match("golf"))
|
||||||
|
s.clear("chess")
|
||||||
|
assertNull(s.load("chess"))
|
||||||
|
assertEquals("golf", s.load("golf")!!.gameId)
|
||||||
|
s.clear("chess") // clearing nothing is fine
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the most recently played game is listed first`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match("chess", at = 10))
|
||||||
|
s.save(match("golf", at = 30))
|
||||||
|
s.save(match("catan", at = 20))
|
||||||
|
assertEquals(listOf("golf", "catan", "chess"), s.all().map { it.gameId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file that cannot be read is dropped rather than thrown`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match())
|
||||||
|
File(folder.root, "saves/chess.json").writeText("{ this is not json")
|
||||||
|
assertNull(s.load("chess"))
|
||||||
|
assertFalse(File(folder.root, "saves/chess.json").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file holding another game is not served as this one`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match("golf"))
|
||||||
|
File(folder.root, "saves/golf.json").copyTo(File(folder.root, "saves/chess.json"))
|
||||||
|
assertNull(s.load("chess"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a save that was cut off half way leaves the previous position`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match(state = "good"))
|
||||||
|
// What a kill mid-write leaves behind: a stray temporary file.
|
||||||
|
File(folder.root, "saves/chess.json.tmp").writeText("{ half a wri")
|
||||||
|
assertEquals("good", s.load("chess")!!.stateJson)
|
||||||
|
assertEquals(1, s.all().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a game id cannot climb out of the folder`() {
|
||||||
|
val bad = runCatching { store().load("../secret") }
|
||||||
|
assertTrue(bad.isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown field from a newer build does not stop a save loading`() {
|
||||||
|
val s = store()
|
||||||
|
s.save(match())
|
||||||
|
val file = File(folder.root, "saves/chess.json")
|
||||||
|
file.writeText(file.readText().replaceFirst("{", "{\"addedLater\":true,"))
|
||||||
|
assertEquals("chess", s.load("chess")!!.gameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.SavedGameStore
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saving a table as it is played, and starting a table from a save.
|
||||||
|
*
|
||||||
|
* The game here is a counter: one seat, a move adds one, and it is finished at
|
||||||
|
* [GOAL]. It has hidden state — [Counter.secret] — so the tests can check that
|
||||||
|
* what is saved is the true state, not the view a player is shown.
|
||||||
|
*/
|
||||||
|
class MatchSaverTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val folder = TemporaryFolder()
|
||||||
|
|
||||||
|
private val scopes = mutableListOf<CoroutineScope>()
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scopes.forEach { it.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Counter(val n: Int, val secret: String)
|
||||||
|
|
||||||
|
private object Rules : GameRules<Counter, String> {
|
||||||
|
override val gameId = "counter"
|
||||||
|
override fun initialState(config: TableConfig) = Counter(0, "hidden-${config.seed}")
|
||||||
|
override fun currentSeat(state: Counter): Int? = if (state.n >= GOAL) null else 0
|
||||||
|
override fun legalMoves(state: Counter, seat: Int) = if (state.n >= GOAL) emptyList() else listOf("add")
|
||||||
|
override fun applyMove(state: Counter, seat: Int, move: String) = state.copy(n = state.n + 1)
|
||||||
|
override fun isFinished(state: Counter) = state.n >= GOAL
|
||||||
|
override fun summary(state: Counter) = "at ${state.n}"
|
||||||
|
override fun viewFor(state: Counter, seat: Int) = state.copy(secret = "")
|
||||||
|
override fun encodeState(state: Counter) = "${state.n}|${state.secret}"
|
||||||
|
override fun decodeState(json: String): Counter {
|
||||||
|
val (n, secret) = json.split("|", limit = 2)
|
||||||
|
return Counter(n.toInt(), secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encodeMove(move: String) = move
|
||||||
|
override fun decodeMove(json: String) = json
|
||||||
|
}
|
||||||
|
|
||||||
|
private val config = TableConfig(
|
||||||
|
gameId = "counter",
|
||||||
|
seats = listOf(PlayerSlot(0, "You", PlayerKind.HUMAN_LOCAL, 0)),
|
||||||
|
optionsJson = "{}",
|
||||||
|
seed = 7L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun scope() = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
|
||||||
|
|
||||||
|
private fun controller(): MatchController<Counter, String> = MatchController(
|
||||||
|
rules = Rules,
|
||||||
|
ai = object : GameAi<Counter, String> {
|
||||||
|
override fun chooseMove(state: Counter, seat: Int, legal: List<String>) = legal.first()
|
||||||
|
},
|
||||||
|
config = config,
|
||||||
|
scope = scope(),
|
||||||
|
role = MatchController.Role.HOST,
|
||||||
|
localSeats = setOf(0),
|
||||||
|
primarySeat = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun store() = SavedGameStore(File(folder.root, "saves"))
|
||||||
|
|
||||||
|
private fun waitFor(what: String, check: () -> Boolean) = runBlocking {
|
||||||
|
repeat(200) {
|
||||||
|
if (check()) return@runBlocking
|
||||||
|
delay(10)
|
||||||
|
}
|
||||||
|
error("Timed out waiting for $what")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saver(s: SavedGameStore, c: MatchController<Counter, String>) =
|
||||||
|
MatchSaver(s, "counter", config, localSeat = 0, scope = scope(), clock = { 123L }).also {
|
||||||
|
it.start()
|
||||||
|
c.onHostState = it::offer
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every position the table reaches is kept, with the true state`() {
|
||||||
|
val s = store()
|
||||||
|
val c = controller()
|
||||||
|
saver(s, c)
|
||||||
|
c.startAsHost(emptyList())
|
||||||
|
waitFor("the deal") { c.state.value != null }
|
||||||
|
c.submit("add")
|
||||||
|
c.submit("add")
|
||||||
|
waitFor("two moves") { s.load("counter")?.stateJson?.startsWith("2|") == true }
|
||||||
|
|
||||||
|
val saved = s.load("counter")!!
|
||||||
|
assertEquals("2|hidden-7", saved.stateJson) // the hidden part is saved; the view blanks it
|
||||||
|
assertEquals("at 2", saved.summary)
|
||||||
|
assertEquals(123L, saved.savedAtMillis)
|
||||||
|
assertEquals(config, saved.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a table started from a save carries on from where it stood`() {
|
||||||
|
val s = store()
|
||||||
|
val first = controller()
|
||||||
|
saver(s, first)
|
||||||
|
first.startAsHost(emptyList())
|
||||||
|
waitFor("the deal") { first.state.value != null }
|
||||||
|
repeat(3) { first.submit("add") }
|
||||||
|
waitFor("three moves") { s.load("counter")?.stateJson?.startsWith("3|") == true }
|
||||||
|
first.close()
|
||||||
|
|
||||||
|
val second = controller()
|
||||||
|
assertTrue(second.restore(s.load("counter")!!.stateJson))
|
||||||
|
second.startAsHost(emptyList())
|
||||||
|
waitFor("the restored table") { second.state.value != null }
|
||||||
|
assertEquals(3, second.state.value!!.n)
|
||||||
|
second.submit("add")
|
||||||
|
waitFor("a move after resuming") { second.state.value!!.n == 4 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a save the game cannot read is refused and the table is dealt afresh`() {
|
||||||
|
val c = controller()
|
||||||
|
assertFalse(c.restore("not a counter"))
|
||||||
|
c.startAsHost(emptyList())
|
||||||
|
waitFor("a fresh deal") { c.state.value != null }
|
||||||
|
assertEquals(0, c.state.value!!.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finished game is not kept`() {
|
||||||
|
val s = store()
|
||||||
|
val c = controller()
|
||||||
|
saver(s, c)
|
||||||
|
c.startAsHost(emptyList())
|
||||||
|
waitFor("the deal") { s.load("counter") != null }
|
||||||
|
repeat(GOAL) { c.submit("add") }
|
||||||
|
waitFor("the finish") { c.finished.value }
|
||||||
|
waitFor("the slot to clear") { s.load("counter") == null }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `flush writes the position the background writer has not reached`() {
|
||||||
|
val s = store()
|
||||||
|
val saver = MatchSaver(s, "counter", config, 0, scope(), clock = { 1L })
|
||||||
|
// Not started: nothing is written in the background at all.
|
||||||
|
saver.offer(MatchController.HostSnapshot(false, "at 5") { "5|x" })
|
||||||
|
assertNull(s.load("counter"))
|
||||||
|
saver.flush()
|
||||||
|
assertEquals("5|x", s.load("counter")!!.stateJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ending a game on purpose forgets it and writes no more`() {
|
||||||
|
val s = store()
|
||||||
|
val saver = MatchSaver(s, "counter", config, 0, scope(), clock = { 1L })
|
||||||
|
saver.offer(MatchController.HostSnapshot(false, "at 1") { "1|x" })
|
||||||
|
saver.flush()
|
||||||
|
assertNotNull(s.load("counter"))
|
||||||
|
saver.discard()
|
||||||
|
assertNull(s.load("counter"))
|
||||||
|
// Leaving after ending must not put it back.
|
||||||
|
saver.offer(MatchController.HostSnapshot(false, "at 2") { "2|x" })
|
||||||
|
saver.flush()
|
||||||
|
assertNull(s.load("counter"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val GOAL = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import org.prolibertate.games.ui.seatCountFor
|
||||||
|
import org.prolibertate.games.game.backgammon.BackgammonAi
|
||||||
|
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||||
|
import org.prolibertate.games.game.backgammon.BackgammonRules
|
||||||
|
import org.prolibertate.games.game.catan.CatanAi
|
||||||
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
|
import org.prolibertate.games.game.catan.CatanRules
|
||||||
|
import org.prolibertate.games.game.checkers.CheckersAi
|
||||||
|
import org.prolibertate.games.game.checkers.CheckersOptions
|
||||||
|
import org.prolibertate.games.game.checkers.CheckersRules
|
||||||
|
import org.prolibertate.games.game.chess.ChessAi
|
||||||
|
import org.prolibertate.games.game.chess.ChessOptions
|
||||||
|
import org.prolibertate.games.game.chess.ChessRules
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
|
||||||
|
import org.prolibertate.games.game.cribbage.CribbageAi
|
||||||
|
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||||
|
import org.prolibertate.games.game.cribbage.CribbageRules
|
||||||
|
import org.prolibertate.games.game.euchre.EuchreAi
|
||||||
|
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||||
|
import org.prolibertate.games.game.euchre.EuchreRules
|
||||||
|
import org.prolibertate.games.game.freecell.FreeCellOptions
|
||||||
|
import org.prolibertate.games.game.freecell.FreeCellRules
|
||||||
|
import org.prolibertate.games.game.golf.GolfAi
|
||||||
|
import org.prolibertate.games.game.golf.GolfOptions
|
||||||
|
import org.prolibertate.games.game.golf.GolfRules
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsAi
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsOptions
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsRules
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserAi
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||||
|
import org.prolibertate.games.game.kaiser.KaiserRules
|
||||||
|
import org.prolibertate.games.game.klondike.KlondikeAi
|
||||||
|
import org.prolibertate.games.game.klondike.KlondikeOptions
|
||||||
|
import org.prolibertate.games.game.klondike.KlondikeRules
|
||||||
|
import org.prolibertate.games.game.mastermind.MastermindAi
|
||||||
|
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||||
|
import org.prolibertate.games.game.mastermind.MastermindRules
|
||||||
|
import org.prolibertate.games.game.morris.MorrisAi
|
||||||
|
import org.prolibertate.games.game.morris.MorrisOptions
|
||||||
|
import org.prolibertate.games.game.morris.MorrisRules
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules
|
||||||
|
import org.prolibertate.games.game.pirates.PiratesAi
|
||||||
|
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||||
|
import org.prolibertate.games.game.pirates.PiratesRules
|
||||||
|
import org.prolibertate.games.game.president.PresidentAi
|
||||||
|
import org.prolibertate.games.game.president.PresidentOptions
|
||||||
|
import org.prolibertate.games.game.president.PresidentRules
|
||||||
|
import org.prolibertate.games.game.pyramid.PyramidOptions
|
||||||
|
import org.prolibertate.games.game.pyramid.PyramidRules
|
||||||
|
import org.prolibertate.games.game.sequence.SequenceAi
|
||||||
|
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||||
|
import org.prolibertate.games.game.sequence.SequenceRules
|
||||||
|
import org.prolibertate.games.game.solitaire.FirstLegalAi
|
||||||
|
import org.prolibertate.games.game.spider.SpiderOptions
|
||||||
|
import org.prolibertate.games.game.spider.SpiderRules
|
||||||
|
import org.prolibertate.games.game.tayu.TayuAi
|
||||||
|
import org.prolibertate.games.game.tayu.TayuOptions
|
||||||
|
import org.prolibertate.games.game.tayu.TayuRules
|
||||||
|
import org.prolibertate.games.game.wizard.WizardAi
|
||||||
|
import org.prolibertate.games.game.wizard.WizardOptions
|
||||||
|
import org.prolibertate.games.game.wizard.WizardRules
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeAi
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeRules
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every game can be saved and picked up again.
|
||||||
|
*
|
||||||
|
* For each game in the catalogue a table is dealt and played by the computer, and
|
||||||
|
* at every single move the whole true state is encoded and decoded again — which
|
||||||
|
* is exactly what saving it to disk and resuming does — and play carries on from
|
||||||
|
* the decoded copy. A game whose state does not survive that, or that cannot make
|
||||||
|
* a legal move from a state it has been through it, fails here.
|
||||||
|
*/
|
||||||
|
class SaveRoundTripAllGamesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun <S : Any, M : Any> exercise(
|
||||||
|
gameId: String,
|
||||||
|
rules: GameRules<S, M>,
|
||||||
|
ai: GameAi<S, M>,
|
||||||
|
optionsJson: String,
|
||||||
|
steps: Int = 120,
|
||||||
|
) {
|
||||||
|
val seats = seatCountFor(gameId, optionsJson)
|
||||||
|
val config = TableConfig(
|
||||||
|
gameId = gameId,
|
||||||
|
seats = (0 until seats).map {
|
||||||
|
PlayerSlot(it, "P$it", if (it == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, it)
|
||||||
|
},
|
||||||
|
optionsJson = optionsJson,
|
||||||
|
seed = 20240607L,
|
||||||
|
)
|
||||||
|
var state = rules.initialState(config)
|
||||||
|
var moves = 0
|
||||||
|
repeat(steps) {
|
||||||
|
if (rules.isFinished(state)) return
|
||||||
|
// The save and the resume: what goes to disk, and what comes back.
|
||||||
|
val saved = rules.encodeState(state)
|
||||||
|
val back = rules.decodeState(saved)
|
||||||
|
assertEquals("$gameId: a saved state encodes differently after a resume", saved, rules.encodeState(back))
|
||||||
|
assertEquals(
|
||||||
|
"$gameId: a resumed state is not on the same seat's turn",
|
||||||
|
rules.currentSeat(state),
|
||||||
|
rules.currentSeat(back),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"$gameId: a resumed state has different legal moves",
|
||||||
|
rules.currentSeat(state)?.let { rules.legalMoves(state, it) },
|
||||||
|
rules.currentSeat(back)?.let { rules.legalMoves(back, it) },
|
||||||
|
)
|
||||||
|
state = back
|
||||||
|
|
||||||
|
val seat = rules.currentSeat(state) ?: return // waiting on a scoreboard, which is not a move
|
||||||
|
val legal = rules.legalMoves(state, seat)
|
||||||
|
if (legal.isEmpty()) return
|
||||||
|
val move = ai.chooseMove(state, seat, legal)
|
||||||
|
assertTrue("$gameId: move $move is not legal after a resume", rules.isLegalMove(state, seat, move))
|
||||||
|
state = rules.applyMove(state, seat, move)
|
||||||
|
moves++
|
||||||
|
}
|
||||||
|
assertTrue("$gameId: made no moves", moves > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified O> opts(o: O) = json.encodeToString(o)
|
||||||
|
|
||||||
|
@Test fun euchre() = exercise(GameCatalog.EUCHRE, EuchreRules, EuchreAi(), opts(EuchreOptions()))
|
||||||
|
@Test fun sequence() = exercise(GameCatalog.SEQUENCE, SequenceRules, SequenceAi(), opts(SequenceOptions()))
|
||||||
|
@Test fun president() = exercise(GameCatalog.PRESIDENT, PresidentRules, PresidentAi(), opts(PresidentOptions()))
|
||||||
|
@Test fun golf() = exercise(GameCatalog.GOLF, GolfRules, GolfAi(), opts(GolfOptions()))
|
||||||
|
@Test fun kaiser() = exercise(GameCatalog.KAISER, KaiserRules, KaiserAi(), opts(KaiserOptions()))
|
||||||
|
@Test fun wizard() = exercise(GameCatalog.WIZARD, WizardRules, WizardAi(), opts(WizardOptions()))
|
||||||
|
@Test fun crazyEights() = exercise(GameCatalog.CRAZY_EIGHTS, CrazyEightsRules, CrazyEightsAi(), opts(CrazyEightsOptions()))
|
||||||
|
@Test fun cribbage() = exercise(GameCatalog.CRIBBAGE, CribbageRules, CribbageAi(), opts(CribbageOptions()))
|
||||||
|
@Test fun tayu() = exercise(GameCatalog.TAYU, TayuRules, TayuAi(), opts(TayuOptions()))
|
||||||
|
@Test fun checkers() = exercise(GameCatalog.CHECKERS, CheckersRules, CheckersAi(), opts(CheckersOptions()))
|
||||||
|
@Test fun backgammon() = exercise(GameCatalog.BACKGAMMON, BackgammonRules, BackgammonAi(), opts(BackgammonOptions()))
|
||||||
|
@Test fun mastermind() = exercise(GameCatalog.MASTERMIND, MastermindRules, MastermindAi(), opts(MastermindOptions()))
|
||||||
|
@Test fun pirates() = exercise(GameCatalog.PIRATES, PiratesRules, PiratesAi(), opts(PiratesOptions()))
|
||||||
|
@Test fun hearts() = exercise(GameCatalog.HEARTS, HeartsRules, HeartsAi, opts(HeartsOptions()))
|
||||||
|
@Test fun pegSolitaire() = exercise(GameCatalog.PEG_SOLITAIRE, PegSolitaireRules, PegSolitaireAi, opts(PegSolitaireOptions()))
|
||||||
|
@Test fun yahtzee() = exercise(GameCatalog.YAHTZEE, YahtzeeRules, YahtzeeAi, opts(YahtzeeOptions()))
|
||||||
|
@Test fun klondike() = exercise(GameCatalog.KLONDIKE, KlondikeRules, KlondikeAi, opts(KlondikeOptions()))
|
||||||
|
@Test fun freecell() = exercise(GameCatalog.FREECELL, FreeCellRules, FirstLegalAi(), opts(FreeCellOptions()))
|
||||||
|
@Test fun spider() = exercise(GameCatalog.SPIDER, SpiderRules, FirstLegalAi(), opts(SpiderOptions()))
|
||||||
|
@Test fun pyramid() = exercise(GameCatalog.PYRAMID, PyramidRules, FirstLegalAi(), opts(PyramidOptions()))
|
||||||
|
@Test fun morris() = exercise(GameCatalog.MORRIS, MorrisRules, MorrisAi(), opts(MorrisOptions()))
|
||||||
|
@Test fun chess() = exercise(GameCatalog.CHESS, ChessRules, ChessAi(), opts(ChessOptions()), steps = 24)
|
||||||
|
@Test fun catan() = exercise(GameCatalog.CATAN, CatanRules, CatanAi(), opts(CatanOptions()), steps = 400)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun catanCitiesAndKnights() = exercise(
|
||||||
|
GameCatalog.CATAN,
|
||||||
|
CatanRules,
|
||||||
|
CatanAi(),
|
||||||
|
opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)),
|
||||||
|
steps = 600,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
- Build, CI, releases and OTA are described in the CI section of the root
|
||||||
[README](../README.md).
|
[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,
|
- [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.
|
re-copy or re-lay-out it without touching the rules.
|
||||||
- `licenses/` — third-party licences that must travel with the app
|
- `licenses/` — third-party licences that must travel with the app
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Saved games
|
||||||
|
|
||||||
|
A game played against the computer (or on one device) is kept as it is played, and can be
|
||||||
|
picked up again after the app has been closed.
|
||||||
|
|
||||||
|
## What the player sees
|
||||||
|
|
||||||
|
- **Main menu → Continue.** Each game left half-played is listed, most recent first
|
||||||
|
("Chess · saved 5 minutes ago"); tapping it resumes.
|
||||||
|
- **A game's setup screen** shows a *Game in progress* card with **Resume** and **Discard**.
|
||||||
|
Starting a new game from that screen replaces the saved one.
|
||||||
|
- **Leaving the table** (the back arrow, or the app being closed or sent to the background)
|
||||||
|
keeps the position. **End game → End the game** is the deliberate way to throw it away.
|
||||||
|
- A game that is **finished** is not kept.
|
||||||
|
- A **networked** table is not kept: the other player's seat cannot be held for them. Only a
|
||||||
|
table with no remote human at it (`PlayerKind.HUMAN_REMOTE`) is saved.
|
||||||
|
|
||||||
|
One slot per game: there is one saved Chess game, one saved Katan game, and so on.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
| Piece | Where | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SavedMatch`, `SavedGameStore` | `game/engine/SavedGame.kt` | one JSON file per game under `filesDir/saved-games/`, written atomically (temp file, then renamed); an unreadable file is deleted and read as absent |
|
||||||
|
| `MatchController.onHostState`, `restore` | `net/MatchController.kt` | the host reports every position it reaches; `restore(json)` starts the table from a saved position instead of dealing |
|
||||||
|
| `MatchSaver` | `net/MatchSaver.kt` | takes the newest position and writes it off the main thread; `flush()` writes synchronously on leaving or on `ON_STOP`; `discard()` forgets the game |
|
||||||
|
| `StartMatch` | `ui/PlayScreen.kt` | wires the two together for every game; consumes the resume state once, so a solitaire "new deal" is not put back |
|
||||||
|
| `LocalEndGameHandle` | `ui/EndGameHandle.kt` | how each *End game* dialog tells the table to forget its save |
|
||||||
|
|
||||||
|
What is saved is `rules.encodeState` of the **host's true state** (hidden cards included),
|
||||||
|
plus the `TableConfig` (seed, seats, options) and the local seat. Nothing in the store knows
|
||||||
|
which game it is holding, so a new game is saved with no work, provided its state encodes.
|
||||||
|
|
||||||
|
A save this build cannot decode (one written by an older version whose state has since
|
||||||
|
changed shape) is thrown away and a fresh game is dealt.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `SavedGameStoreTest` — the files: round trip, one slot, ordering, corruption, a cut-off write.
|
||||||
|
- `MatchSaverTest` — saving as a table is played, resuming from a save, finished games, flush, discard.
|
||||||
|
- `SaveRoundTripAllGamesTest` — **every game in the catalogue**: the whole state is encoded and
|
||||||
|
decoded at every move of a computer-played game, and play carries on from the decoded copy.
|
||||||
|
A new game should be added to it.
|
||||||
Reference in New Issue
Block a user