Save state for every game between sessions #10
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
private var authoritative: S? = null
|
||||
|
||||
/** A position to carry on from instead of dealing, set by [restore] before [startAsHost]. */
|
||||
private var restored: S? = null
|
||||
|
||||
/**
|
||||
* Host only: told about every position the table reaches, so it can be kept
|
||||
* for later. Called under the table's lock, so it must return quickly — the
|
||||
* snapshot encodes its state only when asked, and the work of writing it
|
||||
* belongs to whoever is listening.
|
||||
*/
|
||||
var onHostState: ((HostSnapshot) -> Unit)? = null
|
||||
|
||||
/** One position of the table, as handed to [onHostState]. */
|
||||
class HostSnapshot(
|
||||
val finished: Boolean,
|
||||
val summary: String,
|
||||
private val encode: () -> String,
|
||||
) {
|
||||
/** The whole true state, encoded by the game's own rules. */
|
||||
fun stateJson(): String = encode()
|
||||
}
|
||||
private val lock = Mutex()
|
||||
private var sequence = 0
|
||||
|
||||
@@ -160,7 +181,7 @@ class MatchController<S : Any, M : Any>(
|
||||
check(role == Role.HOST) { "Not hosting" }
|
||||
clients.forEach { connection -> listenToPeer(connection) }
|
||||
scope.launch {
|
||||
val initial = runCatching { rules.initialState(config) }.getOrElse { error ->
|
||||
val initial = restored ?: runCatching { rules.initialState(config) }.getOrElse { error ->
|
||||
// A table the rules will not accept is a dead end, not a reason
|
||||
// to take the app down with it: this runs in the screen's own
|
||||
// scope, where nothing would catch it.
|
||||
@@ -175,6 +196,20 @@ class MatchController<S : Any, M : Any>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host: carry on from a saved position instead of dealing a new game.
|
||||
*
|
||||
* Call before [startAsHost]. Returns false, and changes nothing, if
|
||||
* [stateJson] cannot be read by this version of the game — a save made by an
|
||||
* older build, say — and the caller should deal a fresh table instead.
|
||||
*/
|
||||
fun restore(stateJson: String): Boolean {
|
||||
check(role == Role.HOST) { "Only a host can restore a game" }
|
||||
val state = runCatching { rules.decodeState(stateJson) }.getOrNull() ?: return false
|
||||
restored = state
|
||||
return true
|
||||
}
|
||||
|
||||
private fun listenToPeer(connection: Connection) {
|
||||
peers[connection.peerId] = connection
|
||||
peerJobs[connection.peerId] = scope.launch {
|
||||
@@ -449,6 +484,16 @@ class MatchController<S : Any, M : Any>(
|
||||
}
|
||||
_finished.value = rules.isFinished(current)
|
||||
|
||||
if (role == Role.HOST) {
|
||||
onHostState?.invoke(
|
||||
HostSnapshot(
|
||||
finished = rules.isFinished(current),
|
||||
summary = rules.summary(current),
|
||||
encode = { rules.encodeState(current) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (slot in config.seats) {
|
||||
val peerId = slot.peerId ?: continue
|
||||
val connection = peers[peerId] ?: continue
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.prolibertate.games.net
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.prolibertate.games.game.engine.SavedGameStore
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Keeps the position of one table on disk as it is played.
|
||||
*
|
||||
* It is fed every position the host reaches ([offer]) and writes the newest one
|
||||
* off the main thread. Positions that arrive while a write is under way are
|
||||
* dropped in favour of the latest — a computer's turn can be several moves in a
|
||||
* second, and only where the game stands matters. A finished game is not kept:
|
||||
* its slot is cleared, so a won game is not offered back as one to resume.
|
||||
*
|
||||
* [flush] writes whatever has not been written yet, on the caller's thread. It
|
||||
* is what makes leaving the table, or the app going to the background, safe: the
|
||||
* background writer may not have caught up, and the process may not be given the
|
||||
* chance to let it.
|
||||
*/
|
||||
class MatchSaver(
|
||||
private val store: SavedGameStore,
|
||||
private val gameId: String,
|
||||
private val config: TableConfig,
|
||||
private val localSeat: Int,
|
||||
private val scope: CoroutineScope,
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
private val latest = MutableStateFlow<MatchController.HostSnapshot?>(null)
|
||||
private var worker: Job? = null
|
||||
private var written: MatchController.HostSnapshot? = null
|
||||
private var discarded = false
|
||||
|
||||
/** Starts the background writer. */
|
||||
fun start() {
|
||||
worker = scope.launch(Dispatchers.IO) {
|
||||
latest.filterNotNull().collectLatest { write(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands over the newest position. Cheap: the encoding happens on the writer. */
|
||||
fun offer(snapshot: MatchController.HostSnapshot) {
|
||||
latest.value = snapshot
|
||||
}
|
||||
|
||||
/** Writes the newest position now if the writer has not already. */
|
||||
fun flush() {
|
||||
latest.value?.let { write(it) }
|
||||
}
|
||||
|
||||
/** The game was ended on purpose: forget the saved position and write no more. */
|
||||
@Synchronized
|
||||
fun discard() {
|
||||
discarded = true
|
||||
worker?.cancel()
|
||||
worker = null
|
||||
runCatching { store.clear(gameId) }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
worker?.cancel()
|
||||
worker = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun write(snapshot: MatchController.HostSnapshot) {
|
||||
if (discarded || snapshot === written) return
|
||||
written = snapshot
|
||||
// Saving is a courtesy to the player: a full disk must not take the game
|
||||
// down with it.
|
||||
runCatching {
|
||||
if (snapshot.finished) {
|
||||
store.clear(gameId)
|
||||
} else {
|
||||
store.save(
|
||||
SavedMatch(
|
||||
gameId = gameId,
|
||||
config = config,
|
||||
localSeat = localSeat,
|
||||
stateJson = snapshot.stateJson(),
|
||||
savedAtMillis = clock(),
|
||||
summary = snapshot.summary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -50,6 +52,10 @@ import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.ui.game.LocalCardSpeed
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
import org.prolibertate.games.update.Updater
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.prolibertate.games.game.engine.SavedGameStore
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
|
||||
/** Everything the screens need, assembled once by the activity. */
|
||||
class AppEnv(
|
||||
@@ -60,6 +66,8 @@ class AppEnv(
|
||||
/** The shared score sheet, for the Scorekeeper to host or join. */
|
||||
val scoreSession: ScorekeeperSession,
|
||||
val peerId: String,
|
||||
/** Games in progress, kept between sessions. */
|
||||
val saves: SavedGameStore,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -83,6 +91,8 @@ sealed interface Route {
|
||||
val config: TableConfig,
|
||||
val hosting: Boolean,
|
||||
val localSeat: Int,
|
||||
/** A saved position to carry on from, as the game's rules encoded it; null deals a new game. */
|
||||
val resumeState: String? = null,
|
||||
) : Route
|
||||
}
|
||||
|
||||
@@ -167,6 +177,10 @@ private fun AppContent(
|
||||
// Dev builds show the games that are not finished yet; production
|
||||
// releases list only what can actually be played.
|
||||
showComingSoon = showComingSoon,
|
||||
saved = savedGames(env, stack),
|
||||
onResume = { saved ->
|
||||
push(Route.Play(saved.gameId, saved.config, true, saved.localSeat, saved.stateJson))
|
||||
},
|
||||
onPickSection = { push(Route.Section(it)) },
|
||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||
onScorekeeper = { push(Route.Scorekeeper) },
|
||||
@@ -194,15 +208,30 @@ private fun AppContent(
|
||||
onBack = { pop() },
|
||||
)
|
||||
|
||||
is Route.Setup -> GameSetupScreen(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.displayName,
|
||||
onBack = { pop() },
|
||||
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
|
||||
onHostOnline = { optionsJson ->
|
||||
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
||||
},
|
||||
)
|
||||
is Route.Setup -> {
|
||||
// Bumped when the saved game is thrown away, so the banner goes with it.
|
||||
var discards by remember(route) { mutableIntStateOf(0) }
|
||||
val saved by produceState<SavedMatch?>(null, route, discards) {
|
||||
value = withContext(Dispatchers.IO) { env.saves.load(route.gameId) }
|
||||
}
|
||||
GameSetupScreen(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.displayName,
|
||||
onBack = { pop() },
|
||||
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
|
||||
onHostOnline = { optionsJson ->
|
||||
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
||||
},
|
||||
saved = saved,
|
||||
onResume = { match ->
|
||||
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
|
||||
},
|
||||
onDiscardSaved = {
|
||||
env.saves.clear(route.gameId)
|
||||
discards++
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is Route.Lobby -> LobbyScreen(
|
||||
env = env,
|
||||
@@ -231,6 +260,8 @@ private fun AppContent(
|
||||
@Composable
|
||||
fun MainMenuScreen(
|
||||
showComingSoon: Boolean,
|
||||
saved: List<SavedMatch>,
|
||||
onResume: (SavedMatch) -> Unit,
|
||||
onPickSection: (GameMenu) -> Unit,
|
||||
onJoinGame: () -> Unit,
|
||||
onScorekeeper: () -> Unit,
|
||||
@@ -280,6 +311,23 @@ fun MainMenuScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Games left half-played, most recent first, above everything
|
||||
// else: coming back to the app is mostly coming back to one.
|
||||
if (saved.isNotEmpty()) {
|
||||
Text(
|
||||
"Continue",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val now = System.currentTimeMillis()
|
||||
saved.forEach { match ->
|
||||
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
|
||||
Button(onClick = { onResume(match) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(savedLine(title, match, now))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Below the games, and full width like them, so the column
|
||||
// stays one column all the way down.
|
||||
OutlinedButton(
|
||||
@@ -490,3 +538,17 @@ fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved games, read off the main thread each time the menu is the screen
|
||||
* showing — which is when a game has just been left and its last position
|
||||
* written, so the list is not read until that has happened.
|
||||
*/
|
||||
@Composable
|
||||
private fun savedGames(env: AppEnv, stack: List<Route>): List<SavedMatch> {
|
||||
val onMenu = stack.last() is Route.Menu
|
||||
val saved by produceState(emptyList<SavedMatch>(), onMenu, stack.size) {
|
||||
value = if (onMenu) withContext(Dispatchers.IO) { env.saves.all() } else emptyList()
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.prolibertate.games.ui
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
/**
|
||||
* How a screen's "End game" button tells the table to forget the saved game.
|
||||
*
|
||||
* Leaving a table keeps its position so it can be resumed; ending one on purpose
|
||||
* does not. The two arrive at the screen as the same "go back", so the
|
||||
* difference is carried here instead: [PlayScreen] provides one handle, the match
|
||||
* it builds says what ending means for it by setting [onEnd], and each End-game
|
||||
* dialog calls [end] before it leaves.
|
||||
*/
|
||||
class EndGameHandle {
|
||||
/** Set by the table once it knows how to forget its own saved position. */
|
||||
var onEnd: () -> Unit = {}
|
||||
|
||||
fun end() = onEnd()
|
||||
}
|
||||
|
||||
val LocalEndGameHandle = staticCompositionLocalOf { EndGameHandle() }
|
||||
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -29,6 +32,7 @@ import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
package org.prolibertate.games.ui
|
||||
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
|
||||
/** How long ago something happened, in the words a person would use. */
|
||||
fun agoText(nowMillis: Long, thenMillis: Long): String {
|
||||
val seconds = ((nowMillis - thenMillis) / 1000).coerceAtLeast(0)
|
||||
val minutes = seconds / 60
|
||||
val hours = minutes / 60
|
||||
val days = hours / 24
|
||||
return when {
|
||||
seconds < 45 -> "just now"
|
||||
minutes < 2 -> "a minute ago"
|
||||
minutes < 60 -> "$minutes minutes ago"
|
||||
hours < 2 -> "an hour ago"
|
||||
hours < 24 -> "$hours hours ago"
|
||||
days < 2 -> "yesterday"
|
||||
else -> "$days days ago"
|
||||
}
|
||||
}
|
||||
|
||||
/** "Chess · saved 5 minutes ago" for a menu line. */
|
||||
fun savedLine(title: String, match: SavedMatch, nowMillis: Long): String =
|
||||
"$title · saved ${agoText(nowMillis, match.savedAtMillis)}"
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -378,7 +379,12 @@ private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -79,7 +80,12 @@ fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text(what) },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -314,7 +315,12 @@ private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -215,7 +216,12 @@ private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -268,7 +269,12 @@ private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -342,7 +343,12 @@ private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The deal is left as it stands.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -356,7 +357,12 @@ private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -483,7 +484,12 @@ private fun LeaveBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -242,7 +243,12 @@ private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The board is left as it stands.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -371,7 +372,12 @@ private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -668,7 +669,12 @@ private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -295,7 +296,12 @@ private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
@@ -603,7 +604,12 @@ fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left.",
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
[README](../README.md).
|
||||
- [SAVED-GAMES.md](SAVED-GAMES.md) — how a game in progress is kept between sessions, and what is
|
||||
not kept.
|
||||
- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle,
|
||||
re-copy or re-lay-out it without touching the rules.
|
||||
- `licenses/` — third-party licences that must travel with the app
|
||||
|
||||
@@ -0,0 +1,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