Merge pull request #3: a scorekeeper for games the app does not deal
Verified green on the feature branch before merging: build, unit tests and Android lint (ci.yml run 36).
This commit is contained in:
@@ -41,6 +41,32 @@ Each client is then sent a state **redacted for its own seat**, so opponents'
|
||||
hands and the deck order are never transmitted to devices that should not see
|
||||
them.
|
||||
|
||||
## Scorekeeper
|
||||
|
||||
**Scorekeeper** on the main menu is a pencil for games the app does not deal —
|
||||
darts, cribbage, whatever is actually on the table. Say how many are playing,
|
||||
name them, and write down what each of them scored every round; the running
|
||||
tally sits at the foot of the screen, where the one number anybody looks up is
|
||||
always visible.
|
||||
|
||||
Players can be renamed, added, removed or **dragged into a different order** at
|
||||
any point in a game. That works because points are recorded against a player's
|
||||
id rather than their position, so reordering the sheet moves the columns and
|
||||
nothing else. Ids are handed out once and never reused: somebody sitting down
|
||||
where somebody else got up starts at nought instead of inheriting a stranger's
|
||||
score. Removing a player does take their points with them — a score nobody can
|
||||
see is a score nobody can correct.
|
||||
|
||||
Rounds are not written in ink. Tapping one reopens it for correction or lets it
|
||||
be struck out, and the totals follow.
|
||||
|
||||
The sheet is saved as it is edited, so leaving the app or taking a phone call
|
||||
does not lose the game. It is kept in its own DataStore rather than with the
|
||||
settings, because it is a game in progress rather than a preference.
|
||||
|
||||
`score/ScoreSheet.kt` holds the whole model and, like `game/`, has no Android in
|
||||
it: every rule above is a pure function and unit-tested on the JVM.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Sound** on/off.
|
||||
@@ -247,6 +273,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||||
chess/ Chess model, FEN, move generation, search
|
||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||
net/ wire protocol, LAN transport, lobby, match driver
|
||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||
settings/ DataStore-backed preferences
|
||||
update/ GitHub Releases OTA updater
|
||||
ui/ Compose screens
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import org.prolibertate.games.net.LobbyController
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.ui.AppEnv
|
||||
import org.prolibertate.games.ui.AppRoot
|
||||
@@ -24,6 +25,7 @@ class MainActivity : ComponentActivity() {
|
||||
settingsRepository = SettingsRepository(applicationContext),
|
||||
updater = Updater(this),
|
||||
lobby = LobbyController(applicationContext, lifecycleScope),
|
||||
scorekeeper = ScorekeeperRepository(applicationContext),
|
||||
peerId = stablePeerId(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One person on the score sheet.
|
||||
*
|
||||
* Points are recorded against [id] rather than against a position, which is
|
||||
* what lets the players be dragged into a different order without their scores
|
||||
* moving with them. Ids come from [ScoreSheet.nextPlayerId] and are never
|
||||
* reused: somebody added where somebody else was removed must not inherit the
|
||||
* rounds that were scored against them.
|
||||
*
|
||||
* [name] is stored exactly as typed, blank included, so clearing the field
|
||||
* while renaming does not fight back. What to show for a blank name is a
|
||||
* display concern and belongs to [ScoreSheet.displayName].
|
||||
*/
|
||||
@Serializable
|
||||
data class ScorePlayer(val id: Int, val name: String = "")
|
||||
|
||||
/**
|
||||
* What each player scored in one round, keyed by [ScorePlayer.id].
|
||||
*
|
||||
* A player absent from the map scored nothing, which is the same as zero — so
|
||||
* a round where only one person scored carries one entry rather than a column
|
||||
* of noughts.
|
||||
*/
|
||||
@Serializable
|
||||
data class ScoreRound(val deltas: Map<Int, Int> = emptyMap()) {
|
||||
fun delta(playerId: Int): Int = deltas[playerId] ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A pencil-and-paper score sheet: some players, and a row of points per round.
|
||||
*
|
||||
* Every change returns a new sheet rather than mutating this one, so the screen
|
||||
* can hand the result straight to state and persistence without worrying about
|
||||
* which of them holds the live copy. Nothing here touches Android, for the same
|
||||
* reason `game/` does not: it can then be unit-tested on the JVM.
|
||||
*/
|
||||
@Serializable
|
||||
data class ScoreSheet(
|
||||
val players: List<ScorePlayer> = emptyList(),
|
||||
val rounds: List<ScoreRound> = emptyList(),
|
||||
/** The next id to hand out. Only ever counts up; see [ScorePlayer.id]. */
|
||||
val nextPlayerId: Int = 1,
|
||||
) {
|
||||
/** A sheet with nobody on it has not been set up yet. */
|
||||
val started: Boolean get() = players.isNotEmpty()
|
||||
|
||||
/** What to call a player who has not been named: their seat at the table. */
|
||||
fun displayName(player: ScorePlayer): String {
|
||||
val typed = player.name.trim()
|
||||
if (typed.isNotEmpty()) return typed
|
||||
val position = players.indexOfFirst { it.id == player.id }
|
||||
return "Player ${if (position >= 0) position + 1 else player.id}"
|
||||
}
|
||||
|
||||
fun total(playerId: Int): Int = rounds.sumOf { it.delta(playerId) }
|
||||
|
||||
fun totals(): Map<Int, Int> = players.associate { it.id to total(it.id) }
|
||||
|
||||
/**
|
||||
* Grows or shrinks the table to [count], clamped to what a sheet can hold.
|
||||
*
|
||||
* Shrinking drops players from the end and takes their scores with them,
|
||||
* because points recorded against somebody nobody can see are points that
|
||||
* can never be corrected.
|
||||
*/
|
||||
fun withPlayerCount(count: Int): ScoreSheet {
|
||||
val target = count.coerceIn(MIN_PLAYERS, MAX_PLAYERS)
|
||||
if (target == players.size) return this
|
||||
if (target < players.size) return copy(players = players.take(target)).pruned()
|
||||
|
||||
var next = nextPlayerId
|
||||
val added = List(target - players.size) { ScorePlayer(id = next++) }
|
||||
return copy(players = players + added, nextPlayerId = next)
|
||||
}
|
||||
|
||||
fun withPlayerAdded(): ScoreSheet = withPlayerCount(players.size + 1)
|
||||
|
||||
/** Removes one player by id. The last two cannot be removed. */
|
||||
fun withPlayerRemoved(playerId: Int): ScoreSheet {
|
||||
if (players.size <= MIN_PLAYERS) return this
|
||||
if (players.none { it.id == playerId }) return this
|
||||
return copy(players = players.filterNot { it.id == playerId }).pruned()
|
||||
}
|
||||
|
||||
fun renamed(playerId: Int, name: String): ScoreSheet = copy(
|
||||
players = players.map { if (it.id == playerId) it.copy(name = name) else it }
|
||||
)
|
||||
|
||||
/** Moves the player at [from] to [to], which is what a drag amounts to. */
|
||||
fun moved(from: Int, to: Int): ScoreSheet {
|
||||
if (from !in players.indices) return this
|
||||
val target = to.coerceIn(0, players.size - 1)
|
||||
if (from == target) return this
|
||||
val reordered = players.toMutableList()
|
||||
reordered.add(target, reordered.removeAt(from))
|
||||
return copy(players = reordered)
|
||||
}
|
||||
|
||||
fun withRound(deltas: Map<Int, Int>): ScoreSheet = copy(rounds = rounds + roundOf(deltas))
|
||||
|
||||
/** Corrects a round already written down. Out-of-range indices do nothing. */
|
||||
fun withRoundAt(index: Int, deltas: Map<Int, Int>): ScoreSheet {
|
||||
if (index !in rounds.indices) return this
|
||||
return copy(rounds = rounds.toMutableList().also { it[index] = roundOf(deltas) })
|
||||
}
|
||||
|
||||
fun withoutRound(index: Int): ScoreSheet {
|
||||
if (index !in rounds.indices) return this
|
||||
return copy(rounds = rounds.filterIndexed { at, _ -> at != index })
|
||||
}
|
||||
|
||||
/**
|
||||
* A round holding only what is worth storing: entries for players who are
|
||||
* still at the table, and only where somebody actually scored.
|
||||
*/
|
||||
private fun roundOf(deltas: Map<Int, Int>): ScoreRound {
|
||||
val ids = players.map { it.id }.toSet()
|
||||
return ScoreRound(deltas.filterKeys { it in ids }.filterValues { it != 0 })
|
||||
}
|
||||
|
||||
/** Drops points recorded against anybody no longer at the table. */
|
||||
private fun pruned(): ScoreSheet {
|
||||
val ids = players.map { it.id }.toSet()
|
||||
return copy(rounds = rounds.map { round -> ScoreRound(round.deltas.filterKeys { it in ids }) })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_PLAYERS = 2
|
||||
const val MAX_PLAYERS = 12
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
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.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// Its own store rather than a corner of the settings one: a score sheet is
|
||||
// session data with a shape that will keep changing, and it has no business
|
||||
// being rewritten every time somebody moves the animation slider.
|
||||
private val Context.scorekeeperDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "scorekeeper",
|
||||
)
|
||||
|
||||
/**
|
||||
* Keeps the score sheet across the app being left, backgrounded or killed.
|
||||
*
|
||||
* A scorekeeper that forgets the tally because somebody took a phone call is
|
||||
* worse than a pencil, so the sheet is written on every change and read back at
|
||||
* the top of the screen.
|
||||
*/
|
||||
class ScorekeeperRepository(private val context: Context) {
|
||||
|
||||
private object Keys {
|
||||
val SHEET = stringPreferencesKey("sheet")
|
||||
}
|
||||
|
||||
// Unknown keys are ignored so a sheet written by a newer build can still be
|
||||
// read by an older one — the alternative is losing a game in progress to a
|
||||
// channel switch.
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
val sheet: Flow<ScoreSheet> = context.scorekeeperDataStore.data.map { prefs ->
|
||||
prefs[Keys.SHEET]
|
||||
?.let { stored -> runCatching { json.decodeFromString<ScoreSheet>(stored) }.getOrNull() }
|
||||
?: ScoreSheet()
|
||||
}
|
||||
|
||||
suspend fun save(sheet: ScoreSheet) {
|
||||
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -39,6 +40,7 @@ import org.prolibertate.games.game.GameCategory
|
||||
import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.net.LobbyController
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
@@ -49,6 +51,7 @@ class AppEnv(
|
||||
val settingsRepository: SettingsRepository,
|
||||
val updater: Updater,
|
||||
val lobby: LobbyController,
|
||||
val scorekeeper: ScorekeeperRepository,
|
||||
val peerId: String,
|
||||
)
|
||||
|
||||
@@ -62,6 +65,7 @@ class AppEnv(
|
||||
sealed interface Route {
|
||||
data object Menu : Route
|
||||
data object Settings : Route
|
||||
data object Scorekeeper : Route
|
||||
data class Setup(val gameId: String) : Route
|
||||
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
|
||||
data class Play(
|
||||
@@ -145,6 +149,7 @@ private fun AppContent(
|
||||
showComingSoon = showComingSoon,
|
||||
onPickGame = { push(Route.Setup(it.id)) },
|
||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||
onScorekeeper = { push(Route.Scorekeeper) },
|
||||
onSettings = { push(Route.Settings) },
|
||||
)
|
||||
|
||||
@@ -154,6 +159,11 @@ private fun AppContent(
|
||||
onBack = { pop() },
|
||||
)
|
||||
|
||||
is Route.Scorekeeper -> ScorekeeperScreen(
|
||||
repository = env.scorekeeper,
|
||||
onBack = { pop() },
|
||||
)
|
||||
|
||||
is Route.Setup -> GameSetupScreen(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.displayName,
|
||||
@@ -187,12 +197,13 @@ private fun AppContent(
|
||||
// Main menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MainMenuScreen(
|
||||
showComingSoon: Boolean,
|
||||
onPickGame: (GameDescriptor) -> Unit,
|
||||
onJoinGame: () -> Unit,
|
||||
onScorekeeper: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -211,11 +222,17 @@ fun MainMenuScreen(
|
||||
.padding(padding)
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
Row(
|
||||
// Wraps rather than running off the edge: two buttons and a narrow
|
||||
// phone would otherwise push the second one past the screen.
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") }
|
||||
// Not a game, and deliberately not in the catalogue: it keeps
|
||||
// score for whatever is being played on the actual table.
|
||||
OutlinedButton(onClick = onScorekeeper) { Text("Scorekeeper") }
|
||||
}
|
||||
|
||||
// The column count follows the available width, so a phone in
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
package org.prolibertate.games.ui
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.prolibertate.games.score.ScorePlayer
|
||||
import org.prolibertate.games.score.ScoreSheet
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A pencil for games the app does not play.
|
||||
*
|
||||
* Name the people at the table, drag them into the order they are sitting in,
|
||||
* then write down what each of them scored every round. The running tally sits
|
||||
* at the foot of the screen where it can always be seen, because that is the
|
||||
* one number anybody actually looks up.
|
||||
*/
|
||||
@Composable
|
||||
fun ScorekeeperScreen(
|
||||
repository: ScorekeeperRepository,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Read once, then held here and written through. Reading the sheet back out
|
||||
// of storage on every change would put a DataStore round trip between a
|
||||
// keystroke and the letter appearing, which is how a name field loses
|
||||
// characters.
|
||||
var loaded by remember { mutableStateOf(false) }
|
||||
var sheet by remember { mutableStateOf(ScoreSheet()) }
|
||||
LaunchedEffect(Unit) {
|
||||
sheet = repository.sheet.first()
|
||||
loaded = true
|
||||
}
|
||||
|
||||
val update: (ScoreSheet) -> Unit = { next ->
|
||||
sheet = next
|
||||
scope.launch { repository.save(next) }
|
||||
}
|
||||
|
||||
var editingPlayers by remember { mutableStateOf(false) }
|
||||
var confirmNewGame by remember { mutableStateOf(false) }
|
||||
|
||||
ScreenScaffold(
|
||||
title = "Scorekeeper",
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
if (sheet.started) {
|
||||
TextButton(onClick = { confirmNewGame = true }) { Text("New game") }
|
||||
}
|
||||
},
|
||||
) { modifier ->
|
||||
// Nothing is drawn until the stored sheet has been read, so a game in
|
||||
// progress does not flash the "how many are playing" pane first.
|
||||
if (!loaded) {
|
||||
Box(modifier = modifier)
|
||||
} else if (!sheet.started) {
|
||||
SetupPane(modifier = modifier) { count ->
|
||||
update(ScoreSheet.of(count))
|
||||
// Straight into naming them: a fresh sheet is a row of blanks.
|
||||
editingPlayers = true
|
||||
}
|
||||
} else {
|
||||
Scoreboard(
|
||||
modifier = modifier,
|
||||
sheet = sheet,
|
||||
editingPlayers = editingPlayers,
|
||||
onEditPlayers = { editingPlayers = it },
|
||||
onChange = update,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmNewGame) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmNewGame = false },
|
||||
title = { Text("Start a new game?") },
|
||||
text = { Text("The players and every round scored so far are rubbed out.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmNewGame = false
|
||||
editingPlayers = false
|
||||
update(ScoreSheet())
|
||||
}) { Text("Rub it out") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmNewGame = false }) { Text("Keep scoring") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setting the table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) {
|
||||
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
|
||||
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Keep score for a game the app does not deal — darts, cribbage, " +
|
||||
"whatever is on the table. Add or take away points each round and the " +
|
||||
"tally is kept for you.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text("How many are playing?", style = MaterialTheme.typography.labelLarge)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
(ScoreSheet.MIN_PLAYERS..ScoreSheet.MAX_PLAYERS).forEach { value ->
|
||||
FilterChip(
|
||||
selected = value == count,
|
||||
onClick = { count = value },
|
||||
label = { Text("$value") },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Names come next, and players can be added, removed or dragged into " +
|
||||
"a different order at any point without losing anybody's score.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
PrimaryAction(text = "Start scoring") { onStart(count) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The sheet proper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun Scoreboard(
|
||||
modifier: Modifier,
|
||||
sheet: ScoreSheet,
|
||||
editingPlayers: Boolean,
|
||||
onEditPlayers: (Boolean) -> Unit,
|
||||
onChange: (ScoreSheet) -> Unit,
|
||||
) {
|
||||
// What is being typed into the round that has not been written down yet,
|
||||
// keyed by player id so it survives the players being reordered.
|
||||
val pending = remember { mutableStateMapOf<Int, String>() }
|
||||
var editingRound by remember { mutableStateOf(-1) }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SectionHeader(
|
||||
title = if (editingPlayers) "Players" else "Round ${sheet.rounds.size + 1}",
|
||||
actionLabel = if (editingPlayers) "Done" else "Players",
|
||||
onAction = { onEditPlayers(!editingPlayers) },
|
||||
)
|
||||
|
||||
if (editingPlayers) {
|
||||
PlayerEditor(sheet = sheet, onChange = onChange)
|
||||
} else {
|
||||
RoundEntry(
|
||||
sheet = sheet,
|
||||
amounts = pending,
|
||||
onRecord = {
|
||||
onChange(sheet.withRound(amountsAsPoints(pending)))
|
||||
pending.clear()
|
||||
},
|
||||
)
|
||||
if (sheet.rounds.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Rounds so far",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
// Newest first: the round just written down is the one most
|
||||
// likely to need correcting.
|
||||
for (index in sheet.rounds.indices.reversed()) {
|
||||
RoundRow(
|
||||
sheet = sheet,
|
||||
index = index,
|
||||
onClick = { editingRound = index },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TotalsBar(sheet = sheet)
|
||||
}
|
||||
|
||||
if (editingRound in sheet.rounds.indices) {
|
||||
RoundEditorDialog(
|
||||
sheet = sheet,
|
||||
index = editingRound,
|
||||
onDismiss = { editingRound = -1 },
|
||||
onSave = { amounts ->
|
||||
onChange(sheet.withRoundAt(editingRound, amounts))
|
||||
editingRound = -1
|
||||
},
|
||||
onDelete = {
|
||||
onChange(sheet.withoutRound(editingRound))
|
||||
editingRound = -1
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String, actionLabel: String, onAction: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
TextButton(onClick = onAction) { Text(actionLabel) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Players: naming, adding, removing, and dragging into order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One row per player, dragged by the handle on the left.
|
||||
*
|
||||
* Every row is exactly [PLAYER_ROW_HEIGHT] tall and they are stacked with no
|
||||
* gap, so a drag of one row height is a move of exactly one place and the
|
||||
* arithmetic needs no measurement of individual items.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlayerEditor(sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit) {
|
||||
val rowHeightPx = with(LocalDensity.current) { PLAYER_ROW_HEIGHT.toPx() }
|
||||
|
||||
// Read inside the gesture handlers, which outlive the composition that
|
||||
// created them: without this a drag would be applied to whatever the sheet
|
||||
// looked like when the row was first drawn, throwing away anything typed
|
||||
// since.
|
||||
val currentSheet by rememberUpdatedState(sheet)
|
||||
val commit by rememberUpdatedState(onChange)
|
||||
|
||||
// The sheet is deliberately left alone until the finger lifts. Rows move on
|
||||
// screen as the drag passes them, but the positions the gesture is working
|
||||
// from cannot shift underneath it.
|
||||
var draggingIndex by remember { mutableStateOf(-1) }
|
||||
var dragOffset by remember { mutableStateOf(0f) }
|
||||
|
||||
fun landingIndex(): Int {
|
||||
val last = currentSheet.players.lastIndex
|
||||
if (draggingIndex < 0 || last < 0) return -1
|
||||
val moved = draggingIndex + (dragOffset / rowHeightPx).roundToInt()
|
||||
return moved.coerceIn(0, last)
|
||||
}
|
||||
|
||||
val target = landingIndex()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
sheet.players.forEachIndexed { index, player ->
|
||||
val dragging = index == draggingIndex
|
||||
// The dragged row follows the finger; the rows it has passed step
|
||||
// aside by one place to show where it would land.
|
||||
val shift = when {
|
||||
draggingIndex < 0 -> 0f
|
||||
dragging -> dragOffset
|
||||
draggingIndex < target && index in (draggingIndex + 1)..target -> -rowHeightPx
|
||||
draggingIndex > target && index in target until draggingIndex -> rowHeightPx
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
PlayerRow(
|
||||
sheet = sheet,
|
||||
player = player,
|
||||
dragging = dragging,
|
||||
shift = shift,
|
||||
onName = { commit(currentSheet.renamed(player.id, it)) },
|
||||
onRemove = { commit(currentSheet.withPlayerRemoved(player.id)) },
|
||||
onDragStart = {
|
||||
draggingIndex = currentSheet.players.indexOfFirst { it.id == player.id }
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDrag = { dy -> dragOffset += dy },
|
||||
onDragEnd = {
|
||||
val from = draggingIndex
|
||||
val to = landingIndex()
|
||||
draggingIndex = -1
|
||||
dragOffset = 0f
|
||||
if (from >= 0 && to >= 0) commit(currentSheet.moved(from, to))
|
||||
},
|
||||
onDragCancel = {
|
||||
draggingIndex = -1
|
||||
dragOffset = 0f
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { onChange(sheet.withPlayerAdded()) },
|
||||
enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS,
|
||||
) { Text("Add a player") }
|
||||
}
|
||||
Text(
|
||||
text = "Drag the ≡ handle to change the order. Removing a player takes " +
|
||||
"their score with them.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerRow(
|
||||
sheet: ScoreSheet,
|
||||
player: ScorePlayer,
|
||||
dragging: Boolean,
|
||||
shift: Float,
|
||||
onName: (String) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDragStart: () -> Unit,
|
||||
onDrag: (Float) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onDragCancel: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PLAYER_ROW_HEIGHT)
|
||||
// Above its neighbours while it is being dragged, so it passes over
|
||||
// them rather than under.
|
||||
.zIndex(if (dragging) 1f else 0f)
|
||||
.graphicsLayer { translationY = shift },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(40.dp)
|
||||
.fillMaxHeight()
|
||||
.pointerInput(player.id) {
|
||||
detectDragGestures(
|
||||
onDragStart = { onDragStart() },
|
||||
onDragEnd = { onDragEnd() },
|
||||
onDragCancel = { onDragCancel() },
|
||||
onDrag = { change, delta ->
|
||||
// Consumed here so the surrounding scroll does not
|
||||
// also take the gesture and run away with the page.
|
||||
change.consume()
|
||||
onDrag(delta.y)
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "≡",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = player.name,
|
||||
onValueChange = onName,
|
||||
placeholder = { Text(sheet.displayName(player)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = onRemove,
|
||||
enabled = sheet.players.size > ScoreSheet.MIN_PLAYERS,
|
||||
) { Text("✕") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring a round
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun RoundEntry(
|
||||
sheet: ScoreSheet,
|
||||
amounts: SnapshotStateMap<Int, String>,
|
||||
onRecord: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "What did each player score? Take points away with the minus, or type " +
|
||||
"a negative number.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
sheet.players.forEach { player ->
|
||||
AmountRow(name = sheet.displayName(player), id = player.id, amounts = amounts)
|
||||
}
|
||||
PrimaryAction(
|
||||
text = "Record round ${sheet.rounds.size + 1}",
|
||||
// A round where nobody scored anything is not worth a row.
|
||||
enabled = amountsAsPoints(amounts).isNotEmpty(),
|
||||
onClick = onRecord,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Name, a minus, a number, a plus. The same row scores a round and edits one. */
|
||||
@Composable
|
||||
private fun AmountRow(name: String, id: Int, amounts: SnapshotStateMap<Int, String>) {
|
||||
val typed = amounts[id].orEmpty()
|
||||
|
||||
fun step(by: Int) {
|
||||
val next = (typed.toIntOrNull() ?: 0) + by
|
||||
amounts[id] = if (next == 0) "" else next.toString()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { step(-1) }) { Text("−") }
|
||||
OutlinedTextField(
|
||||
value = typed,
|
||||
onValueChange = { amounts[id] = sanitiseAmount(it) },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
placeholder = { Text("0", textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) },
|
||||
modifier = Modifier.width(96.dp),
|
||||
)
|
||||
TextButton(onClick = { step(1) }) { Text("+") }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RoundRow(sheet: ScoreSheet, index: Int, onClick: () -> Unit) {
|
||||
val round = sheet.rounds[index]
|
||||
Card(
|
||||
onClick = onClick,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = "Round ${index + 1}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
) {
|
||||
sheet.players.forEach { player ->
|
||||
Text(
|
||||
text = "${sheet.displayName(player)} ${signed(round.delta(player.id))}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoundEditorDialog(
|
||||
sheet: ScoreSheet,
|
||||
index: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Map<Int, Int>) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val round = sheet.rounds[index]
|
||||
val amounts = remember(index) {
|
||||
mutableStateMapOf<Int, String>().apply {
|
||||
sheet.players.forEach { player ->
|
||||
val delta = round.delta(player.id)
|
||||
if (delta != 0) put(player.id, delta.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Round ${index + 1}") },
|
||||
text = {
|
||||
Column(
|
||||
// Twelve players will not fit on a phone otherwise.
|
||||
modifier = Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
sheet.players.forEach { player ->
|
||||
AmountRow(
|
||||
name = sheet.displayName(player),
|
||||
id = player.id,
|
||||
amounts = amounts,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onDelete) { Text("Delete this round") }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSave(amountsAsPoints(amounts)) }) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The tally
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun TotalsBar(sheet: ScoreSheet) {
|
||||
Surface(
|
||||
tonalElevation = 3.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(
|
||||
text = when (sheet.rounds.size) {
|
||||
0 -> "No rounds scored yet"
|
||||
1 -> "After 1 round"
|
||||
else -> "After ${sheet.rounds.size} rounds"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
// Scrolls sideways rather than squeezing: a twelve-handed game has
|
||||
// more columns than a phone has width.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
sheet.players.forEach { player ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = sheet.displayName(player),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = sheet.total(player.id).toString(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private val PLAYER_ROW_HEIGHT = 64.dp
|
||||
|
||||
/** Keeps a points field to a number, optionally negative, as it is typed. */
|
||||
private fun sanitiseAmount(raw: String): String {
|
||||
val negative = raw.startsWith("-")
|
||||
val digits = raw.filter { it.isDigit() }.take(5)
|
||||
return if (negative) "-$digits" else digits
|
||||
}
|
||||
|
||||
/** The typed fields as points. Blanks, a lone minus and zeroes all drop out. */
|
||||
private fun amountsAsPoints(amounts: Map<Int, String>): Map<Int, Int> =
|
||||
amounts.mapNotNull { (id, text) -> text.toIntOrNull()?.takeIf { it != 0 }?.let { id to it } }
|
||||
.toMap()
|
||||
|
||||
private fun signed(points: Int): String = if (points > 0) "+$points" else points.toString()
|
||||
@@ -0,0 +1,185 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ScoreSheetTest {
|
||||
|
||||
private fun sheetOf(vararg names: String): ScoreSheet =
|
||||
names.foldIndexed(ScoreSheet.of(names.size)) { index, sheet, name ->
|
||||
sheet.renamed(sheet.players[index].id, name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new sheet seats the players asked for, unnamed`() {
|
||||
val sheet = ScoreSheet.of(5)
|
||||
assertEquals(5, sheet.players.size)
|
||||
assertTrue(sheet.started)
|
||||
assertTrue(sheet.rounds.isEmpty())
|
||||
assertEquals("Player 3", sheet.displayName(sheet.players[2]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table cannot be smaller than two or larger than twelve`() {
|
||||
assertEquals(ScoreSheet.MIN_PLAYERS, ScoreSheet.of(1).players.size)
|
||||
assertEquals(ScoreSheet.MAX_PLAYERS, ScoreSheet.of(40).players.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `points add up round on round, and can be taken away`() {
|
||||
val sheet = sheetOf("Alice", "Bob")
|
||||
.let { it.withRound(mapOf(it.players[0].id to 3, it.players[1].id to -1)) }
|
||||
.let { it.withRound(mapOf(it.players[0].id to -5, it.players[1].id to 10)) }
|
||||
|
||||
assertEquals(-2, sheet.total(sheet.players[0].id))
|
||||
assertEquals(9, sheet.total(sheet.players[1].id))
|
||||
assertEquals(2, sheet.rounds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a player with no entry that round scored nothing`() {
|
||||
val sheet = sheetOf("Alice", "Bob").let { it.withRound(mapOf(it.players[0].id to 4)) }
|
||||
assertEquals(0, sheet.total(sheet.players[1].id))
|
||||
// Nothing scored is not worth storing.
|
||||
assertEquals(1, sheet.rounds.first().deltas.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging a player into a different order leaves the scores where they were`() {
|
||||
val start = sheetOf("Alice", "Bob", "Carol")
|
||||
.let {
|
||||
it.withRound(
|
||||
mapOf(
|
||||
it.players[0].id to 1,
|
||||
it.players[1].id to 2,
|
||||
it.players[2].id to 3,
|
||||
)
|
||||
)
|
||||
}
|
||||
val alice = start.players[0].id
|
||||
val carol = start.players[2].id
|
||||
|
||||
// Carol dragged to the front.
|
||||
val moved = start.moved(from = 2, to = 0)
|
||||
|
||||
assertEquals(listOf("Carol", "Alice", "Bob"), moved.players.map { it.name })
|
||||
assertEquals(3, moved.total(carol))
|
||||
assertEquals(1, moved.total(alice))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag that lands where it started changes nothing`() {
|
||||
val sheet = sheetOf("Alice", "Bob", "Carol")
|
||||
assertEquals(sheet, sheet.moved(from = 1, to = 1))
|
||||
// Off the end of the list is clamped rather than rejected: a finger can
|
||||
// always be dragged further than the list is long.
|
||||
assertEquals(listOf("Bob", "Carol", "Alice"), sheet.moved(from = 0, to = 9).players.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renaming touches nobody else`() {
|
||||
val sheet = sheetOf("Alice", "Bob")
|
||||
val renamed = sheet.renamed(sheet.players[0].id, "Alasdair")
|
||||
assertEquals(listOf("Alasdair", "Bob"), renamed.players.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank name shows as the seat, and is stored as typed`() {
|
||||
val sheet = sheetOf("Alice", "Bob").let { it.renamed(it.players[0].id, "") }
|
||||
assertEquals("", sheet.players[0].name)
|
||||
assertEquals("Player 1", sheet.displayName(sheet.players[0]))
|
||||
// Whitespace is not a name either.
|
||||
assertEquals("Player 1", sheet.displayName(sheet.players[0].copy(name = " ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a player mid-game gives them a fresh id and no history`() {
|
||||
val start = sheetOf("Alice", "Bob", "Carol")
|
||||
.let { it.withRound(mapOf(it.players[1].id to 7)) }
|
||||
val bob = start.players[1].id
|
||||
|
||||
// Bob leaves and somebody else sits down in his place. The id he was
|
||||
// scored against must not come round again with him.
|
||||
val grown = start.withPlayerRemoved(bob).withPlayerAdded()
|
||||
val newcomer = grown.players.last()
|
||||
|
||||
assertNotEquals(bob, newcomer.id)
|
||||
assertEquals(0, grown.total(newcomer.id))
|
||||
assertEquals(3, grown.players.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing a player takes their score with them`() {
|
||||
val start = sheetOf("Alice", "Bob", "Carol")
|
||||
.let { it.withRound(mapOf(it.players[0].id to 5, it.players[1].id to 6)) }
|
||||
val bob = start.players[1].id
|
||||
|
||||
val without = start.withPlayerRemoved(bob)
|
||||
|
||||
assertEquals(listOf("Alice", "Carol"), without.players.map { it.name })
|
||||
assertEquals(5, without.total(without.players[0].id))
|
||||
assertTrue("Bob's points went with him", without.rounds.all { bob !in it.deltas })
|
||||
// The round itself stays: it was still played.
|
||||
assertEquals(1, without.rounds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shrinking the table drops from the end and prunes those scores`() {
|
||||
val start = sheetOf("Alice", "Bob", "Carol")
|
||||
.let { it.withRound(mapOf(it.players[2].id to 9)) }
|
||||
val carol = start.players[2].id
|
||||
|
||||
val smaller = start.withPlayerCount(2)
|
||||
|
||||
assertEquals(listOf("Alice", "Bob"), smaller.players.map { it.name })
|
||||
assertTrue(smaller.rounds.all { carol !in it.deltas })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last two players cannot be removed`() {
|
||||
val sheet = sheetOf("Alice", "Bob")
|
||||
assertEquals(sheet, sheet.withPlayerRemoved(sheet.players[0].id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a round can be corrected or struck out`() {
|
||||
val start = sheetOf("Alice", "Bob")
|
||||
.let { it.withRound(mapOf(it.players[0].id to 1)) }
|
||||
.let { it.withRound(mapOf(it.players[0].id to 100)) }
|
||||
val alice = start.players[0].id
|
||||
|
||||
val corrected = start.withRoundAt(1, mapOf(alice to 10))
|
||||
assertEquals(11, corrected.total(alice))
|
||||
|
||||
val struck = corrected.withoutRound(0)
|
||||
assertEquals(1, struck.rounds.size)
|
||||
assertEquals(10, struck.total(alice))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `points for somebody not at the table are ignored`() {
|
||||
val sheet = sheetOf("Alice", "Bob")
|
||||
val stranger = 9999
|
||||
val recorded = sheet.withRound(mapOf(sheet.players[0].id to 2, stranger to 50))
|
||||
assertEquals(mapOf(sheet.players[0].id to 2), recorded.rounds.first().deltas)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out of range round index is left alone rather than crashing`() {
|
||||
val sheet = sheetOf("Alice", "Bob").withRound(mapOf())
|
||||
assertEquals(sheet, sheet.withRoundAt(7, mapOf()))
|
||||
assertEquals(sheet, sheet.withoutRound(-1))
|
||||
assertEquals(sheet, sheet.moved(from = 5, to = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `totals cover everybody at the table`() {
|
||||
val sheet = sheetOf("Alice", "Bob", "Carol")
|
||||
.let { it.withRound(mapOf(it.players[1].id to 4)) }
|
||||
assertEquals(sheet.players.map { it.id }.toSet(), sheet.totals().keys)
|
||||
assertEquals(0, sheet.totals()[sheet.players[0].id])
|
||||
assertEquals(4, sheet.totals()[sheet.players[1].id])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user