2 Commits
Author SHA1 Message Date
devadmin 518f80b998 Merge pull request 'Release: dev to main (Cities & Knights)' (#9) from dev into main
Build and Release APK / Build APK and publish release (push) Successful in 4m2s
Build and Release APK / Deploy to Cloudflare Pages (push) Successful in 32s
2026-10-17 12:43:30 -06:00
devadmin 5fdb70cae0 Merge pull request 'Release: dev to main' (#7) from dev into main
Build and Release APK / Build APK and publish release (push) Successful in 4m1s
Build and Release APK / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-15 18:01:36 -06:00
29 changed files with 564 additions and 1648 deletions
@@ -14,9 +14,7 @@ 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() {
@@ -42,7 +40,6 @@ class MainActivity : ComponentActivity() {
deviceId = peerId, deviceId = peerId,
), ),
peerId = peerId, peerId = peerId,
saves = SavedGameStore(File(filesDir, "saved-games")),
) )
setContent { setContent {
@@ -1,101 +0,0 @@
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,27 +96,6 @@ 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
@@ -181,7 +160,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 = restored ?: runCatching { rules.initialState(config) }.getOrElse { error -> val initial = 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.
@@ -196,20 +175,6 @@ 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 {
@@ -484,16 +449,6 @@ 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
@@ -1,95 +0,0 @@
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,8 +29,6 @@ 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
@@ -52,10 +50,6 @@ 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(
@@ -66,8 +60,6 @@ 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,
) )
/** /**
@@ -91,8 +83,6 @@ 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
} }
@@ -177,10 +167,6 @@ 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) },
@@ -208,30 +194,15 @@ private fun AppContent(
onBack = { pop() }, onBack = { pop() },
) )
is Route.Setup -> { is Route.Setup -> GameSetupScreen(
// Bumped when the saved game is thrown away, so the banner goes with it. descriptor = GameCatalog.byId(route.gameId)!!,
var discards by remember(route) { mutableIntStateOf(0) } playerName = settings.displayName,
val saved by produceState<SavedMatch?>(null, route, discards) { onBack = { pop() },
value = withContext(Dispatchers.IO) { env.saves.load(route.gameId) } onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
} onHostOnline = { optionsJson ->
GameSetupScreen( push(Route.Lobby(route.gameId, optionsJson, hosting = true))
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( is Route.Lobby -> LobbyScreen(
env = env, env = env,
@@ -260,8 +231,6 @@ 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,
@@ -311,23 +280,6 @@ 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(
@@ -538,17 +490,3 @@ 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
}
@@ -1,21 +0,0 @@
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,14 +9,11 @@ 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
@@ -32,7 +29,6 @@ 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
@@ -87,10 +83,6 @@ 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()) }
@@ -157,10 +149,6 @@ 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)
@@ -245,12 +233,6 @@ 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),
@@ -1234,30 +1216,3 @@ 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") }
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,24 +0,0 @@
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,6 +1,5 @@
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
@@ -379,12 +378,7 @@ private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "at the table is told you have left."
) )
}, },
confirmButton = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -80,12 +79,7 @@ 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 = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -315,12 +314,7 @@ private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "at the table is told you have left."
) )
}, },
confirmButton = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -216,12 +215,7 @@ private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "at the table is told you have left."
) )
}, },
confirmButton = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -269,12 +268,7 @@ 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 = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -343,12 +342,7 @@ 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 = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -357,12 +356,7 @@ 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 = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -484,12 +483,7 @@ private fun LeaveBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "at the table is told you have left."
) )
}, },
confirmButton = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -243,12 +242,7 @@ 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 = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -372,12 +371,7 @@ private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "at the table is told you have left."
) )
}, },
confirmButton = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -669,12 +668,7 @@ private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"table is told you have left." "table is told you have left."
) )
}, },
confirmButton = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -296,12 +295,7 @@ 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 = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,6 +1,5 @@
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
@@ -604,12 +603,7 @@ fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left.", "at the table is told you have left.",
) )
}, },
confirmButton = { confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
// 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,119 +0,0 @@
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)
}
}
@@ -1,191 +0,0 @@
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
}
}
@@ -1,177 +0,0 @@
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,
)
}
@@ -145,11 +145,6 @@ class ScorekeeperSessionTest {
await("$name to be asking") { host.state.requests.any { it.id == deviceId } } await("$name to be asking") { host.state.requests.any { it.id == deviceId } }
host.session.allow(deviceId) host.session.allow(deviceId)
await("$name to be let in") { guest.state.value.connected } await("$name to be let in") { guest.state.value.connected }
// The sheet follows the connection over the socket, so `connected` can
// flip while it is still in flight. Callers read the guest's sheet at
// once (its players, its rounds), and on a slow runner that was an empty
// one: wait until the guest is looking at the host's.
await("$name to receive the sheet") { guest.state.value.sheet == host.state.sheet }
return guest return guest
} }
@@ -1,27 +0,0 @@
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))
}
}
-2
View File
@@ -42,8 +42,6 @@ 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
-43
View File
@@ -1,43 +0,0 @@
# 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.