Merge pull request 'Release: Yahtzee improvements' (#24) from dev into main
Build and Release APK / Build APK and publish release (push) Successful in 4m25s
Build and Release APK / Deploy to Cloudflare Pages (push) Successful in 32s

This commit was merged in pull request #24.
This commit is contained in:
2026-10-28 20:43:39 -06:00
9 changed files with 367 additions and 35 deletions
@@ -10,6 +10,7 @@ import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.settings.YahtzeeStatsRepository
import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme
@@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() {
),
peerId = peerId,
saves = SavedGameStore(File(filesDir, "saved-games")),
yahtzeeStats = YahtzeeStatsRepository(applicationContext),
)
setContent {
@@ -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 },
)
@@ -24,6 +24,8 @@ data class Settings(
val soundEnabled: Boolean = true,
val animationSpeed: Float = 1.0f,
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
* mid-edit. Nothing substitutes a default into this value: doing so meant
@@ -46,6 +48,8 @@ data class Settings(
companion object {
const val MIN_SPEED = 0.5f
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"
}
}
@@ -56,6 +60,7 @@ class SettingsRepository(private val context: Context) {
val SOUND = booleanPreferencesKey("sound_enabled")
val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val TEXT_SCALE = floatPreferencesKey("text_scale")
val NAME = stringPreferencesKey("player_name")
// There is deliberately no update-channel key. The channel is a
// 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,
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
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] ?: "",
)
}
@@ -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) {
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.ExperimentalMaterial3Api
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.Scaffold
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.settings.Settings
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.settings.YahtzeeStatsRepository
import org.prolibertate.games.ui.game.LocalCardSpeed
import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater
@@ -68,6 +71,7 @@ class AppEnv(
val peerId: String,
/** Games in progress, kept between sessions. */
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.
// Every card screen animates, most of them several layers deep, and the
// 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(
env = env,
settings = settings,
@@ -594,7 +594,12 @@ fun PlayScreen(
)
}
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 -> {
@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.Release
import org.prolibertate.games.update.UpdateChannel
@@ -83,6 +84,26 @@ fun SettingsScreen(
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 {
Text("Your name", fontWeight = FontWeight.Bold)
Text(
@@ -6,7 +6,11 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.Canvas
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,12 +34,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.yahtzee.DICE_COUNT
import org.prolibertate.games.game.yahtzee.RollDice
import org.prolibertate.games.game.yahtzee.ScoreIn
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.YahtzeeSection
import org.prolibertate.games.game.yahtzee.YahtzeeState
@@ -61,6 +70,7 @@ private const val TITLE = "Yahtzee"
fun YahtzeeScreen(
controller: MatchController<YahtzeeState, YahtzeeMove>,
localSeat: Int,
stats: YahtzeeStatsRepository,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
@@ -69,6 +79,11 @@ fun YahtzeeScreen(
val kept = remember { mutableStateListOf<Int>() }
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 = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
@@ -89,6 +104,15 @@ fun YahtzeeScreen(
// refused, and the Throw button would look broken again.
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 canRoll = legal.any { it is RollDice }
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
@@ -138,8 +162,14 @@ fun YahtzeeScreen(
localSeat = localSeat,
writable = if (yours) writable else emptySet(),
onWrite = {
controller.submit(ScoreIn(it))
kept.clear()
// A zero is a box spent for nothing, and one mis-tap on a
// 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) }
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
private fun DiceRow(
dice: List<Int>,
@@ -190,12 +290,29 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
.clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
text = face?.toString() ?: "·",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = WallaceRed,
)
if (face != null) {
Canvas(modifier = Modifier.size(48.dp)) {
val radius = size.minDimension * 0.08f
pipPositions(face).forEach { (x, y) ->
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>,
onWrite: (YahtzeeCategory) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
YahtzeeSection.entries.forEach { section ->
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
if (section == YahtzeeSection.UPPER) {
// Upper boxes and bonus on the left, lower boxes and total on the right, so
// each half is wide enough for text that can actually be read. Each half
// has a floor on its width; if two of them do not fit (many players, large
// text) the pair scrolls sideways instead of squeezing.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val needed = (CARD_LABEL_WIDTH + CARD_SEAT_WIDTH * state.cards.size).dp
val half = maxOf((maxWidth - CARD_GAP.dp) / 2, needed)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(CARD_GAP.dp),
) {
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.UPPER, state, localSeat, writable, onWrite)
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
.fillMaxWidth()
.clickable(enabled = open) { onWrite(category) }
.padding(vertical = 2.dp),
.padding(vertical = 5.dp),
) {
Text(
text = category.label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
)
state.cards.forEachIndexed { seat, card ->
@@ -263,7 +413,7 @@ private fun CardRow(
else -> "—"
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
)
}
@@ -272,18 +422,18 @@ private fun CardRow(
@Composable
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 = label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
values.forEach {
Text(
text = "$it",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
}
@@ -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)
}
}