Compare commits

...
12 Commits
Author SHA1 Message Date
Claude ebab7a8415 Yahtzee: end-of-game stats dialog compared with previous games
CI / Build and check (pull_request) Successful in 8m51s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 04:26:52 +00:00
devadmin a0b15b3140 Merge pull request 'Yahtzee: two-column score card, larger text' (#22) from yahtzee-card-layout into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-26 22:00:12 -06:00
Claude 8990dde363 Yahtzee: two-column score card with larger text, scrolls sideways when needed
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 03:50:51 +00:00
devadmin c2b21a07f5 Merge pull request 'Yahtzee: pip dice, confirm zero scores; text size setting' (#21) from yahtzee-improvements into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-10-26 20:03:26 -06:00
Claude e98afbfd8d Yahtzee: pip dice, confirm zero scores; add text size setting
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 01:52:15 +00:00
devadmin 2c90de66d1 Merge pull request 'Release Katan and Meowdoku to production' (#19) from fix/release-katan-meowdoku into dev
CI / Build and check (push) Successful in 8m50s
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 34s
2026-10-26 12:23:15 -06:00
Claude f93eba88e5 Release Katan and Meowdoku: open the production gate
CI / Build and check (pull_request) Successful in 8m43s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 18:14:12 +00:00
devadmin 36ab518309 Merge pull request 'Scorekeeper: name players at setup; refocus after a round' (#17) from feature/scorekeeper-setup-names into dev
CI / Build and check (push) Successful in 9m36s
CI / Build and check (pull_request) Successful in 9m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 36s
2026-10-26 10:35:39 -06:00
Claude 77cc6c5a05 Scorekeeper: ask for each player's name at setup; refocus first cell after a round
CI / Build and check (pull_request) Successful in 9m40s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 04:24:25 +00:00
devadmin 166f85734b Merge pull request 'Scorekeeper: remember the last 10 player names' (#16) from feature/scorekeeper-memory into dev
CI / Build and check (push) Successful in 8m44s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-25 21:34:23 -06:00
Claude 25e38c4bf8 Scorekeeper: remember the last 10 player names and offer them when naming a column
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 03:16:53 +00:00
devadmin 6bf7522042 Merge pull request 'Meowdoku: rules, AI and screen refinements' (#15) from meow2 into dev
CI / Build and check (push) Successful in 20m52s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-25 20:29:24 -06:00
17 changed files with 651 additions and 91 deletions
@@ -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)