Merge pull request #36 from adrianoftyriel/feature/tally-scoring

Scorekeeper: round-based or tally scoring
This commit is contained in:
adrianoftyriel
2026-10-12 17:15:56 -06:00
committed by GitHub
4 changed files with 199 additions and 9 deletions
@@ -30,6 +30,18 @@ data class ScoreRound(val deltas: Map<Int, Int> = emptyMap()) {
fun delta(playerId: Int): Int = deltas[playerId] ?: 0
}
/**
* How points get onto a sheet.
*
* [ROUNDS] fills in a row per round, with an entry for whoever scored in it.
* [TALLY] has no rounds: any player scores at any moment, one entry at a time.
* Both keep their entries in [ScoreSheet.rounds] — a tally entry is just a row
* with a single player in it — so totals, editing, undo and sharing work the
* same for either, and only the way the sheet is drawn differs.
*/
@Serializable
enum class ScoringMode { ROUNDS, TALLY }
/**
* A pencil-and-paper score sheet: some players, and a row of points per round.
*
@@ -44,6 +56,8 @@ data class ScoreSheet(
val rounds: List<ScoreRound> = emptyList(),
/** The next id to hand out. Only ever counts up; see [ScorePlayer.id]. */
val nextPlayerId: Int = 1,
/** Chosen when the sheet is started and fixed until it is rubbed out. */
val mode: ScoringMode = ScoringMode.ROUNDS,
) {
/** A sheet with nobody on it has not been set up yet. */
val started: Boolean get() = players.isNotEmpty()
@@ -100,6 +114,9 @@ data class ScoreSheet(
return copy(players = reordered)
}
/** One tally entry: [points] to a single player, whenever they scored. */
fun withTally(playerId: Int, points: Int): ScoreSheet = withRound(mapOf(playerId to points))
fun withRound(deltas: Map<Int, Int>): ScoreSheet = copy(rounds = rounds + roundOf(deltas))
/** Corrects a round already written down. Out-of-range indices do nothing. */
@@ -134,6 +151,7 @@ data class ScoreSheet(
const val DEFAULT_PLAYERS = 4
/** A fresh sheet seated for [count] players, none of them named yet. */
fun of(count: Int): ScoreSheet = ScoreSheet().withPlayerCount(count)
fun of(count: Int, mode: ScoringMode = ScoringMode.ROUNDS): ScoreSheet =
ScoreSheet(mode = mode).withPlayerCount(count)
}
}
@@ -28,11 +28,11 @@ sealed interface SheetOp {
fun applyTo(sheet: ScoreSheet): ScoreSheet
}
/** Sets a fresh sheet up for [count] players. */
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
@Serializable
@SerialName("start")
data class StartSheet(val count: Int) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count)
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
}
/** Rubs everything out. */
@@ -74,6 +74,7 @@ import org.prolibertate.games.score.RemovePlayer
import org.prolibertate.games.score.RenamePlayer
import org.prolibertate.games.score.ScorePlayer
import org.prolibertate.games.score.ScoreSheet
import org.prolibertate.games.score.ScoringMode
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.score.SheetOp
import org.prolibertate.games.score.StartSheet
@@ -217,7 +218,7 @@ fun ScorekeeperScreen(
role == Role.GUEST -> Column(modifier = modifier) {
ConnectionBanner(shared = shared, onSync = { session.sync() })
Sheet(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
}
joining && role == Role.NONE ->
@@ -225,11 +226,11 @@ fun ScorekeeperScreen(
!sheet.started -> SetupPane(
modifier = modifier,
onStart = { count -> change(StartSheet(count)) },
onStart = { count, mode -> change(StartSheet(count, mode)) },
onJoin = { joining = true },
)
else -> Sheet(modifier = modifier, sheet = sheet, onOp = change)
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
}
}
@@ -286,8 +287,9 @@ fun ScorekeeperScreen(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () -> Unit) {
private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) {
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
@@ -299,6 +301,28 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () ->
"tally kept for you at the foot.",
style = MaterialTheme.typography.bodyMedium,
)
Text("How is it scored?", style = MaterialTheme.typography.labelLarge)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = mode == ScoringMode.ROUNDS,
onClick = { mode = ScoringMode.ROUNDS },
label = { Text("By round") },
)
FilterChip(
selected = mode == ScoringMode.TALLY,
onClick = { mode = ScoringMode.TALLY },
label = { Text("Tally") },
)
}
Text(
text = if (mode == ScoringMode.ROUNDS) {
"Everyone's points are written down together, a row per round."
} else {
"Anybody can score at any moment: tap +1, or enter any amount, " +
"with no rounds to finish."
},
style = MaterialTheme.typography.bodySmall,
)
Text("How many are playing?", style = MaterialTheme.typography.labelLarge)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -318,7 +342,7 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () ->
"without losing anybody's score.",
style = MaterialTheme.typography.bodySmall,
)
PrimaryAction(text = "Start scoring") { onStart(count) }
PrimaryAction(text = "Start scoring") { onStart(count, mode) }
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
Text("Join a shared sheet nearby")
}
@@ -329,6 +353,15 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () ->
// The sheet: a column per player, a row per round, totals ruled off at the foot
// ---------------------------------------------------------------------------
@Composable
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
if (sheet.mode == ScoringMode.TALLY) {
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
} else {
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
}
}
@Composable
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
// One scroll state shared by the head, the rows and the totals, so all three
@@ -562,6 +595,127 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
}
}
// ---------------------------------------------------------------------------
// Tally: points scored by anybody, whenever
// ---------------------------------------------------------------------------
/**
* A card per player with their running total and quick buttons, and the entries
* so far listed underneath, newest first. Each entry is a one-player row on the
* sheet, so tapping one in the list takes it back off.
*/
@Composable
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
var renaming by remember { mutableStateOf(-1) }
var custom by remember { mutableStateOf(-1) }
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Tap a name to rename it. Tap an entry below to take it back.",
style = MaterialTheme.typography.bodySmall,
)
sheet.players.forEach { player ->
Row(
modifier = Modifier
.fillMaxWidth()
.border(Dp.Hairline, MaterialTheme.colorScheme.outline)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = sheet.displayName(player),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).clickable { renaming = player.id },
)
Text(
text = sheet.total(player.id).toString(),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(64.dp),
textAlign = TextAlign.Center,
)
OutlinedButton(onClick = { onOp(AddRound(mapOf(player.id to -1))) }) { Text("−1") }
Button(onClick = { onOp(AddRound(mapOf(player.id to 1))) }) { Text("+1") }
TextButton(onClick = { custom = player.id }) { Text("…") }
}
}
OutlinedButton(
onClick = { onOp(AddPlayer) },
enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS,
modifier = Modifier.fillMaxWidth(),
) { Text("Add a player") }
Text("Entries", style = MaterialTheme.typography.labelLarge)
if (sheet.rounds.isEmpty()) {
Text("Nothing scored yet.", style = MaterialTheme.typography.bodySmall)
}
sheet.rounds.indices.reversed().forEach { index ->
val entry = sheet.rounds[index].deltas.entries.firstOrNull()
val who = entry?.let { e -> sheet.players.firstOrNull { it.id == e.key } }
if (entry != null && who != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOp(DeleteRound(index)) }
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("${index + 1}. ${sheet.displayName(who)}", style = MaterialTheme.typography.bodyMedium)
Text(signed(entry.value), style = MaterialTheme.typography.bodyMedium)
}
}
}
}
val renamed = sheet.players.firstOrNull { it.id == renaming }
if (renamed != null) {
RenameDialog(
sheet = sheet,
player = renamed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
renaming = -1
},
onDismiss = { renaming = -1 },
)
}
val scorer = sheet.players.firstOrNull { it.id == custom }
if (scorer != null) {
var amount by remember(scorer.id) { mutableStateOf("") }
AlertDialog(
onDismissRequest = { custom = -1 },
title = { Text("Points for ${sheet.displayName(scorer)}") },
text = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = amount,
onValueChange = { amount = sanitiseAmount(it) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = { amount = flipSign(amount) }, enabled = amount.toIntOrNull() != null) { Text("±") }
}
},
confirmButton = {
TextButton(
onClick = {
amount.toIntOrNull()?.let { onOp(AddRound(mapOf(scorer.id to it))) }
custom = -1
},
enabled = amount.toIntOrNull() != null,
) { Text("Add") }
},
dismissButton = { TextButton(onClick = { custom = -1 }) { Text("Cancel") } },
)
}
}
// ---------------------------------------------------------------------------
// Dragging a column into a different place
// ---------------------------------------------------------------------------
@@ -191,4 +191,22 @@ class ScoreSheetTest {
assertEquals(null, round.deltas[sheet.players[1].id])
assertEquals(0, sheet.total(sheet.players[0].id))
}
@Test
fun `a sheet defaults to round scoring and tally is chosen at start`() {
assertEquals(ScoringMode.ROUNDS, ScoreSheet.of(3).mode)
assertEquals(ScoringMode.TALLY, StartSheet(3, ScoringMode.TALLY).applyTo(ScoreSheet()).mode)
assertEquals(ScoringMode.ROUNDS, StartSheet(3).applyTo(ScoreSheet()).mode)
}
@Test
fun `tally entries add up per player and can be taken back`() {
val sheet = ScoreSheet.of(2, ScoringMode.TALLY)
val (a, b) = sheet.players.map { it.id }
val scored = sheet.withTally(a, 1).withTally(b, 5).withTally(a, 2).withTally(a, -1)
assertEquals(2, scored.total(a))
assertEquals(5, scored.total(b))
assertEquals(3, scored.withoutRound(3).total(a))
assertEquals(ScoringMode.TALLY, scored.mode)
}
}