Compare commits
12
Commits
meow2
...
yahtzee-stats
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebab7a8415 | ||
|
|
a0b15b3140 | ||
|
|
8990dde363 | ||
|
|
c2b21a07f5 | ||
|
|
e98afbfd8d | ||
|
|
2c90de66d1 | ||
|
|
f93eba88e5 | ||
|
|
36ab518309 | ||
|
|
77cc6c5a05 | ||
|
|
166f85734b | ||
|
|
25e38c4bf8 | ||
|
|
6bf7522042 |
@@ -10,6 +10,7 @@ import org.prolibertate.games.net.LobbyController
|
|||||||
import org.prolibertate.games.net.ScorekeeperSession
|
import org.prolibertate.games.net.ScorekeeperSession
|
||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
|
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||||
import org.prolibertate.games.ui.AppEnv
|
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
|
||||||
@@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
),
|
),
|
||||||
peerId = peerId,
|
peerId = peerId,
|
||||||
saves = SavedGameStore(File(filesDir, "saved-games")),
|
saves = SavedGameStore(File(filesDir, "saved-games")),
|
||||||
|
yahtzeeStats = YahtzeeStatsRepository(applicationContext),
|
||||||
)
|
)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
|
|||||||
@@ -362,12 +362,7 @@ object GameCatalog {
|
|||||||
maxPlayers = 4,
|
maxPlayers = 4,
|
||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
|
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
|
||||||
// Engine and screen exist but have not been verified on two real
|
available = true,
|
||||||
// devices yet. Dev builds can open it as a *Preview*; a production
|
|
||||||
// build does not list it. Flip `available` on, and `devPreview`
|
|
||||||
// off, once two-device play has been verified.
|
|
||||||
available = false,
|
|
||||||
devPreview = true,
|
|
||||||
),
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = MEOWDOKU,
|
id = MEOWDOKU,
|
||||||
@@ -378,8 +373,7 @@ object GameCatalog {
|
|||||||
teamBased = false,
|
teamBased = false,
|
||||||
blurb = "One cat in every row, column and colour, and no two touching. " +
|
blurb = "One cat in every row, column and colour, and no two touching. " +
|
||||||
"Wrong taps cost a heart; the board only ever accepts a cat that belongs.",
|
"Wrong taps cost a heart; the board only ever accepts a cat that belongs.",
|
||||||
available = false,
|
available = true,
|
||||||
devPreview = true,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package org.prolibertate.games.game.yahtzee
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** What one finished game says about the local player's card. */
|
||||||
|
@Serializable
|
||||||
|
data class YahtzeeRecord(
|
||||||
|
val total: Int,
|
||||||
|
val gotBonus: Boolean,
|
||||||
|
/** Yahtzees scored, counting the extra ones that earned the hundred. */
|
||||||
|
val yahtzees: Int,
|
||||||
|
/** Boxes written in as nothing. */
|
||||||
|
val zeros: Int,
|
||||||
|
val players: Int,
|
||||||
|
val won: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Builds the record of [seat]'s card in a finished game. Ties for first count as a win. */
|
||||||
|
fun recordOf(state: YahtzeeState, seat: Int): YahtzeeRecord {
|
||||||
|
val card = state.cards[seat]
|
||||||
|
val mine = state.totalFor(seat)
|
||||||
|
val best = state.cards.indices.maxOf { state.totalFor(it) }
|
||||||
|
return YahtzeeRecord(
|
||||||
|
total = mine,
|
||||||
|
gotBonus = card.upperBonus > 0,
|
||||||
|
yahtzees = (if (card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE) 1 else 0) +
|
||||||
|
state.yahtzeeBonuses[seat] / YAHTZEE_BONUS,
|
||||||
|
zeros = card.boxes.count { it == 0 },
|
||||||
|
players = state.cards.size,
|
||||||
|
won = mine >= best,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A game set against the ones before it. [rank] is 1 for a new best. */
|
||||||
|
data class YahtzeeComparison(
|
||||||
|
val current: YahtzeeRecord,
|
||||||
|
val previousGames: Int,
|
||||||
|
val bestBefore: Int?,
|
||||||
|
val averageBefore: Double?,
|
||||||
|
val rank: Int,
|
||||||
|
val gamesRanked: Int,
|
||||||
|
val bonusRateBefore: Double?,
|
||||||
|
val yahtzeesBefore: Int,
|
||||||
|
val winRateBefore: Double?,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun compareToHistory(previous: List<YahtzeeRecord>, current: YahtzeeRecord): YahtzeeComparison =
|
||||||
|
YahtzeeComparison(
|
||||||
|
current = current,
|
||||||
|
previousGames = previous.size,
|
||||||
|
bestBefore = previous.maxOfOrNull { it.total },
|
||||||
|
averageBefore = previous.takeIf { it.isNotEmpty() }?.map { it.total }?.average(),
|
||||||
|
rank = 1 + previous.count { it.total > current.total },
|
||||||
|
gamesRanked = previous.size + 1,
|
||||||
|
bonusRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.gotBonus }.toDouble() / p.size },
|
||||||
|
yahtzeesBefore = previous.sumOf { it.yahtzees },
|
||||||
|
winRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.won }.toDouble() / p.size },
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.prolibertate.games.score
|
||||||
|
|
||||||
|
/** The short list of names offered when a column is being named. */
|
||||||
|
object RecentNames {
|
||||||
|
const val LIMIT = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [name] moved to the front of [names], trimmed, with any earlier copy
|
||||||
|
* (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name
|
||||||
|
* leaves the list as it was.
|
||||||
|
*/
|
||||||
|
fun with(names: List<String>, name: String): List<String> {
|
||||||
|
val typed = name.trim()
|
||||||
|
if (typed.isEmpty()) return names
|
||||||
|
return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) {
|
|||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val SHEET = stringPreferencesKey("sheet")
|
val SHEET = stringPreferencesKey("sheet")
|
||||||
|
val RECENT_NAMES = stringPreferencesKey("recent_names")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown keys are ignored so a sheet written by a newer build can still be
|
// Unknown keys are ignored so a sheet written by a newer build can still be
|
||||||
@@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) {
|
|||||||
?: ScoreSheet()
|
?: ScoreSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The names last used on a sheet, most recent first, for the rename dialog. */
|
||||||
|
val recentNames: Flow<List<String>> = context.scorekeeperDataStore.data.map { prefs ->
|
||||||
|
prefs[Keys.RECENT_NAMES]
|
||||||
|
?.let { stored ->
|
||||||
|
runCatching { json.decodeFromString<List<String>>(stored) }.getOrNull()
|
||||||
|
}
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun rememberName(name: String) {
|
||||||
|
context.scorekeeperDataStore.edit { prefs ->
|
||||||
|
val current = prefs[Keys.RECENT_NAMES]
|
||||||
|
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
|
||||||
|
.orEmpty()
|
||||||
|
prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun save(sheet: ScoreSheet) {
|
suspend fun save(sheet: ScoreSheet) {
|
||||||
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
|
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,18 @@ sealed interface SheetOp {
|
|||||||
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
|
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("start")
|
@SerialName("start")
|
||||||
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
|
data class StartSheet(
|
||||||
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
|
val count: Int,
|
||||||
|
val mode: ScoringMode = ScoringMode.ROUNDS,
|
||||||
|
/** Names for the first seats, in order; a seat past the end stays unnamed. */
|
||||||
|
val names: List<String> = emptyList(),
|
||||||
|
) : SheetOp {
|
||||||
|
override fun applyTo(sheet: ScoreSheet): ScoreSheet {
|
||||||
|
val seated = ScoreSheet.of(count, mode)
|
||||||
|
return seated.players.foldIndexed(seated) { index, acc, player ->
|
||||||
|
names.getOrNull(index)?.let { acc.renamed(player.id, it) } ?: acc
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rubs everything out. */
|
/** Rubs everything out. */
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ data class Settings(
|
|||||||
val soundEnabled: Boolean = true,
|
val soundEnabled: Boolean = true,
|
||||||
val animationSpeed: Float = 1.0f,
|
val animationSpeed: Float = 1.0f,
|
||||||
val checkForUpdatesOnLaunch: Boolean = true,
|
val checkForUpdatesOnLaunch: Boolean = true,
|
||||||
|
/** Multiplies the system font size across the whole app. */
|
||||||
|
val textScale: Float = 1.0f,
|
||||||
/**
|
/**
|
||||||
* Exactly what the user has typed, which may be blank while they are
|
* Exactly what the user has typed, which may be blank while they are
|
||||||
* mid-edit. Nothing substitutes a default into this value: doing so meant
|
* mid-edit. Nothing substitutes a default into this value: doing so meant
|
||||||
@@ -46,6 +48,8 @@ data class Settings(
|
|||||||
companion object {
|
companion object {
|
||||||
const val MIN_SPEED = 0.5f
|
const val MIN_SPEED = 0.5f
|
||||||
const val MAX_SPEED = 2.0f
|
const val MAX_SPEED = 2.0f
|
||||||
|
const val MIN_TEXT_SCALE = 0.8f
|
||||||
|
const val MAX_TEXT_SCALE = 1.6f
|
||||||
const val DEFAULT_PLAYER_NAME = "Player"
|
const val DEFAULT_PLAYER_NAME = "Player"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +60,7 @@ class SettingsRepository(private val context: Context) {
|
|||||||
val SOUND = booleanPreferencesKey("sound_enabled")
|
val SOUND = booleanPreferencesKey("sound_enabled")
|
||||||
val SPEED = floatPreferencesKey("animation_speed")
|
val SPEED = floatPreferencesKey("animation_speed")
|
||||||
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
|
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
|
||||||
|
val TEXT_SCALE = floatPreferencesKey("text_scale")
|
||||||
val NAME = stringPreferencesKey("player_name")
|
val NAME = stringPreferencesKey("player_name")
|
||||||
// There is deliberately no update-channel key. The channel is a
|
// There is deliberately no update-channel key. The channel is a
|
||||||
// property of the installed APK, not a preference — an install that
|
// property of the installed APK, not a preference — an install that
|
||||||
@@ -69,6 +74,8 @@ class SettingsRepository(private val context: Context) {
|
|||||||
soundEnabled = prefs[Keys.SOUND] ?: true,
|
soundEnabled = prefs[Keys.SOUND] ?: true,
|
||||||
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
||||||
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
||||||
|
textScale = (prefs[Keys.TEXT_SCALE] ?: 1.0f)
|
||||||
|
.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE),
|
||||||
playerName = prefs[Keys.NAME] ?: "",
|
playerName = prefs[Keys.NAME] ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -83,6 +90,12 @@ class SettingsRepository(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setTextScale(scale: Float) {
|
||||||
|
context.dataStore.edit {
|
||||||
|
it[Keys.TEXT_SCALE] = scale.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
|
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
|
||||||
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
|
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package org.prolibertate.games.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeRecord
|
||||||
|
import org.prolibertate.games.game.yahtzee.compareToHistory
|
||||||
|
|
||||||
|
private val Context.yahtzeeStatsStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = "yahtzee_stats",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The local player's finished Yahtzee games, newest last, kept to the last [KEEP]. */
|
||||||
|
class YahtzeeStatsRepository(private val context: Context) {
|
||||||
|
|
||||||
|
private val key = stringPreferencesKey("games")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun decode(stored: String?): List<YahtzeeRecord> =
|
||||||
|
stored?.let { runCatching { json.decodeFromString<List<YahtzeeRecord>>(it) }.getOrNull() }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
/** Compares [record] with every earlier game, then adds it to the history. */
|
||||||
|
suspend fun finish(record: YahtzeeRecord): YahtzeeComparison {
|
||||||
|
val previous = decode(context.yahtzeeStatsStore.data.first()[key])
|
||||||
|
context.yahtzeeStatsStore.edit {
|
||||||
|
it[key] = json.encodeToString((previous + record).takeLast(KEEP))
|
||||||
|
}
|
||||||
|
return compareToHistory(previous, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val KEEP = 500
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -49,6 +51,7 @@ import org.prolibertate.games.net.ScorekeeperSession
|
|||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
|
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||||
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
|
||||||
@@ -68,6 +71,7 @@ class AppEnv(
|
|||||||
val peerId: String,
|
val peerId: String,
|
||||||
/** Games in progress, kept between sessions. */
|
/** Games in progress, kept between sessions. */
|
||||||
val saves: SavedGameStore,
|
val saves: SavedGameStore,
|
||||||
|
val yahtzeeStats: YahtzeeStatsRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,7 +143,14 @@ fun AppRoot(env: AppEnv) {
|
|||||||
// Card motion reads its speed from here rather than from a parameter.
|
// Card motion reads its speed from here rather than from a parameter.
|
||||||
// Every card screen animates, most of them several layers deep, and the
|
// Every card screen animates, most of them several layers deep, and the
|
||||||
// alternative was passing one unchanging number down through all of it.
|
// alternative was passing one unchanging number down through all of it.
|
||||||
CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
|
val density = LocalDensity.current
|
||||||
|
val scaledDensity = remember(density, settings.textScale) {
|
||||||
|
Density(density.density, density.fontScale * settings.textScale)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalCardSpeed provides settings.animationSpeed,
|
||||||
|
LocalDensity provides scaledDensity,
|
||||||
|
) {
|
||||||
AppContent(
|
AppContent(
|
||||||
env = env,
|
env = env,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
|
|||||||
@@ -594,7 +594,12 @@ fun PlayScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
StartMatch(env, controller, route)
|
StartMatch(env, controller, route)
|
||||||
YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
YahtzeeScreen(
|
||||||
|
controller = controller,
|
||||||
|
localSeat = route.localSeat,
|
||||||
|
stats = env.yahtzeeStats,
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
GameCatalog.KLONDIKE -> {
|
GameCatalog.KLONDIKE -> {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -114,6 +115,8 @@ fun ScorekeeperScreen(
|
|||||||
local = repository.sheet.first()
|
local = repository.sheet.first()
|
||||||
loaded = true
|
loaded = true
|
||||||
}
|
}
|
||||||
|
val recentNames by repository.recentNames.collectAsState(initial = emptyList())
|
||||||
|
val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } }
|
||||||
|
|
||||||
val shared by session.state.collectAsState()
|
val shared by session.state.collectAsState()
|
||||||
val role = shared.role
|
val role = shared.role
|
||||||
@@ -220,7 +223,7 @@ fun ScorekeeperScreen(
|
|||||||
|
|
||||||
role == Role.GUEST -> Column(modifier = modifier) {
|
role == Role.GUEST -> Column(modifier = modifier) {
|
||||||
ConnectionBanner(shared = shared, onSync = { session.sync() })
|
ConnectionBanner(shared = shared, onSync = { session.sync() })
|
||||||
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
|
Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
joining && role == Role.NONE ->
|
joining && role == Role.NONE ->
|
||||||
@@ -228,11 +231,16 @@ fun ScorekeeperScreen(
|
|||||||
|
|
||||||
!sheet.started -> SetupPane(
|
!sheet.started -> SetupPane(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onStart = { count, mode -> change(StartSheet(count, mode)) },
|
recentNames = recentNames,
|
||||||
|
onStart = { count, mode, names ->
|
||||||
|
change(StartSheet(count, mode, names))
|
||||||
|
// Last first, so the list reads in seating order.
|
||||||
|
names.reversed().forEach(nameUsed)
|
||||||
|
},
|
||||||
onJoin = { joining = true },
|
onJoin = { joining = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
|
else -> Scoring(modifier, sheet, change, recentNames, nameUsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,9 +297,46 @@ fun ScorekeeperScreen(
|
|||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) {
|
private fun SetupPane(
|
||||||
|
modifier: Modifier,
|
||||||
|
recentNames: List<String>,
|
||||||
|
onStart: (Int, ScoringMode, List<String>) -> Unit,
|
||||||
|
onJoin: () -> Unit,
|
||||||
|
) {
|
||||||
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
|
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
|
||||||
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
|
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
|
||||||
|
// -1 while the options are showing; otherwise the seat being named.
|
||||||
|
var naming by remember { mutableStateOf(-1) }
|
||||||
|
val names = remember { mutableStateListOf<String>() }
|
||||||
|
|
||||||
|
if (naming >= 0) {
|
||||||
|
NamePane(
|
||||||
|
modifier = modifier,
|
||||||
|
seat = naming,
|
||||||
|
count = count,
|
||||||
|
name = names.getOrElse(naming) { "" },
|
||||||
|
// Offered once only: two players on one sheet with the same name
|
||||||
|
// are indistinguishable.
|
||||||
|
suggestions = recentNames.filter { recent ->
|
||||||
|
names.withIndex().none { (seat, taken) ->
|
||||||
|
seat != naming && taken.trim().equals(recent.trim(), ignoreCase = true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onName = { typed ->
|
||||||
|
while (names.size <= naming) names.add("")
|
||||||
|
names[naming] = typed
|
||||||
|
},
|
||||||
|
onBack = { naming -= 1 },
|
||||||
|
onNext = {
|
||||||
|
if (naming + 1 < count) {
|
||||||
|
naming += 1
|
||||||
|
} else {
|
||||||
|
onStart(count, mode, List(count) { names.getOrElse(it) { "" }.trim() })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
@@ -339,33 +384,98 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Names come next: tap a column head to name it. Players can be " +
|
text = "You will be asked for each name next, or skip them and tap a " +
|
||||||
"added, removed or dragged into a different order at any point " +
|
"column head later. Players can be added, removed or dragged into " +
|
||||||
"without losing anybody's score.",
|
"a different order at any point without losing anybody's score.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
PrimaryAction(text = "Start scoring") { onStart(count, mode) }
|
PrimaryAction(text = "Next: name the players") { naming = 0 }
|
||||||
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Join a shared sheet nearby")
|
Text("Join a shared sheet nearby")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One seat's name, asked for on its own; a blank leaves the seat as "Player N". */
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun NamePane(
|
||||||
|
modifier: Modifier,
|
||||||
|
seat: Int,
|
||||||
|
count: Int,
|
||||||
|
name: String,
|
||||||
|
suggestions: List<String>,
|
||||||
|
onName: (String) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(seat) { focus.requestFocus() }
|
||||||
|
val last = seat + 1 >= count
|
||||||
|
Column(
|
||||||
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text("Player ${seat + 1} of $count", style = MaterialTheme.typography.titleMedium)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = onName,
|
||||||
|
placeholder = { Text("Player ${seat + 1}") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = if (last) ImeAction.Done else ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }),
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(focus),
|
||||||
|
)
|
||||||
|
if (suggestions.isNotEmpty()) {
|
||||||
|
Text("Recent names", style = MaterialTheme.typography.labelMedium)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
suggestions.forEach { recent ->
|
||||||
|
FilterChip(
|
||||||
|
selected = recent.equals(name.trim(), ignoreCase = true),
|
||||||
|
onClick = { onName(recent) },
|
||||||
|
label = { Text(recent) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrimaryAction(text = if (last) "Start scoring" else "Next") { onNext() }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TextButton(onClick = onBack) { Text("Back") }
|
||||||
|
TextButton(onClick = { onName(""); onNext() }) { Text(if (last) "Skip and start" else "Skip") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// The sheet: a column per player, a row per round, totals ruled off at the foot
|
// The sheet: a column per player, a row per round, totals ruled off at the foot
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
private fun Scoring(
|
||||||
|
modifier: Modifier,
|
||||||
|
sheet: ScoreSheet,
|
||||||
|
onOp: (SheetOp) -> Unit,
|
||||||
|
recentNames: List<String>,
|
||||||
|
onNameUsed: (String) -> Unit,
|
||||||
|
) {
|
||||||
if (sheet.mode == ScoringMode.TALLY) {
|
if (sheet.mode == ScoringMode.TALLY) {
|
||||||
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
|
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
|
||||||
} else {
|
} else {
|
||||||
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
|
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
private fun Sheet(
|
||||||
|
modifier: Modifier,
|
||||||
|
sheet: ScoreSheet,
|
||||||
|
onOp: (SheetOp) -> Unit,
|
||||||
|
recentNames: List<String>,
|
||||||
|
onNameUsed: (String) -> Unit,
|
||||||
|
) {
|
||||||
// One scroll state shared by the head, the rows and the totals, so all three
|
// One scroll state shared by the head, the rows and the totals, so all three
|
||||||
// move together: a column and the name over it must never come apart. Every
|
// move together: a column and the name over it must never come apart. Every
|
||||||
// row is built the same way and is therefore exactly the same width, which
|
// row is built the same way and is therefore exactly the same width, which
|
||||||
@@ -384,6 +494,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
|||||||
var lastTyped by remember { mutableStateOf(-1) }
|
var lastTyped by remember { mutableStateOf(-1) }
|
||||||
// The cell to put the cursor in when a round is opened by tapping it.
|
// The cell to put the cursor in when a round is opened by tapping it.
|
||||||
var focusPlayer by remember { mutableStateOf(-1) }
|
var focusPlayer by remember { mutableStateOf(-1) }
|
||||||
|
// Bumped when a round is finished, to put the cursor back in the first cell
|
||||||
|
// of the row that is now being filled in.
|
||||||
|
var nextRoundFocus by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
val editing = if (correcting >= 0) correction else pending
|
val editing = if (correcting >= 0) correction else pending
|
||||||
|
|
||||||
@@ -513,7 +626,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) },
|
trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) },
|
||||||
) { _, player ->
|
) { index, player ->
|
||||||
if (correcting >= 0) {
|
if (correcting >= 0) {
|
||||||
// A round is being corrected above; what is typed here
|
// A round is being corrected above; what is typed here
|
||||||
// is kept, but only one row is edited at a time.
|
// is kept, but only one row is edited at a time.
|
||||||
@@ -521,6 +634,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
|||||||
} else {
|
} else {
|
||||||
PointsField(
|
PointsField(
|
||||||
text = pending[player.id].orEmpty(),
|
text = pending[player.id].orEmpty(),
|
||||||
|
autoFocus = index == 0 && nextRoundFocus > 0,
|
||||||
|
focusRequest = nextRoundFocus,
|
||||||
onText = {
|
onText = {
|
||||||
pending[player.id] = it
|
pending[player.id] = it
|
||||||
lastTyped = player.id
|
lastTyped = player.id
|
||||||
@@ -563,6 +678,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
|||||||
onOp(AddRound(amountsAsPoints(pending)))
|
onOp(AddRound(amountsAsPoints(pending)))
|
||||||
pending.clear()
|
pending.clear()
|
||||||
}
|
}
|
||||||
|
nextRoundFocus += 1
|
||||||
lastTyped = -1
|
lastTyped = -1
|
||||||
focusPlayer = -1
|
focusPlayer = -1
|
||||||
},
|
},
|
||||||
@@ -587,6 +703,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
|||||||
RenameDialog(
|
RenameDialog(
|
||||||
sheet = sheet,
|
sheet = sheet,
|
||||||
player = renamed,
|
player = renamed,
|
||||||
|
recentNames = recentNames,
|
||||||
|
onNameUsed = onNameUsed,
|
||||||
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||||
onRemove = {
|
onRemove = {
|
||||||
onOp(RemovePlayer(renamed.id))
|
onOp(RemovePlayer(renamed.id))
|
||||||
@@ -607,7 +725,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
|||||||
* sheet, so tapping one in the list takes it back off.
|
* sheet, so tapping one in the list takes it back off.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
private fun TallySheet(
|
||||||
|
modifier: Modifier,
|
||||||
|
sheet: ScoreSheet,
|
||||||
|
onOp: (SheetOp) -> Unit,
|
||||||
|
recentNames: List<String>,
|
||||||
|
onNameUsed: (String) -> Unit,
|
||||||
|
) {
|
||||||
var renaming by remember { mutableStateOf(-1) }
|
var renaming by remember { mutableStateOf(-1) }
|
||||||
|
|
||||||
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -676,6 +800,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
|
|||||||
RenameDialog(
|
RenameDialog(
|
||||||
sheet = sheet,
|
sheet = sheet,
|
||||||
player = renamed,
|
player = renamed,
|
||||||
|
recentNames = recentNames,
|
||||||
|
onNameUsed = onNameUsed,
|
||||||
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||||
onRemove = {
|
onRemove = {
|
||||||
onOp(RemovePlayer(renamed.id))
|
onOp(RemovePlayer(renamed.id))
|
||||||
@@ -961,9 +1087,14 @@ private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Uni
|
|||||||
|
|
||||||
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
|
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) {
|
private fun PointsField(
|
||||||
|
text: String,
|
||||||
|
onText: (String) -> Unit,
|
||||||
|
autoFocus: Boolean = false,
|
||||||
|
focusRequest: Int = 0,
|
||||||
|
) {
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
|
LaunchedEffect(autoFocus, focusRequest) { if (autoFocus) focus.requestFocus() }
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { onText(sanitiseAmount(it)) },
|
onValueChange = { onText(sanitiseAmount(it)) },
|
||||||
@@ -1064,16 +1195,27 @@ private fun ActionBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenameDialog(
|
private fun RenameDialog(
|
||||||
sheet: ScoreSheet,
|
sheet: ScoreSheet,
|
||||||
player: ScorePlayer,
|
player: ScorePlayer,
|
||||||
|
recentNames: List<String>,
|
||||||
|
onNameUsed: (String) -> Unit,
|
||||||
onName: (String) -> Unit,
|
onName: (String) -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Closing the dialog is what files the name away, so a name is remembered
|
||||||
|
// once rather than once per keystroke.
|
||||||
|
val finish = {
|
||||||
|
onNameUsed(player.name)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() }
|
||||||
|
val suggestions = recentNames.filter { it.trim().lowercase() !in taken }
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = finish,
|
||||||
title = { Text("Name this column") },
|
title = { Text("Name this column") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -1084,6 +1226,21 @@ private fun RenameDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
if (suggestions.isNotEmpty()) {
|
||||||
|
Text("Recent names", style = MaterialTheme.typography.labelMedium)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
suggestions.forEach { name ->
|
||||||
|
FilterChip(
|
||||||
|
selected = name.equals(player.name.trim(), ignoreCase = true),
|
||||||
|
onClick = { onName(name) },
|
||||||
|
label = { Text(name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onRemove,
|
onClick = onRemove,
|
||||||
// The last two columns stay: a sheet needs somebody on it.
|
// The last two columns stay: a sheet needs somebody on it.
|
||||||
@@ -1091,7 +1248,7 @@ private fun RenameDialog(
|
|||||||
) { Text("Remove this player and their score") }
|
) { Text("Remove this player and their score") }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
confirmButton = { TextButton(onClick = finish) { Text("Done") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.update.Release
|
import org.prolibertate.games.update.Release
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
@@ -83,6 +84,26 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text("Text size", fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
text = "${(settings.textScale * 100).roundToInt()}% — " +
|
||||||
|
"scales text throughout the app, on top of your phone's setting",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Slider(
|
||||||
|
value = settings.textScale,
|
||||||
|
onValueChange = { scale ->
|
||||||
|
scope.launch { env.settingsRepository.setTextScale(scale) }
|
||||||
|
},
|
||||||
|
valueRange = Settings.MIN_TEXT_SCALE..Settings.MAX_TEXT_SCALE,
|
||||||
|
// 10% increments across 80%-160%.
|
||||||
|
steps = 7,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Text("Your name", fontWeight = FontWeight.Bold)
|
Text("Your name", fontWeight = FontWeight.Bold)
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import androidx.compose.foundation.border
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
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.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -30,12 +34,17 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.game.yahtzee.DICE_COUNT
|
import org.prolibertate.games.game.yahtzee.DICE_COUNT
|
||||||
import org.prolibertate.games.game.yahtzee.RollDice
|
import org.prolibertate.games.game.yahtzee.RollDice
|
||||||
import org.prolibertate.games.game.yahtzee.ScoreIn
|
import org.prolibertate.games.game.yahtzee.ScoreIn
|
||||||
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
|
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
|
||||||
|
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
|
||||||
|
import org.prolibertate.games.game.yahtzee.recordOf
|
||||||
|
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import org.prolibertate.games.game.yahtzee.YahtzeeMove
|
import org.prolibertate.games.game.yahtzee.YahtzeeMove
|
||||||
import org.prolibertate.games.game.yahtzee.YahtzeeSection
|
import org.prolibertate.games.game.yahtzee.YahtzeeSection
|
||||||
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
||||||
@@ -61,6 +70,7 @@ private const val TITLE = "Yahtzee"
|
|||||||
fun YahtzeeScreen(
|
fun YahtzeeScreen(
|
||||||
controller: MatchController<YahtzeeState, YahtzeeMove>,
|
controller: MatchController<YahtzeeState, YahtzeeMove>,
|
||||||
localSeat: Int,
|
localSeat: Int,
|
||||||
|
stats: YahtzeeStatsRepository,
|
||||||
onExit: () -> Unit,
|
onExit: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsState()
|
val state by controller.state.collectAsState()
|
||||||
@@ -69,6 +79,11 @@ fun YahtzeeScreen(
|
|||||||
|
|
||||||
val kept = remember { mutableStateListOf<Int>() }
|
val kept = remember { mutableStateListOf<Int>() }
|
||||||
var confirmingEnd by remember { mutableStateOf(false) }
|
var confirmingEnd by remember { mutableStateOf(false) }
|
||||||
|
var confirmingZero by remember { mutableStateOf<YahtzeeCategory?>(null) }
|
||||||
|
var comparison by remember { mutableStateOf<YahtzeeComparison?>(null) }
|
||||||
|
// Only a game seen to finish is recorded: reopening a saved game that was
|
||||||
|
// already over must not count it a second time.
|
||||||
|
var sawInProgress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val endGameAction: @Composable () -> Unit = {
|
val endGameAction: @Composable () -> Unit = {
|
||||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||||
@@ -89,6 +104,15 @@ fun YahtzeeScreen(
|
|||||||
// refused, and the Throw button would look broken again.
|
// refused, and the Throw button would look broken again.
|
||||||
LaunchedEffect(current.turn, current.round) { kept.clear() }
|
LaunchedEffect(current.turn, current.round) { kept.clear() }
|
||||||
|
|
||||||
|
LaunchedEffect(current.isOver) {
|
||||||
|
if (!current.isOver) {
|
||||||
|
sawInProgress = true
|
||||||
|
} else if (sawInProgress) {
|
||||||
|
sawInProgress = false
|
||||||
|
comparison = stats.finish(recordOf(current, localSeat))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val yours = current.turn == localSeat && !current.isOver
|
val yours = current.turn == localSeat && !current.isOver
|
||||||
val canRoll = legal.any { it is RollDice }
|
val canRoll = legal.any { it is RollDice }
|
||||||
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
|
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
|
||||||
@@ -138,8 +162,14 @@ fun YahtzeeScreen(
|
|||||||
localSeat = localSeat,
|
localSeat = localSeat,
|
||||||
writable = if (yours) writable else emptySet(),
|
writable = if (yours) writable else emptySet(),
|
||||||
onWrite = {
|
onWrite = {
|
||||||
controller.submit(ScoreIn(it))
|
// A zero is a box spent for nothing, and one mis-tap on a
|
||||||
kept.clear()
|
// dense card is all it takes, so ask first.
|
||||||
|
if (scoreOf(it, current.dice) == 0) {
|
||||||
|
confirmingZero = it
|
||||||
|
} else {
|
||||||
|
controller.submit(ScoreIn(it))
|
||||||
|
kept.clear()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -150,9 +180,79 @@ fun YahtzeeScreen(
|
|||||||
|
|
||||||
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
||||||
|
comparison?.let { StatsDialog(it, onExit) { comparison = null } }
|
||||||
|
confirmingZero?.let { category ->
|
||||||
|
ZeroScoreDialog(
|
||||||
|
category = category,
|
||||||
|
onConfirm = {
|
||||||
|
confirmingZero = null
|
||||||
|
controller.submit(ScoreIn(category))
|
||||||
|
kept.clear()
|
||||||
|
},
|
||||||
|
onCancel = { confirmingZero = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatsDialog(c: YahtzeeComparison, onLeave: () -> Unit, onClose: () -> Unit) {
|
||||||
|
fun pct(v: Double?) = v?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||||
|
val headline = when {
|
||||||
|
c.previousGames == 0 -> "Your first recorded game."
|
||||||
|
c.rank == 1 -> "A new personal best!"
|
||||||
|
else -> "${ordinal(c.rank)} best of ${c.gamesRanked} games."
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
title = { Text("Game over: ${c.current.total} points") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(headline, fontWeight = FontWeight.Bold)
|
||||||
|
StatLine("", "This game", "Before")
|
||||||
|
StatLine("Score", "${c.current.total}", c.averageBefore?.let { "avg ${it.roundToInt()}, best ${c.bestBefore}" } ?: "—")
|
||||||
|
StatLine("Upper bonus", if (c.current.gotBonus) "Yes" else "No", pct(c.bonusRateBefore))
|
||||||
|
StatLine("Yahtzees", "${c.current.yahtzees}", "${c.yahtzeesBefore} total")
|
||||||
|
StatLine("Zeroed boxes", "${c.current.zeros}", "")
|
||||||
|
if (c.current.players > 1) {
|
||||||
|
StatLine("Result", if (c.current.won) "Won" else "Lost", "won ${pct(c.winRateBefore)}")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Games before this one: ${c.previousGames}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onLeave) { Text("Leave the game") } },
|
||||||
|
dismissButton = { TextButton(onClick = onClose) { Text("Look at the card") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatLine(label: String, now: String, before: String) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(label, modifier = Modifier.weight(1.2f), fontWeight = FontWeight.Bold)
|
||||||
|
Text(now, modifier = Modifier.weight(1f))
|
||||||
|
Text(before, modifier = Modifier.weight(1.6f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ordinal(n: Int): String {
|
||||||
|
val suffix = if (n % 100 in 11..13) "th" else when (n % 10) { 1 -> "st"; 2 -> "nd"; 3 -> "rd"; else -> "th" }
|
||||||
|
return "$n$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ZeroScoreDialog(category: YahtzeeCategory, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text("Score zero?") },
|
||||||
|
text = { Text("These dice are worth 0 in ${category.label}. Write the zero there?") },
|
||||||
|
confirmButton = { TextButton(onClick = onConfirm) { Text("Score 0") } },
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Choose another") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiceRow(
|
private fun DiceRow(
|
||||||
dice: List<Int>,
|
dice: List<Int>,
|
||||||
@@ -190,12 +290,29 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
|
|||||||
.clickable(enabled = enabled) { onClick() },
|
.clickable(enabled = enabled) { onClick() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
if (face != null) {
|
||||||
text = face?.toString() ?: "·",
|
Canvas(modifier = Modifier.size(48.dp)) {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
val radius = size.minDimension * 0.08f
|
||||||
fontWeight = FontWeight.Bold,
|
pipPositions(face).forEach { (x, y) ->
|
||||||
color = WallaceRed,
|
drawCircle(WallaceRed, radius, Offset(size.width * x, size.height * y))
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pip centres as fractions of the die face, in the usual casino layout. */
|
||||||
|
private fun pipPositions(face: Int): List<Pair<Float, Float>> {
|
||||||
|
val lo = 0.28f
|
||||||
|
val mid = 0.5f
|
||||||
|
val hi = 0.72f
|
||||||
|
return when (face) {
|
||||||
|
1 -> listOf(mid to mid)
|
||||||
|
2 -> listOf(lo to lo, hi to hi)
|
||||||
|
3 -> listOf(lo to lo, mid to mid, hi to hi)
|
||||||
|
4 -> listOf(lo to lo, hi to lo, lo to hi, hi to hi)
|
||||||
|
5 -> listOf(lo to lo, hi to lo, mid to mid, lo to hi, hi to hi)
|
||||||
|
else -> listOf(lo to lo, hi to lo, lo to mid, hi to mid, lo to hi, hi to hi)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,28 +323,61 @@ private fun ScoreCard(
|
|||||||
writable: Set<YahtzeeCategory>,
|
writable: Set<YahtzeeCategory>,
|
||||||
onWrite: (YahtzeeCategory) -> Unit,
|
onWrite: (YahtzeeCategory) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
// Upper boxes and bonus on the left, lower boxes and total on the right, so
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
// each half is wide enough for text that can actually be read. Each half
|
||||||
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
|
// has a floor on its width; if two of them do not fit (many players, large
|
||||||
state.cards.indices.forEach { seat ->
|
// text) the pair scrolls sideways instead of squeezing.
|
||||||
Text(
|
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||||
text = if (seat == localSeat) "You" else "S$seat",
|
val needed = (CARD_LABEL_WIDTH + CARD_SEAT_WIDTH * state.cards.size).dp
|
||||||
modifier = Modifier.weight(1f),
|
val half = maxOf((maxWidth - CARD_GAP.dp) / 2, needed)
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Row(
|
||||||
fontWeight = FontWeight.Bold,
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
)
|
horizontalArrangement = Arrangement.spacedBy(CARD_GAP.dp),
|
||||||
}
|
) {
|
||||||
}
|
Column(modifier = Modifier.width(half)) {
|
||||||
YahtzeeSection.entries.forEach { section ->
|
CardHeader(state, localSeat)
|
||||||
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
|
CardSection(YahtzeeSection.UPPER, state, localSeat, writable, onWrite)
|
||||||
CardRow(state, localSeat, category, writable, onWrite)
|
|
||||||
}
|
|
||||||
if (section == YahtzeeSection.UPPER) {
|
|
||||||
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
|
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
|
||||||
}
|
}
|
||||||
|
Column(modifier = Modifier.width(half)) {
|
||||||
|
CardHeader(state, localSeat)
|
||||||
|
CardSection(YahtzeeSection.LOWER, state, localSeat, writable, onWrite)
|
||||||
|
Divider()
|
||||||
|
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Divider()
|
}
|
||||||
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
|
}
|
||||||
|
|
||||||
|
private const val CARD_LABEL_WIDTH = 120
|
||||||
|
private const val CARD_SEAT_WIDTH = 56
|
||||||
|
private const val CARD_GAP = 12
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CardHeader(state: YahtzeeState, localSeat: Int) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("", modifier = Modifier.weight(2f))
|
||||||
|
state.cards.indices.forEach { seat ->
|
||||||
|
Text(
|
||||||
|
text = if (seat == localSeat) "You" else "S$seat",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CardSection(
|
||||||
|
section: YahtzeeSection,
|
||||||
|
state: YahtzeeState,
|
||||||
|
localSeat: Int,
|
||||||
|
writable: Set<YahtzeeCategory>,
|
||||||
|
onWrite: (YahtzeeCategory) -> Unit,
|
||||||
|
) {
|
||||||
|
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
|
||||||
|
CardRow(state, localSeat, category, writable, onWrite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,12 +394,12 @@ private fun CardRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = open) { onWrite(category) }
|
.clickable(enabled = open) { onWrite(category) }
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 5.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = category.label,
|
text = category.label,
|
||||||
modifier = Modifier.weight(2f),
|
modifier = Modifier.weight(2f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
state.cards.forEachIndexed { seat, card ->
|
state.cards.forEachIndexed { seat, card ->
|
||||||
@@ -263,7 +413,7 @@ private fun CardRow(
|
|||||||
else -> "—"
|
else -> "—"
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
|
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -272,18 +422,18 @@ private fun CardRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
|
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
modifier = Modifier.weight(2f),
|
modifier = Modifier.weight(2f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
values.forEach {
|
values.forEach {
|
||||||
Text(
|
Text(
|
||||||
text = "$it",
|
text = "$it",
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,42 +106,33 @@ class CatalogTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `every reference game in the catalogue can be played`() {
|
fun `every game in the catalogue can be played`() {
|
||||||
// Every game the app ships is playable. The exceptions are the games
|
// `available` is the release gate. Nothing is held back at the moment,
|
||||||
// whose engine and screen exist but have not been verified on a real
|
// so a production menu lists every game; a game parked behind the gate
|
||||||
// device yet: they are marked as previews so a production build keeps
|
// belongs in `devPreview`, and must say so rather than being quietly
|
||||||
// them out while a dev one can open them. `available = false` is what
|
// unavailable everywhere.
|
||||||
// keeps an unverified game out of a production build while leaving it
|
GameCatalog.all.filterNot { it.available }.forEach { game ->
|
||||||
// visible on a dev one.
|
assertTrue("${game.id} is unavailable but not a dev preview", game.devPreview)
|
||||||
assertEquals(
|
}
|
||||||
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU),
|
GameMenu.entries.forEach { menu ->
|
||||||
GameCatalog.all.filterNot { it.available }.map { it.id },
|
val held = GameCatalog.inMenu(menu, includeComingSoon = true) -
|
||||||
)
|
GameCatalog.inMenu(menu, includeComingSoon = false).toSet()
|
||||||
// It is also the only difference the two channels see in a menu.
|
held.forEach { assertTrue("${it.id} hidden from production without a preview flag", it.devPreview) }
|
||||||
// Katan sits under Board and Meowdoku under Solitaire, so the two
|
}
|
||||||
// channels part ways in different sections.
|
|
||||||
val hiddenFromBoard = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
|
|
||||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
|
|
||||||
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromBoard)
|
|
||||||
val hiddenFromSolitaire = GameCatalog.inMenu(GameMenu.SOLITAIRE, includeComingSoon = true) -
|
|
||||||
GameCatalog.inMenu(GameMenu.SOLITAIRE, includeComingSoon = false)
|
|
||||||
assertEquals(listOf(GameCatalog.byId(GameCatalog.MEOWDOKU)!!), hiddenFromSolitaire)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a preview game opens on a dev build and nowhere else`() {
|
fun `a preview game opens on a dev build and nowhere else`() {
|
||||||
// Katan until its two-device play is verified; Meowdoku until its board
|
GameCatalog.all.filter { it.devPreview }.forEach { game ->
|
||||||
// and hearts have been looked at on a real screen.
|
|
||||||
val preview = GameCatalog.all.filter { it.devPreview }
|
|
||||||
assertEquals(
|
|
||||||
setOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU),
|
|
||||||
preview.map { it.id }.toSet(),
|
|
||||||
)
|
|
||||||
preview.forEach { game ->
|
|
||||||
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
|
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
|
||||||
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
|
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
|
||||||
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
|
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
|
||||||
}
|
}
|
||||||
|
// The two games that were once previews now open everywhere.
|
||||||
|
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU).forEach { id ->
|
||||||
|
val game = GameCatalog.byId(id)!!
|
||||||
|
assertTrue("$id should open in production", game.playableIn(devBuild = false))
|
||||||
|
}
|
||||||
// An ordinary finished game opens everywhere.
|
// An ordinary finished game opens everywhere.
|
||||||
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
|
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
|
||||||
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
|
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
|
||||||
@@ -186,6 +177,8 @@ class CatalogTest {
|
|||||||
GameCatalog.MASTERMIND,
|
GameCatalog.MASTERMIND,
|
||||||
GameCatalog.BACKGAMMON,
|
GameCatalog.BACKGAMMON,
|
||||||
GameCatalog.PIRATES,
|
GameCatalog.PIRATES,
|
||||||
|
GameCatalog.CATAN,
|
||||||
|
GameCatalog.MEOWDOKU,
|
||||||
),
|
),
|
||||||
GameCatalog.playable.map { it.id }.toSet(),
|
GameCatalog.playable.map { it.id }.toSet(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.prolibertate.games.game.yahtzee
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class YahtzeeStatsTest {
|
||||||
|
private fun game(total: Int, bonus: Boolean = false, yahtzees: Int = 0, won: Boolean = true) =
|
||||||
|
YahtzeeRecord(total, bonus, yahtzees, zeros = 0, players = 2, won = won)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun firstGameHasNothingToCompareTo() {
|
||||||
|
val c = compareToHistory(emptyList(), game(200))
|
||||||
|
assertEquals(1, c.rank)
|
||||||
|
assertEquals(0, c.previousGames)
|
||||||
|
assertNull(c.bestBefore)
|
||||||
|
assertNull(c.averageBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ranksAgainstPreviousGames() {
|
||||||
|
val history = listOf(game(150, bonus = true, yahtzees = 1), game(250), game(200, won = false))
|
||||||
|
val c = compareToHistory(history, game(220))
|
||||||
|
assertEquals(2, c.rank)
|
||||||
|
assertEquals(4, c.gamesRanked)
|
||||||
|
assertEquals(250, c.bestBefore)
|
||||||
|
assertEquals(200.0, c.averageBefore!!, 0.001)
|
||||||
|
assertEquals(1, c.yahtzeesBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.prolibertate.games.score
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
|
||||||
|
class RecentNamesTest {
|
||||||
|
@Test
|
||||||
|
fun newestComesFirst() {
|
||||||
|
assertEquals(listOf("Bo", "Al"), RecentNames.with(listOf("Al"), "Bo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun repeatMovesToFrontIgnoringCase() {
|
||||||
|
assertEquals(listOf("al", "Bo"), RecentNames.with(listOf("Bo", "Al"), " al "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blankChangesNothing() {
|
||||||
|
assertEquals(listOf("Al"), RecentNames.with(listOf("Al"), " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsOnlyTen() {
|
||||||
|
val names = (1..10).map { "n$it" }
|
||||||
|
val result = RecentNames.with(names, "new")
|
||||||
|
assertEquals(10, result.size)
|
||||||
|
assertEquals("new", result.first())
|
||||||
|
assertEquals("n9", result.last())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -199,6 +199,14 @@ class ScoreSheetTest {
|
|||||||
assertEquals(ScoringMode.ROUNDS, StartSheet(3).applyTo(ScoreSheet()).mode)
|
assertEquals(ScoringMode.ROUNDS, StartSheet(3).applyTo(ScoreSheet()).mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startingWithNamesSeatsThemInOrderAndLeavesTheRestUnnamed() {
|
||||||
|
val sheet = StartSheet(3, names = listOf("Ann", "")).applyTo(ScoreSheet())
|
||||||
|
assertEquals(listOf("Ann", "", ""), sheet.players.map { it.name })
|
||||||
|
assertEquals("Ann", sheet.displayName(sheet.players[0]))
|
||||||
|
assertEquals("Player 2", sheet.displayName(sheet.players[1]))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `tally entries add up per player and can be taken back`() {
|
fun `tally entries add up per player and can be taken back`() {
|
||||||
val sheet = ScoreSheet.of(2, ScoringMode.TALLY)
|
val sheet = ScoreSheet.of(2, ScoringMode.TALLY)
|
||||||
|
|||||||
Reference in New Issue
Block a user