Merge pull request #36 from adrianoftyriel/feature/tally-scoring
Scorekeeper: round-based or tally scoring
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
|
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.
|
* 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(),
|
val rounds: List<ScoreRound> = emptyList(),
|
||||||
/** The next id to hand out. Only ever counts up; see [ScorePlayer.id]. */
|
/** The next id to hand out. Only ever counts up; see [ScorePlayer.id]. */
|
||||||
val nextPlayerId: Int = 1,
|
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. */
|
/** A sheet with nobody on it has not been set up yet. */
|
||||||
val started: Boolean get() = players.isNotEmpty()
|
val started: Boolean get() = players.isNotEmpty()
|
||||||
@@ -100,6 +114,9 @@ data class ScoreSheet(
|
|||||||
return copy(players = reordered)
|
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))
|
fun withRound(deltas: Map<Int, Int>): ScoreSheet = copy(rounds = rounds + roundOf(deltas))
|
||||||
|
|
||||||
/** Corrects a round already written down. Out-of-range indices do nothing. */
|
/** Corrects a round already written down. Out-of-range indices do nothing. */
|
||||||
@@ -134,6 +151,7 @@ data class ScoreSheet(
|
|||||||
const val DEFAULT_PLAYERS = 4
|
const val DEFAULT_PLAYERS = 4
|
||||||
|
|
||||||
/** A fresh sheet seated for [count] players, none of them named yet. */
|
/** 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
|
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
|
@Serializable
|
||||||
@SerialName("start")
|
@SerialName("start")
|
||||||
data class StartSheet(val count: Int) : SheetOp {
|
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
|
||||||
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count)
|
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rubs everything out. */
|
/** Rubs everything out. */
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import org.prolibertate.games.score.RemovePlayer
|
|||||||
import org.prolibertate.games.score.RenamePlayer
|
import org.prolibertate.games.score.RenamePlayer
|
||||||
import org.prolibertate.games.score.ScorePlayer
|
import org.prolibertate.games.score.ScorePlayer
|
||||||
import org.prolibertate.games.score.ScoreSheet
|
import org.prolibertate.games.score.ScoreSheet
|
||||||
|
import org.prolibertate.games.score.ScoringMode
|
||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.score.SheetOp
|
import org.prolibertate.games.score.SheetOp
|
||||||
import org.prolibertate.games.score.StartSheet
|
import org.prolibertate.games.score.StartSheet
|
||||||
@@ -217,7 +218,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() })
|
||||||
Sheet(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
|
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
|
||||||
}
|
}
|
||||||
|
|
||||||
joining && role == Role.NONE ->
|
joining && role == Role.NONE ->
|
||||||
@@ -225,11 +226,11 @@ fun ScorekeeperScreen(
|
|||||||
|
|
||||||
!sheet.started -> SetupPane(
|
!sheet.started -> SetupPane(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onStart = { count -> change(StartSheet(count)) },
|
onStart = { count, mode -> change(StartSheet(count, mode)) },
|
||||||
onJoin = { joining = true },
|
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)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@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 count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
|
||||||
|
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
@@ -299,6 +301,28 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () ->
|
|||||||
"tally kept for you at the foot.",
|
"tally kept for you at the foot.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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)
|
Text("How many are playing?", style = MaterialTheme.typography.labelLarge)
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -318,7 +342,7 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () ->
|
|||||||
"without losing anybody's score.",
|
"without losing anybody's score.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
PrimaryAction(text = "Start scoring") { onStart(count) }
|
PrimaryAction(text = "Start scoring") { onStart(count, mode) }
|
||||||
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Join a shared sheet nearby")
|
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
|
// 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
|
@Composable
|
||||||
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
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
|
// 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
|
// Dragging a column into a different place
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -191,4 +191,22 @@ class ScoreSheetTest {
|
|||||||
assertEquals(null, round.deltas[sheet.players[1].id])
|
assertEquals(null, round.deltas[sheet.players[1].id])
|
||||||
assertEquals(0, sheet.total(sheet.players[0].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