Scorekeeper: choose round-based or tally scoring when starting a game
Tally sheets let any player score at any moment (+1, -1 or a custom amount) with a log of entries that can be taken back. Entries are stored as one-player rows so totals, undo and sharing work unchanged. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user