From 7939395c3cbae2f51f2d32ddbf365890450a5265 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 22:07:32 +0000 Subject: [PATCH 1/4] Install dev and production side by side, in the hunting sett A dev build now installs under its own applicationId, org.prolibertate.games.dev, so it is a separate app to Android and sits beside the production copy instead of replacing it. Each channel still updates itself normally, because an update within a channel keeps the same package. Three things move together off one isDevBuild flag, because a dev package wearing the production name and colours is exactly the mix-up that having both installed is meant to prevent: - applicationId gets a .dev suffix; - the launcher label becomes "Pro Libertate Games DEV"; - the cloth becomes Wallace Hunting, the same threadcount with green where the clan sett has red. The name and the colours reach the resources through resValue, so app_name, tartan_field, splash_background and ic_launcher_background are generated by the build rather than declared in res/values. The icon vectors reference @color/tartan_field instead of a literal, which is how one set of vectors serves both colourways without a second copy of the paths. BuildConfig.DEV_BUILD carries the same flag into Kotlin, where AppSett picks the Compose sett for the splash, and WallaceHuntingSett is derived from the same threadcount builder as WallaceSett so the two cannot drift. versionName now tracks isDevBuild rather than the CI stamp alone, so an unstamped local build cannot claim a channel its applicationId contradicts. Separate packages also mean a channel switch can no longer be refused as a downgrade; the settings copy and the README said it could, and now say what actually happens. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JDuAayWXLRoUNTsfE79Mg1 --- README.md | 53 +++++++++++- app/build.gradle.kts | 49 ++++++++++- .../java/org/prolibertate/games/ui/AppRoot.kt | 6 +- .../prolibertate/games/ui/SettingsScreen.kt | 10 ++- .../org/prolibertate/games/ui/theme/Tartan.kt | 48 +++++++++-- .../org/prolibertate/games/update/Updater.kt | 7 +- .../res/drawable/ic_launcher_background.xml | 82 ++++++++++--------- app/src/main/res/mipmap/ic_launcher.xml | 82 ++++++++++--------- app/src/main/res/mipmap/ic_launcher_round.xml | 82 ++++++++++--------- app/src/main/res/values/colors.xml | 15 +++- app/src/main/res/values/strings.xml | 6 +- 11 files changed, 303 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index db40f69..e088d63 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,20 @@ them. - **Production** — stable builds, published from `main`. - **Dev** — preview builds, published from `dev` on every green CI run. + The two install as separate apps, so both can be kept on one device — see + [Both channels on one device](#both-channels-on-one-device). + ## Branding The launcher icon is a single square of the Wallace tartan set on the bias, and the app opens on the same cloth carrying the name in a Celtic uncial hand for two seconds before fading into the menu. +A dev build wears **Wallace Hunting** instead — the same threadcount with green +where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is +what tells the two copies apart when both are on the phone; see +[Both channels on one device](#both-channels-on-one-device). + The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as warp and then as weft at half opacity, which is what produces the blended squares instead of a flat grid. The icon is the same construction as a vector @@ -72,7 +80,9 @@ Two honest caveats: and the narrow black guard sits *between two red blocks*. The icon is generated from the same numbers, so the icon and the splash cannot drift. Weave scale is derived from the sett rather than fixed, so changing the - threadcount rescales both instead of silently zooming in. + threadcount rescales both instead of silently zooming in. The hunting + colourway is the same threadcount passed a different field colour, not a + second copy of the numbers, so it cannot fall out of step either. - **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J. Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under the SIL Open Font License 1.1. The full licence is at @@ -101,6 +111,11 @@ smaller than a fingertip. Requires JDK 17 and the Android SDK (compileSdk 34). +A local build is a dev build: it installs as `org.prolibertate.games.dev` under +the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an +installed release rather than over it. Set `PLG_CHANNEL=production` to build the +production package instead. + ## CI - `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull @@ -161,9 +176,39 @@ is the run number of the workflow that produced it, and the two workflows count separately. Versions are therefore only ever compared *within* a channel; the app reads the `-dev` suffix in its own `versionName` to know which channel it is on, and treats a change of channel as an explicit switch rather than an upgrade. -One consequence: switching channels can be a downgrade as far as Android is -concerned, in which case the install is refused until the current copy is -uninstalled. The settings screen says so when it applies. + +### Both channels on one device + +A dev build installs under its own `applicationId`, `org.prolibertate.games.dev`, +so it is a separate app as far as Android is concerned and can sit beside the +production copy rather than replacing it. Each channel still updates itself +normally, because an update within a channel keeps the same package. + +Three things move together, all off `isDevBuild` in `app/build.gradle.kts`: + +| | Production | Dev | +| --- | --- | --- | +| `applicationId` | `org.prolibertate.games` | `org.prolibertate.games.dev` | +| Launcher name | Pro Libertate Games | Pro Libertate Games DEV | +| Sett | Wallace (red) | Wallace Hunting (green) | + +Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build, +so a local `assembleDebug` is a dev build too and installs alongside a release +without uninstalling anything. + +They have to move together. A dev package wearing the production name and +colours is precisely the mix-up that having both installed is meant to prevent, +which is why one flag drives all three rather than each being set by hand. The +name and the colours reach the resources through `resValue`, so `app_name`, +`tartan_field`, `splash_background` and `ic_launcher_background` are generated +by the build and are deliberately *not* in `res/values/` — the icon vectors +reference `@color/tartan_field` rather than a literal, which is how one set of +vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag +into Kotlin, where `AppSett` picks the matching Compose sett for the splash. + +Switching channels in Settings therefore installs the other channel's app +alongside this one and leaves this one in place; the settings screen says so +when the selected channel is not the installed one. The debug keystore is committed on purpose: every build is signed with the same key, so an OTA update installs over the previous one instead of being rejected diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94c7eeb..94b98c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,22 +16,63 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty() val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val stampVersion = buildChannel.isNotEmpty() && runNumber > 0 +// Anything that is not an explicit production build is a dev build: ci.yml +// stamps "dev", and a local build with nothing set counts as dev too. +// +// This one flag drives everything that makes a dev build a separate app rather +// than a replacement for the production one — its own applicationId, its own +// launcher name, and the hunting sett in place of the clan sett. They have to +// move together: a dev package wearing the production name and colours is +// exactly the confusion having both installed is meant to avoid. +val isDevBuild = buildChannel != "production" + android { namespace = "org.prolibertate.games" compileSdk = 34 defaultConfig { - applicationId = "org.prolibertate.games" + // A dev build installs under its own package, so a dev copy and a + // production copy can sit side by side on one device instead of one + // replacing the other. The namespace above is deliberately left alone: + // R and BuildConfig stay in org.prolibertate.games for both, so no + // source has to care which package it ended up installed as. + applicationId = if (isDevBuild) "org.prolibertate.games.dev" else "org.prolibertate.games" minSdk = 24 targetSdk = 34 // The -dev suffix in versionName is what the installed app reads to - // know its own channel, so it must survive into the shipped APK. + // know its own channel, so it must survive into the shipped APK. It + // tracks isDevBuild rather than the stamp alone, so an unstamped local + // build cannot end up claiming a channel its applicationId contradicts. versionCode = if (stampVersion) runNumber else 1 versionName = when { stampVersion && buildChannel == "production" -> "1.0.$runNumber" stampVersion -> "1.0.$runNumber-dev" - else -> "1.0.0-dev" + isDevBuild -> "1.0.0-dev" + else -> "1.0.0" } + + // The launcher label. With both copies on the phone the app drawer shows + // two entries, and this is what names them apart. + resValue( + "string", + "app_name", + if (isDevBuild) "Pro Libertate Games DEV" else "Pro Libertate Games", + ) + + // The one colour that separates the two setts: Wallace red for + // production, Wallace Hunting green for dev. The icon vectors reference + // this rather than a literal, and Tartan.kt picks the matching Compose + // colour off DEV_BUILD below — the two have to stay in step or the + // launcher icon and the splash it opens on would be different cloth. + resValue("color", "tartan_field", if (isDevBuild) "#1E7A3C" else "#E01B24") + // Shades of the same field, so a launch does not flash the wrong colour + // before the tartan is drawn: splash_background is held behind the + // window for that instant, ic_launcher_background is the icon's flat + // fallback. + resValue("color", "splash_background", if (isDevBuild) "#0D2E17" else "#4A0B10") + resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B") + + buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString()) } signingConfigs { @@ -59,6 +100,8 @@ android { buildFeatures { compose = true + // For DEV_BUILD, which is how the Compose tartan picks its sett. + buildConfig = true } composeOptions { diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index acde3b5..fd6f842 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -30,8 +30,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.prolibertate.games.R import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCategory import org.prolibertate.games.game.GameDescriptor @@ -196,7 +198,9 @@ fun MainMenuScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Pro Libertate Games") }, + // The installed name rather than a literal, so a dev build says + // so in its own title bar as well as in the app drawer. + title = { Text(stringResource(R.string.app_name)) }, actions = { TextButton(onClick = onSettings) { Text("Settings") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt index 5e072a7..ebfea7e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt @@ -139,12 +139,14 @@ fun SettingsScreen( style = MaterialTheme.typography.bodySmall, ) if (settings.updateChannel != env.updater.installedChannel()) { - // The channels are numbered independently, so a switch can - // be a downgrade as far as Android is concerned. + // Each channel installs under its own package, so the other + // one arrives as a second app rather than replacing this + // one — no downgrade for Android to refuse. Text( text = "You're on a ${env.updater.installedChannel().label} build. " + - "Switching channels installs a different build rather than a " + - "newer one — if Android refuses it, uninstall this copy first.", + "The two channels install as separate apps, so this adds the " + + "${settings.updateChannel.label} build alongside this one and " + + "leaves it in place.", style = MaterialTheme.typography.bodySmall, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt index af4aaa2..21e96a1 100644 --- a/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt +++ b/app/src/main/java/org/prolibertate/games/ui/theme/Tartan.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.prolibertate.games.BuildConfig /** One band of a tartan sett, measured in threads. */ data class TartanStripe(val color: Color, val threads: Int) @@ -18,6 +19,15 @@ val WallaceRed = Color(0xFFE01B24) val WallaceBlack = Color(0xFF0C0C0C) val WallaceGold = Color(0xFFF2C518) +/** + * The green the hunting sett puts where the clan sett puts red. + * + * Must match `tartan_field` for a dev build in app/build.gradle.kts, which is + * what the launcher icon is woven from — otherwise the icon and the splash it + * opens on would be visibly different cloth. + */ +val WallaceHuntingGreen = Color(0xFF1E7A3C) + /** * Expands a half-sett into a full repeat by reflecting it. * @@ -42,14 +52,42 @@ fun List.pivoted(): List = * between red and black; * - the narrow black guard sits **between two red blocks**, splitting the red * into pairs. + * + * The threadcount itself lives in [wallaceSett], because the hunting colourway + * below is the same cloth in a different colour and must not drift from it. */ -val WallaceSett: List = listOf( +val WallaceSett: List = wallaceSett(WallaceRed) + +/** + * Wallace Hunting: the same threadcount woven in green. + * + * Hunting setts are the clan cloth in muted, outdoor colours, and for Wallace + * that is the one substitution — green for the red — with the black ground and + * the yellow overstripe untouched. Deriving it from the same builder rather + * than writing the numbers out twice is what keeps that true: a change to the + * threadcount cannot land in one colourway and miss the other. + */ +val WallaceHuntingSett: List = wallaceSett(WallaceHuntingGreen) + +/** The Wallace threadcount woven with [field] in place of the red. */ +private fun wallaceSett(field: Color): List = listOf( TartanStripe(WallaceBlack, 4), - TartanStripe(WallaceRed, 32), + TartanStripe(field, 32), TartanStripe(WallaceBlack, 32), TartanStripe(WallaceGold, 4), ).pivoted() +/** + * The cloth this build wears: the clan sett on production, the hunting sett on + * dev. + * + * A dev build installs as its own app beside the production one, so it has to + * be recognisable at a glance in the app drawer and on the splash — the green + * says which of the two copies just opened without anything having to be read. + */ +val AppSett: List = + if (BuildConfig.DEV_BUILD) WallaceHuntingSett else WallaceSett + /** Threads in one full repeat of a sett. */ val List.threadsPerRepeat: Int get() = sumOf { it.threads } @@ -62,7 +100,7 @@ val List.threadsPerRepeat: Int get() = sumOf { it.threads } * and stops reading as tartan at all. */ fun threadWidthFor( - sett: List = WallaceSett, + sett: List = AppSett, across: Dp, repeats: Float = 2f, ): Dp = across / (sett.threadsPerRepeat * repeats) @@ -77,7 +115,7 @@ fun threadWidthFor( * past the edges of the canvas so the corners stay covered once it is turned. */ fun DrawScope.drawTartan( - sett: List = WallaceSett, + sett: List = AppSett, threadSize: Float, rotationDegrees: Float = 0f, ) { @@ -135,7 +173,7 @@ fun DrawScope.drawTartan( @Composable fun TartanBackground( modifier: Modifier = Modifier, - sett: List = WallaceSett, + sett: List = AppSett, threadWidth: Dp = 2.dp, rotationDegrees: Float = 0f, ) { diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index fbfa3f4..4d4f2c5 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -22,6 +22,10 @@ import java.net.URL * a production build's is its release run number, and the two are unrelated. * Versions are therefore only ever compared *within* a channel; moving between * them is treated as an explicit switch rather than an upgrade. + * + * The two also carry different applicationIds — a dev build installs as + * `…games.dev` — so a switch installs the other channel beside this one instead + * of over it, and both can be kept on the same device. */ enum class UpdateChannel(val label: String, val blurb: String) { PRODUCTION("Production", "Stable builds released from main"), @@ -53,7 +57,8 @@ class Updater(private val activity: Activity) { /** * [isChannelSwitch] means this build is from a different channel than * the one installed, so it is an explicit move rather than a newer - * version of the same line. + * version of the same line — and, because the channels have separate + * applicationIds, it lands as a second app rather than an upgrade. */ data class Available(val release: Release, val isChannelSwitch: Boolean) : Result diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 0b06f99..f4ce5b0 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml index 22102a1..5dc5e35 100644 --- a/app/src/main/res/mipmap/ic_launcher.xml +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher_round.xml b/app/src/main/res/mipmap/ic_launcher_round.xml index 22102a1..5dc5e35 100644 --- a/app/src/main/res/mipmap/ic_launcher_round.xml +++ b/app/src/main/res/mipmap/ic_launcher_round.xml @@ -8,6 +8,12 @@ centre of a wide black band, and the narrow black guard splits the red into pairs. + The stripes reference @color/tartan_field rather than a literal, because the + same threadcount is woven in two colourways: Wallace red on a production + build, Wallace Hunting green on a dev one, so the two installs are told apart + in the app drawer by the icon alone. The build generates that colour; see + resValue in app/build.gradle.kts. + Warp is opaque, weft laid over it at half opacity, which is what gives the blended squares rather than a flat grid. Paths run well past the viewport because the group is rotated 45 degrees; the overspill keeps the corners @@ -22,43 +28,43 @@ android:rotation="45" android:pivotX="54.0" android:pivotY="54.0"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 97c6599..338becd 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,9 +1,16 @@ + - #B3121B #FF000000 #FFFFFFFF - - #4A0B10 + + + #0C0C0C + #F2C518 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1eff924..41e3314 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,8 @@ + - Pro Libertate Games From 9c9a40303ee4b2dbae3a5c17eac9f2aa43c012c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 22:13:52 +0000 Subject: [PATCH 2/4] Add CLAUDE.md recording how changes must reach CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A git push from a Claude session is made with a bot token, and GitHub creates no workflow run for it — the branch moves and CI stays silent. Today that put a change on dev and then main having only ever been built after the fact, and both branches needed a manual workflow_dispatch to produce anything. Record the working rule that avoids it: push the feature branch, then open and merge the pull request through the GitHub API, whose calls are attributed to the repo owner and do trigger workflows. ci.yml already listens on pull_request for main and dev, so the PR is what builds the change before it lands. Also note what a merge actually does here — dev publishes a dev-channel prerelease, main publishes a production release — and that Gradle cannot run in the sandbox, so CI is the only verification available. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YEsqYAMJKUJoM7UAavm169 --- CLAUDE.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7d93a8a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# Working on this repo with Claude + +## Land changes through the GitHub API, never `git push` alone + +**A `git push` from a Claude session does not trigger GitHub Actions.** The +sandbox pushes with a bot token, and GitHub creates no workflow run for it. The +branch updates, CI stays silent, and nothing gets built, tested or published. + +The evidence, if it ever needs rechecking: every workflow run in this repo's +history is attributed to the repo owner. Commits *authored* by Claude trigger CI +normally — `d165e5ef`, `469c900e`, `d6e237ce` all did — because a human pushed +them. Commits pushed *by a session* (`43394b8`, `742add4`) produced no run at +all. It is the pushing credential that matters, not the commit author. + +Calls through the GitHub API (the `mcp__github__*` tools) are attributed to the +repo owner and **do** trigger workflows. So: + +1. Commit to a feature branch and `git push` it. This triggers nothing, which is + fine — a feature branch has no workflow of its own. +2. **Open a pull request via the API.** `ci.yml` runs on + `pull_request: [main, dev]`, so this is what actually builds and tests the + change. Wait for it to pass. +3. **Merge via the API.** The resulting push to `dev` or `main` is attributed to + the owner and triggers the branch's workflow. + +Never merge by pushing `dev` or `main` directly from a session. It silently +skips every check and publishes nothing, leaving a branch whose head was never +built. + +If a PR needs re-verifying after more commits, note that `synchronize` events +come from a raw push and are also dead. Trigger `ci.yml` manually with +`workflow_dispatch` instead. + +## Merging is a shipping action + +Both long-lived branches publish on push, so a merge is a release, not just an +integration: + +- **`dev`** → `ci.yml` builds, tests, lints, then publishes a `v1.0.N-dev` + **prerelease**. Offered by the in-app updater to anyone on the Dev channel. +- **`main`** → `release.yml` publishes a **production** release. This is what + the updater installs for everyone on the Production channel. + +Confirm before merging to `main`. Note that `release.yml` runs `assembleDebug` +only — no tests, no lint — so a `main` merge is only as verified as the `dev` +build it replays. + +## Gradle does not run in the sandbox + +`./gradlew` cannot resolve the Android plugin here: the proxy blocks +`dl.google.com` with a 403. There is no local build, no local test run, and no +local lint. CI is the only verification available, which is the other reason +changes must reach it through a PR before landing. From 625a130366151bc231353c666a99d9b305c03406 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 22:18:24 +0000 Subject: [PATCH 3/4] Correct the rule: pull requests do not trigger CI either MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening this pull request through the API produced no workflow run, so the previous version of this file was wrong about the API route being the fix. The suppression covers everything the sandbox does — pushes, pull request opens, and merges — because it follows the integration, not the account the UI attributes the action to. workflow_dispatch is the only mechanism that works, because it creates a run directly rather than relying on an event. Record the loop that follows from that, including the part worth having: ci.yml gates only its staging and publishing steps on refs/heads/dev, so dispatching it on a feature branch runs build, tests and lint while publishing nothing. That is a real pre-merge gate with no YAML changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YEsqYAMJKUJoM7UAavm169 --- CLAUDE.md | 84 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d93a8a..5365101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,53 +1,69 @@ # Working on this repo with Claude -## Land changes through the GitHub API, never `git push` alone +## Nothing a session does triggers CI. Dispatch it explicitly. -**A `git push` from a Claude session does not trigger GitHub Actions.** The -sandbox pushes with a bot token, and GitHub creates no workflow run for it. The -branch updates, CI stays silent, and nothing gets built, tested or published. +GitHub creates **no workflow run** for anything this sandbox does — pushing a +branch, opening a pull request, or merging one. All of it goes through the same +integration, and GitHub suppresses event-triggered runs from it. The pull +request will say it was opened by the repo owner; that is only how the UI +attributes it, and it does not change the suppression. -The evidence, if it ever needs rechecking: every workflow run in this repo's -history is attributed to the repo owner. Commits *authored* by Claude trigger CI -normally — `d165e5ef`, `469c900e`, `d6e237ce` all did — because a human pushed -them. Commits pushed *by a session* (`43394b8`, `742add4`) produced no run at -all. It is the pushing credential that matters, not the commit author. +This was measured, not assumed: -Calls through the GitHub API (the `mcp__github__*` tools) are attributed to the -repo owner and **do** trigger workflows. So: +| Action | Result | +| --- | --- | +| Human pushes a Claude-authored commit (`d165e5ef`, `469c900e`) | CI runs | +| Session pushes `dev` / `main` (`43394b8`, `742add4`) | no run | +| Session opens a PR against `dev` (#1) | no run | +| Session calls `workflow_dispatch` | **runs** | -1. Commit to a feature branch and `git push` it. This triggers nothing, which is - fine — a feature branch has no workflow of its own. -2. **Open a pull request via the API.** `ci.yml` runs on - `pull_request: [main, dev]`, so this is what actually builds and tests the - change. Wait for it to pass. -3. **Merge via the API.** The resulting push to `dev` or `main` is attributed to - the owner and triggers the branch's workflow. +`workflow_dispatch` works because it creates a run directly instead of relying +on an event. It is the only mechanism available from a session, so every build +has to be asked for by hand. -Never merge by pushing `dev` or `main` directly from a session. It silently -skips every check and publishes nothing, leaving a branch whose head was never -built. +## Verify before merging, without publishing -If a PR needs re-verifying after more commits, note that `synchronize` events -come from a raw push and are also dead. Trigger `ci.yml` manually with -`workflow_dispatch` instead. +`ci.yml` builds, tests and lints on any ref, but its staging and publishing +steps are gated on `github.ref == 'refs/heads/dev'`. Dispatching it on a +**feature branch** therefore gives the full check — build, unit tests, Android +lint — and publishes nothing. + +So the working loop is: + +1. Commit and push the feature branch. +2. Dispatch `ci.yml` on that branch. Wait for green. This is the real review + gate, and it is the only verification that exists (see below). +3. Open the PR through the API and merge it there, so the change is reviewable + and the merge commit is honest about what happened. Neither step builds + anything. +4. Dispatch the target branch's workflow to actually build and publish: + `ci.yml` on `dev`, `release.yml` on `main`. + +Skipping step 4 leaves a branch whose head was never built and no release. ## Merging is a shipping action -Both long-lived branches publish on push, so a merge is a release, not just an +Both long-lived branches publish, so a merge is a release, not just an integration: -- **`dev`** → `ci.yml` builds, tests, lints, then publishes a `v1.0.N-dev` - **prerelease**. Offered by the in-app updater to anyone on the Dev channel. -- **`main`** → `release.yml` publishes a **production** release. This is what - the updater installs for everyone on the Production channel. +- **`dev`** → `ci.yml` publishes a `v1.0.N-dev` **prerelease**, offered by the + in-app updater to anyone on the Dev channel. +- **`main`** → `release.yml` publishes a **production** release, which the + updater installs for everyone on the Production channel. -Confirm before merging to `main`. Note that `release.yml` runs `assembleDebug` -only — no tests, no lint — so a `main` merge is only as verified as the `dev` +Confirm before shipping to `main`. Note that `release.yml` runs `assembleDebug` +only — no tests, no lint — so a `main` release is only as verified as the `dev` build it replays. ## Gradle does not run in the sandbox `./gradlew` cannot resolve the Android plugin here: the proxy blocks -`dl.google.com` with a 403. There is no local build, no local test run, and no -local lint. CI is the only verification available, which is the other reason -changes must reach it through a PR before landing. +`dl.google.com` with a 403. No local build, no local test run, no local lint. A +dispatched CI run is the only way to find out whether a change compiles. + +## A permanent fix, if you want one + +The suppression is a property of the sandbox's credential, so it cannot be +fixed from inside a session. Giving the environment a personal access token to +push with would make pushes and PRs behave normally for everyone. Until then, +the dispatch step above is the workaround. From b532749093be5e9fdf28ef1939bf85db0e46b5f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 23:33:11 +0000 Subject: [PATCH 4/4] Add a scorekeeper for games the app does not deal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pencil, reachable from the main menu: say how many are playing, name them, and write down what each scored every round. The tally sits at the foot of the screen because that is the one number anybody looks up. Points are recorded against a player's id rather than their position, which is what lets the players be dragged into a different order without their scores moving with them. Ids are never reused, so 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. The drag leaves the sheet 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, and the handlers read the sheet through rememberUpdatedState so a drag cannot be applied to a stale copy and throw away a name typed since. Rounds are not written in ink — tapping one reopens it for correction or strikes it out — and the sheet is saved as it is edited, so a phone call does not lose the game. It lives in its own DataStore rather than with the settings: a game in progress is not a preference. score/ScoreSheet.kt has no Android in it, for the same reason game/ does not, and every rule above is unit-tested on the JVM. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud --- README.md | 27 + .../org/prolibertate/games/MainActivity.kt | 2 + .../prolibertate/games/score/ScoreSheet.kt | 139 ++++ .../games/score/ScorekeeperRepository.kt | 51 ++ .../java/org/prolibertate/games/ui/AppRoot.kt | 23 +- .../games/ui/ScorekeeperScreen.kt | 636 ++++++++++++++++++ .../games/score/ScoreSheetTest.kt | 185 +++++ 7 files changed, 1060 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt create mode 100644 app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt diff --git a/README.md b/README.md index e088d63..e475e13 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/src/main/java/org/prolibertate/games/MainActivity.kt b/app/src/main/java/org/prolibertate/games/MainActivity.kt index d8e2cb1..b4a7059 100644 --- a/app/src/main/java/org/prolibertate/games/MainActivity.kt +++ b/app/src/main/java/org/prolibertate/games/MainActivity.kt @@ -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(), ) diff --git a/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt new file mode 100644 index 0000000..812f484 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/ScoreSheet.kt @@ -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 = 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 = emptyList(), + val rounds: List = 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 = 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): ScoreSheet = copy(rounds = rounds + roundOf(deltas)) + + /** Corrects a round already written down. Out-of-range indices do nothing. */ + fun withRoundAt(index: Int, deltas: Map): 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): 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) + } +} diff --git a/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt b/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt new file mode 100644 index 0000000..0394199 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/ScorekeeperRepository.kt @@ -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 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 = context.scorekeeperDataStore.data.map { prefs -> + prefs[Keys.SHEET] + ?.let { stored -> runCatching { json.decodeFromString(stored) }.getOrNull() } + ?: ScoreSheet() + } + + suspend fun save(sheet: ScoreSheet) { + context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index fd6f842..c82fa4e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -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 diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt new file mode 100644 index 0000000..5f38295 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -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() } + 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, + 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) { + 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) -> Unit, + onDelete: () -> Unit, +) { + val round = sheet.rounds[index] + val amounts = remember(index) { + mutableStateMapOf().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): Map = + 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() diff --git a/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt new file mode 100644 index 0000000..9454760 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/score/ScoreSheetTest.kt @@ -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]) + } +}