Author SHA1 Message Date
adrianoftyrielandPaperclip 4d7cd6182f Rename the installed Android package to ca.wallacenet.prolibertategames
Build and Release APK / Build APK and publish release (push) Failing after 7s
Follows the Gitea repo rename: the applicationId (and its .dev variant)
now matches the repo, the launcher name and the docs, instead of carrying
the abandoned Pilgrims of Katan identity.

The JVM namespace stays org.prolibertate.games, so no source file moves;
only the installed identity changes.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 14:47:13 +00:00
adrianoftyrielandPaperclip b58b4251dc Rename the Gitea repo references to ProLibertateGames
Build and Release APK / Build APK and publish release (push) Canceled after 0s
The repo was renamed from PilgrimsOfKatan to ProLibertateGames on the
Gitea instance. Update every reference so nothing points at the old name:

- OTA updater: the repo constant and the User-Agent
- publish-release.sh: the default repo, the asset name, the release title
- CI/release workflows: the artifact names and the staged APK name
- settings.gradle.kts: the Gradle project name
- Protocol.kt / LanTransport.kt: the mDNS service label and the Wi-Fi lock tag
- docs (README, CLAUDE.md, docs/CI.md) and the update-policy test fixtures

The installed applicationId is left unchanged in this commit.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 14:21:43 +00:00
adrianoftyrielandPaperclip 5c797c8807 Restore the Pro Libertate Games title and splash; list Katan under Board Games
Build and Release APK / Build APK and publish release (push) Canceled after 0s
The bootstrap rebranded the app to Pilgrims of Katan (WALA-118 review);
the decision is to keep the upstream's own identity instead.

- Title and splash: the upstream originals restored exactly — the Wallace
  tartan splash under the "Pro Libertate Games" uncial wordmark
  (SplashScreen.kt, Tartan.kt), the tartan launcher icon, and the
  Theme.ProLibertateGames window theme. The game screens go back to the
  upstream WallaceGold/WallaceRed accents the tartan is woven from.
- Names and colours: launcher names back to "Pro Libertate Games" /
  "Pro Libertate Games DEV"; the channel colours back to the clan red /
  hunting green setts (resValue tartan_field and friends). The installed
  identity stays ca.wallacenet.pilgrimsofkatan (+ .dev) — that is the
  package, not the brand.
- Catalogue: Katan is listed under Board Games (3-4 players, original
  blurb, no third-party marks) with available = false, so dev builds show
  it as "Coming soon" and a production build hides it until its engine
  and screen land.
- Docs: README/CLAUDE.md/docs/CI.md updated to the restored identity;
  CatalogTest's invariant now names Katan as the one deliberate exception
  rather than asserting nothing is unfinished.

Structural bootstrap untouched: applicationId, OTA repointed at the Gitea
releases, CI workflows, version stamping.

Verified: ./gradlew assembleDebug and testDebugUnitTest green — 628 tests,
0 failures.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 02:15:30 +00:00
adrianoftyrielandPaperclip 05c443a4b5 Publish: verify the release's stored state after attaching the APK
Build and Release APK / Build APK and publish release (push) Canceled after 0s
A live publish on git.wallacenet.ca kept coming back invisible to the updater:
the create accepted "draft": false, and the release was still stored as a
draft once the APK was attached — so the anonymous list the app reads came
back empty even though every publish step reported success.

The script now attaches first, then reads the release back and PATCHes the
state if it did not stick, failing loudly if the correction is refused. A
draft is not a channel a preview build can silently become.

Also lists all 22 catalogue games in the README's table; seven were missing,
which read as "the app has 15 games".

Verified against the live instance: anonymous list shows
v1.4.1001-dev as a published prerelease with a tag-named asset, the
production /releases/latest correctly returns 404 while only a prerelease
exists, and an anonymous download through the URL the app builds matches the
built APK byte for byte.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:27:33 +00:00
31 changed files with 595 additions and 225 deletions
+3 -3
View File
@@ -69,7 +69,7 @@ jobs:
- name: Upload debug APK - name: Upload debug APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: PilgrimsOfKatan-debug-apk name: ProLibertateGames-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk path: app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
@@ -79,7 +79,7 @@ jobs:
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: PilgrimsOfKatan-reports name: ProLibertateGames-reports
path: | path: |
app/build/reports/ app/build/reports/
if-no-files-found: ignore if-no-files-found: ignore
@@ -109,7 +109,7 @@ jobs:
stamp="$(( ${{ github.run_number }} + offset ))" stamp="$(( ${{ github.run_number }} + offset ))"
version="v${series}.${stamp}-dev" version="v${series}.${stamp}-dev"
src="app/build/outputs/apk/debug/app-debug.apk" src="app/build/outputs/apk/debug/app-debug.apk"
dest="PilgrimsOfKatan-${version}.apk" dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest" cp "$src" "$dest"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT"
+2 -2
View File
@@ -64,7 +64,7 @@ jobs:
stamp="$(( ${{ github.run_number }} + offset ))" stamp="$(( ${{ github.run_number }} + offset ))"
version="v${series}.${stamp}" version="v${series}.${stamp}"
src="app/build/outputs/apk/debug/app-debug.apk" src="app/build/outputs/apk/debug/app-debug.apk"
dest="PilgrimsOfKatan-${version}.apk" dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest" cp "$src" "$dest"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT"
@@ -72,7 +72,7 @@ jobs:
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: PilgrimsOfKatan-apk name: ProLibertateGames-apk
path: ${{ steps.stage.outputs.apk }} path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error if-no-files-found: error
+13 -11
View File
@@ -1,23 +1,25 @@
# Working on this repo (agent notes) # Working on this repo (agent notes)
Pilgrims of Katan — an Android collection of card and board games, playable ProLibertateGames — Wallacenet's build of an Android collection of card and
against AI or over Wi-Fi (LAN). Bootstrapped from board games, playable against AI or over Wi-Fi (LAN). Upstream:
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames) [adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
(GPL-3.0, kept as the `upstream` remote) for Wallacenet. Canonical repo: (GPL-3.0, kept as the `upstream` remote). Canonical repo:
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>. <https://git.wallacenet.ca/devadmin/ProLibertateGames>.
## Ground rules ## Ground rules
- **Licences travel.** `LICENSE` is GPL-3.0 and must stay. The Uncial Antiqua - **Licences travel.** `LICENSE` is GPL-3.0 and must stay. The Uncial Antiqua
font ships with its OFL licence at `docs/licenses/UncialAntiqua-OFL.txt`; font ships with its OFL licence at `docs/licenses/UncialAntiqua-OFL.txt`;
never delete or edit either. Attribution to the upstream lives in the README. never delete or edit either. Attribution to the upstream lives in the README.
- **No Catan trademarks or brand assets**, and none of the upstream's Wallace - **No Catan trademarks or brand assets** — the one hard line. The app's own
clan branding (it was removed in the rebrand; see README → Branding). The branding is the upstream's, restored (see README → Branding): tartan launcher
brand is: *Pilgrims of Katan*, `ca.wallacenet.pilgrimsofkatan` (+ `.dev`), icon and splash, Wallace clan red on production and hunting green on dev,
slate blue / dev green, hexagon emblem. launcher names *Pro Libertate Games* / *Pro Libertate Games DEV*. The
installed identity is `ca.wallacenet.prolibertategames` (+ `.dev`).
- **The existing catalogue is the regression safety net.** Keep every game - **The existing catalogue is the regression safety net.** Keep every game
green; new games (Catan) arrive as separate tasks, not edits to old ones. green; new games (Katan) arrive as separate tasks, not edits to old ones.
All 22 games are `available = true`. The 22 reference games are `available = true`; Katan is listed with
`available = false` (*Coming soon*) until its game task lands.
- **Docs conventions**: every game gets `docs/RULES-<id>.md` (index and gaps in - **Docs conventions**: every game gets `docs/RULES-<id>.md` (index and gaps in
`docs/README.md`); build/CI/release decisions go in `docs/CI.md`. Update the `docs/README.md`); build/CI/release decisions go in `docs/CI.md`. Update the
docs when behaviour changes — they are the check against the rules code. docs when behaviour changes — they are the check against the rules code.
@@ -44,7 +46,7 @@ One flag — `isDevBuild` in `app/build.gradle.kts` — drives applicationId,
launcher name and brand colour together. Never set them individually. launcher name and brand colour together. Never set them individually.
In-app OTA points at this repo's own Gitea releases In-app OTA points at this repo's own Gitea releases
(`/api/v1/repos/devadmin/PilgrimsOfKatan/releases…`) and is **on**: (`/api/v1/repos/devadmin/ProLibertateGames/releases…`) and is **on**:
`BuildConfig.OTA_ENABLED` is `true`. The channel is a decision already made and `BuildConfig.OTA_ENABLED` is `true`. The channel is a decision already made and
recorded — `docs/CI.md` documents the endpoints, the build-number scheme, and recorded — `docs/CI.md` documents the endpoints, the build-number scheme, and
what publishing requires. Setting the flag to `false` is the kill switch. what publishing requires. Setting the flag to `false` is the kill switch.
+42 -39
View File
@@ -1,13 +1,15 @@
# Pilgrims of Katan # ProLibertateGames
A collection of card and board games for Android, playable against the computer A collection of card and board games for Android, playable against the computer
or against other people over Wi-Fi (LAN). or against other people over Wi-Fi (LAN).
Derived from [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames) Wallacenet's build of [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
by adrianoftyriel, used under the GNU GPL v3 — see [LICENSE](LICENSE). Game by adrianoftyriel (GNU GPL v3 — see [LICENSE](LICENSE)), hosted at
rules documentation lives under [docs/](docs/README.md); build, CI and release <https://git.wallacenet.ca/devadmin/ProLibertateGames>. Game rules documentation
decisions live in [docs/CI.md](docs/CI.md). Catan itself arrives in later work; lives under [docs/](docs/README.md); build, CI and release decisions live in
this app ships the reference catalogue unchanged as the regression safety net. [docs/CI.md](docs/CI.md). Katan itself arrives in later work — it is listed under
Board Games as *Coming soon* — and this app ships the reference catalogue
unchanged as the regression safety net.
## What works today ## What works today
@@ -15,16 +17,16 @@ The app shell is complete and every game in the catalogue is playable end to end
| | | | | |
| --- | --- | | --- | --- |
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage | | **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage, Hearts, Klondike, FreeCell, Spider, Pyramid |
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü | | **Table** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Peg Solitaire, Yahtzee, Pirates and Bulgars, Ta Yü, Katan *(coming soon)* |
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The One entry is marked *Coming soon*: Katan, the island game, whose engine and
machinery for it is still there and still tested: a game listed in `GameCatalog` screen land with their own task. The machinery is still there and still tested:
with `available = false` appears in the menu marked *Coming soon* **on dev builds a game listed in `GameCatalog` with `available = false` appears in the menu
only**, so a production release lists just what can actually be played and nobody marked *Coming soon* **on dev builds only**, so a production release lists just
installs it and taps into a dead end. The menu decides this from the installed what can actually be played and nobody installs it and taps into a dead end. The
APK's own version name — the same source the updater uses to know which channel menu decides this from the installed APK's own version name — the same source
it is on. the updater uses to know which channel it is on.
Adding a game means writing a `GameRules` implementation, a `GameAi` and a Adding a game means writing a `GameRules` implementation, a `GameAi` and a
screen, then flipping `available` — the menu, lobby, networking, settings and OTA screen, then flipping `available` — the menu, lobby, networking, settings and OTA
@@ -135,21 +137,21 @@ it: every rule above is a pure function and unit-tested on the JVM.
## Branding ## Branding
Neutral, original, and deliberately *not* Catan's: no third-party marks or The app wears the upstream's own branding, restored (WALA-118 review): the
trade dress ship in this app (rebrand scope: WALA-118). The launcher icon is a Wallace tartan is the launcher icon and the splash, drawn as vector drawables and
hexagonal ring around a solid core — a game tile, not any game's logo — drawn Compose code so it stays sharp at every density and needs no PNGs. The app opens
as vector drawables so it stays sharp at every density and needs no PNGs. The on the tartan held under the name, set in a Celtic uncial hand for two seconds
app opens on the brand field fading to its deep shade, the name set in a Celtic before it fades into the menu.
uncial hand for two seconds before it fades into the menu.
A dev build wears the **dev green** field where production wears **slate A dev build wears the **hunting green** sett where production wears the **clan
blue**, and installs as *Pilgrims of Katan DEV*. That is what tells the two red**, and installs as *Pro Libertate Games DEV*. That is what tells the two
copies apart when both are on the phone; see copies apart when both are on the phone; see
[Both channels on one device](#both-channels-on-one-device). The palette lives [Both channels on one device](#both-channels-on-one-device). The tartan is drawn
in `ui/theme/Theme.kt` (`BrandField`, `BrandDeep`, `BrandAccent`) and the two by `ui/theme/Tartan.kt` (`TartanBackground`, `threadWidthFor`) off
channel colours are generated per build by `resValue` in `app/build.gradle.kts` `BuildConfig.DEV_BUILD`; the matching vector colours are generated per build by
— the icon vectors reference `@color/brand_field` rather than a literal, which `resValue` in `app/build.gradle.kts` — the icon vectors reference
is how one set of vectors serves both colourways. `@color/tartan_field` rather than a literal, which is how one set of vectors
serves both colourways.
Two honest caveats: Two honest caveats:
@@ -158,9 +160,10 @@ Two honest caveats:
the SIL Open Font License 1.1. The full licence is at the SIL Open Font License 1.1. The full licence is at
[docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and [docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and
must stay with the font — the OFL requires it to be distributed alongside. must stay with the font — the OFL requires it to be distributed alongside.
- **The upstream's clan tartan is gone; its code is not.** The Wallace tartan - **Katan is the one place this app deliberately diverges.** No Catan
was the upstream's brand and left with the rebrand. The GPL-licensed history trademarks or brand assets ship anywhere in it; the island game is listed
that produced this app remains in git, and the source stays at under its own name with original art only. The GPL-licensed history that
produced this app remains in git, and the source stays at
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames) [adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
as the `upstream` remote. as the `upstream` remote.
@@ -187,10 +190,10 @@ smaller than a fingertip.
Requires JDK 17 and the Android SDK (compileSdk 34). Requires JDK 17 and the Android SDK (compileSdk 34).
A local build is a dev build: it installs as A local build is a dev build: it installs as
`ca.wallacenet.pilgrimsofkatan.dev` under the name *Pilgrims of Katan DEV* on `ca.wallacenet.prolibertategames.dev` under the name *Pro Libertate Games DEV* on
the dev green field, so it goes on beside a production copy rather than over it. the hunting-green sett, so it goes on beside a production copy rather than over
Set `PLG_CHANNEL=production` to build the production package it. Set `PLG_CHANNEL=production` to build the production package
(`ca.wallacenet.pilgrimsofkatan`, slate blue). (`ca.wallacenet.prolibertategames`, clan red).
## CI ## CI
@@ -262,7 +265,7 @@ unit-tested on the JVM like the rules engines are.
### Both channels on one device ### Both channels on one device
A dev build installs under its own `applicationId`, A dev build installs under its own `applicationId`,
`ca.wallacenet.pilgrimsofkatan.dev`, so it is a separate app as far as Android `ca.wallacenet.prolibertategames.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 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. normally, because an update within a channel keeps the same package.
@@ -270,9 +273,9 @@ Three things move together, all off `isDevBuild` in `app/build.gradle.kts`:
| | Production | Dev | | | Production | Dev |
| --- | --- | --- | | --- | --- | --- |
| `applicationId` | `ca.wallacenet.pilgrimsofkatan` | `ca.wallacenet.pilgrimsofkatan.dev` | | `applicationId` | `ca.wallacenet.prolibertategames` | `ca.wallacenet.prolibertategames.dev` |
| Launcher name | Pilgrims of Katan | Pilgrims of Katan DEV | | Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
| Brand field | slate blue | deep green | | Tartan sett | clan red | hunting green |
Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build, 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 so a local `assembleDebug` is a dev build too and installs alongside a release
+18 -16
View File
@@ -40,9 +40,9 @@ val versionSeries = (project.findProperty("plgVersionSeries") as String?) ?: "1.
// //
// This one flag drives everything that makes a dev build a separate app rather // 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 // than a replacement for the production one — its own applicationId, its own
// launcher name, and its own brand colour. They have to move together: a dev // launcher name, and the hunting sett in place of the clan sett. They have to
// package wearing the production name and colours is exactly the confusion // move together: a dev package wearing the production name and colours is
// having both installed is meant to avoid. // exactly the confusion having both installed is meant to avoid.
val isDevBuild = buildChannel != "production" val isDevBuild = buildChannel != "production"
android { android {
@@ -58,7 +58,7 @@ android {
// A dev build installs under its own package, so a dev copy and a // 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 // production copy can sit side by side on one device instead of one
// replacing the other. // replacing the other.
applicationId = if (isDevBuild) "ca.wallacenet.pilgrimsofkatan.dev" else "ca.wallacenet.pilgrimsofkatan" applicationId = if (isDevBuild) "ca.wallacenet.prolibertategames.dev" else "ca.wallacenet.prolibertategames"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
// The -dev suffix in versionName is what the installed app reads to // The -dev suffix in versionName is what the installed app reads to
@@ -83,19 +83,21 @@ android {
resValue( resValue(
"string", "string",
"app_name", "app_name",
if (isDevBuild) "Pilgrims of Katan DEV" else "Pilgrims of Katan", if (isDevBuild) "Pro Libertate Games DEV" else "Pro Libertate Games",
) )
// The one colour that separates the two installs: slate blue on // The one colour that separates the two setts: Wallace red for
// production, deep green on dev. The launcher icon and the splash are // production, Wallace Hunting green for dev. The icon vectors reference
// both built from it, so the drawer shows at a glance which of the two // this rather than a literal, and Tartan.kt picks the matching Compose
// copies is which — the two have to stay in step or the icon and the // colour off DEV_BUILD below — the two have to stay in step or the
// splash it opens on would be different brands. // launcher icon and the splash it opens on would be different cloth.
resValue("color", "brand_field", if (isDevBuild) "#2A6B54" else "#2B4A78") resValue("color", "tartan_field", if (isDevBuild) "#1E7A3C" else "#E01B24")
// A darker shade of the same hue, held behind the window for the // Shades of the same field, so a launch does not flash the wrong colour
// instant before the splash draws, so a launch never flashes the wrong // before the tartan is drawn: splash_background is held behind the
// colour. // window for that instant, ic_launcher_background is the icon's flat
resValue("color", "splash_background", if (isDevBuild) "#153B2F" else "#182A44") // 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()) buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString())
@@ -131,7 +133,7 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
// For DEV_BUILD, which is how the Compose splash picks its brand colour. // For DEV_BUILD, which is how the Compose tartan picks its sett.
buildConfig = true buildConfig = true
} }
+1 -1
View File
@@ -25,7 +25,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.PilgrimsOfKatan"> android:theme="@style/Theme.ProLibertateGames">
<!-- No fixed orientation: the UI adapts to portrait, landscape and <!-- No fixed orientation: the UI adapts to portrait, landscape and
tablet widths. configChanges keeps Compose state across rotation tablet widths. configChanges keeps Compose state across rotation
@@ -12,7 +12,7 @@ import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.SettingsRepository import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.ui.AppEnv import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.PilgrimsTheme import org.prolibertate.games.ui.theme.ProLibertateTheme
import org.prolibertate.games.update.Updater import org.prolibertate.games.update.Updater
import java.util.UUID import java.util.UUID
@@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
) )
setContent { setContent {
PilgrimsTheme { ProLibertateTheme {
AppRoot(env) AppRoot(env)
} }
} }
@@ -94,6 +94,13 @@ object GameCatalog {
const val MORRIS = "morris" const val MORRIS = "morris"
const val PIRATES = "pirates" const val PIRATES = "pirates"
/**
* The island game the collection is named for. Listed under Board Games
* and marked *Coming soon*; the engine and its screen land with the game
* task, and `available` flips on once two-device play is verified.
*/
const val CATAN = "catan"
val all: List<GameDescriptor> = listOf( val all: List<GameDescriptor> = listOf(
GameDescriptor( GameDescriptor(
id = EUCHRE, id = EUCHRE,
@@ -333,6 +340,19 @@ object GameCatalog {
"because your score multiplies them.", "because your score multiplies them.",
available = true, available = true,
), ),
GameDescriptor(
id = CATAN,
title = "Katan",
menus = setOf(GameMenu.BOARD),
minPlayers = 3,
maxPlayers = 4,
teamBased = false,
blurb = "Trade, build, and settle the island.",
// Listed, not playable: the engine and screen land with the Katan
// game task. Dev builds show the entry as *Coming soon*; a
// production build lists only what can actually be played.
available = false,
),
) )
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
@@ -308,6 +308,6 @@ class LanTransport(private val context: Context) : Transport {
} }
const val CONNECT_TIMEOUT_MS = 8_000 const val CONNECT_TIMEOUT_MS = 8_000
const val WIFI_LOCK_TAG = "PilgrimsOfKatan:lan" const val WIFI_LOCK_TAG = "ProLibertateGames:lan"
} }
} }
@@ -33,7 +33,7 @@ const val PURPOSE_SCOREKEEPER = "scorekeeper"
/** Service type and name advertised over mDNS. */ /** Service type and name advertised over mDNS. */
const val SERVICE_TYPE = "_plgames._tcp" const val SERVICE_TYPE = "_plgames._tcp"
const val SERVICE_NAME = "PilgrimsOfKatan" const val SERVICE_NAME = "ProLibertateGames"
val protocolJson: Json = Json { val protocolJson: Json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -19,7 +20,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -30,9 +30,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.prolibertate.games.R import org.prolibertate.games.R
import org.prolibertate.games.ui.theme.BrandAccent import org.prolibertate.games.ui.theme.TartanBackground
import org.prolibertate.games.ui.theme.BrandDeep import org.prolibertate.games.ui.theme.threadWidthFor
import org.prolibertate.games.ui.theme.BrandField
/** How long the splash holds at full opacity before fading. */ /** How long the splash holds at full opacity before fading. */
private const val SPLASH_HOLD_MILLIS = 2_000L private const val SPLASH_HOLD_MILLIS = 2_000L
@@ -46,11 +45,7 @@ private const val SPLASH_FADE_MILLIS = 600
val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua)) val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua))
/** /**
* The launch screen: the brand field under the name of the game. * The launch screen: Wallace tartan under the name of the house.
*
* The gradient runs from the same hue as the window's `splash_background`
* held behind it, so the first frame and the splash are one brand in two
* shades rather than a colour change.
* *
* Drawn as an overlay rather than a separate destination, so the menu is * Drawn as an overlay rather than a separate destination, so the menu is
* already composed and ready behind it and the fade reveals a live screen * already composed and ready behind it and the fade reveals a live screen
@@ -75,17 +70,30 @@ fun SplashScreen(
onFinished() onFinished()
} }
BoxWithConstraints( BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) {
modifier = modifier
.fillMaxSize()
.alpha(alpha)
.background(Brush.verticalGradient(listOf(BrandField, BrandDeep))),
) {
// Read the constraints here and hold them in locals: inside Column the // Read the constraints here and hold them in locals: inside Column the
// implicit receiver becomes ColumnScope, and maxWidth/maxHeight are no // implicit receiver becomes ColumnScope, and maxWidth/maxHeight are no
// longer in scope. // longer in scope.
val shortestSide = minOf(maxWidth, maxHeight).value val shortestSide = minOf(maxWidth, maxHeight).value
// Weave scale is derived from the sett, not a fixed fraction, so a
// couple of full repeats are always on screen whatever the threadcount
// says and whatever the display is.
val threadWidth = threadWidthFor(across = shortestSide.dp, repeats = 2f)
val titleSize = (shortestSide * 0.13f).sp val titleSize = (shortestSide * 0.13f).sp
TartanBackground(
modifier = Modifier.fillMaxSize(),
threadWidth = threadWidth,
// On the bias, matching the icon.
rotationDegrees = 45f,
)
// A scrim keeps the lettering legible over the busiest part of the sett.
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.45f))
)
Column( Column(
modifier = Modifier modifier = Modifier
@@ -95,7 +103,7 @@ fun SplashScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = "Pilgrims", text = "Pro Libertate",
style = TextStyle( style = TextStyle(
fontFamily = CelticFontFamily, fontFamily = CelticFontFamily,
fontSize = titleSize, fontSize = titleSize,
@@ -109,11 +117,11 @@ fun SplashScreen(
), ),
) )
Text( Text(
text = "of Katan", text = "Games",
style = TextStyle( style = TextStyle(
fontFamily = CelticFontFamily, fontFamily = CelticFontFamily,
fontSize = titleSize, fontSize = titleSize,
color = BrandAccent, color = Color(0xFFF2C200),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
shadow = Shadow( shadow = Shadow(
color = Color.Black, color = Color.Black,
@@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp
import org.prolibertate.games.ui.common.CARD_ASPECT import org.prolibertate.games.ui.common.CARD_ASPECT
import org.prolibertate.games.ui.theme.FeltGreenDark import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.Parchment import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.AccentGold import org.prolibertate.games.ui.theme.WallaceGold
/** /**
* The furniture every patience table needs. * The furniture every patience table needs.
@@ -48,7 +48,7 @@ fun TableSlot(
modifier = modifier modifier = modifier
.width(width) .width(width)
.border( .border(
border = BorderStroke(if (highlighted) 3.dp else 0.dp, AccentGold), border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
) )
.clickable { onClick() }, .clickable { onClick() },
@@ -48,7 +48,7 @@ import org.prolibertate.games.ui.common.CardBackView
import org.prolibertate.games.ui.common.PlayingCardView import org.prolibertate.games.ui.common.PlayingCardView
import org.prolibertate.games.ui.theme.FeltGreenDark import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.Parchment import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.AccentGold import org.prolibertate.games.ui.theme.WallaceGold
private const val TITLE = "Klondike" private const val TITLE = "Klondike"
@@ -311,7 +311,7 @@ private fun Slot(
modifier = modifier modifier = modifier
.width(CARD_WIDTH) .width(CARD_WIDTH)
.border( .border(
border = BorderStroke(if (highlighted) 3.dp else 0.dp, AccentGold), border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
) )
.clickable { onClick() }, .clickable { onClick() },
@@ -41,8 +41,8 @@ import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.FeltGreenDark import org.prolibertate.games.ui.theme.FeltGreenDark
import org.prolibertate.games.ui.theme.Parchment import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.AccentGold import org.prolibertate.games.ui.theme.WallaceGold
import org.prolibertate.games.ui.theme.AccentRed import org.prolibertate.games.ui.theme.WallaceRed
private const val TITLE = "Peg Solitaire" private const val TITLE = "Peg Solitaire"
@@ -214,15 +214,15 @@ private fun HoleView(
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) { Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
val diameter = size * 0.72f val diameter = size * 0.72f
val ring = when { val ring = when {
selected -> AccentGold selected -> WallaceGold
isDestination -> AccentGold isDestination -> WallaceGold
else -> Parchment.copy(alpha = 0.35f) else -> Parchment.copy(alpha = 0.35f)
} }
Box( Box(
modifier = Modifier modifier = Modifier
.size(diameter) .size(diameter)
.background( .background(
color = if (filled) AccentRed else FeltGreenDark, color = if (filled) WallaceRed else FeltGreenDark,
shape = CircleShape, shape = CircleShape,
) )
.border( .border(
@@ -42,8 +42,8 @@ import org.prolibertate.games.game.yahtzee.scoreOf
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.Parchment import org.prolibertate.games.ui.theme.Parchment
import org.prolibertate.games.ui.theme.AccentGold import org.prolibertate.games.ui.theme.WallaceGold
import org.prolibertate.games.ui.theme.AccentRed import org.prolibertate.games.ui.theme.WallaceRed
private const val TITLE = "Yahtzee" private const val TITLE = "Yahtzee"
@@ -183,7 +183,7 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
.background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp)) .background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp))
.border( .border(
width = if (held) 3.dp else 1.dp, width = if (held) 3.dp else 1.dp,
color = if (held) AccentGold else AccentRed.copy(alpha = 0.4f), color = if (held) WallaceGold else WallaceRed.copy(alpha = 0.4f),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) )
.clickable(enabled = enabled) { onClick() }, .clickable(enabled = enabled) { onClick() },
@@ -193,7 +193,7 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
text = face?.toString() ?: "·", text = face?.toString() ?: "·",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = AccentRed, color = WallaceRed,
) )
} }
} }
@@ -263,7 +263,7 @@ private fun CardRow(
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (written == null && open) AccentRed else MaterialTheme.colorScheme.onSurface, color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
) )
} }
} }
@@ -0,0 +1,187 @@
package org.prolibertate.games.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
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)
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.
*
* Tartans are specified as a half-sett between two pivots and woven out to
* both sides, so `A B C` becomes `A B C B` — the pivot stripes are not
* doubled. Writing the data this way keeps it in the same form a threadcount
* is published in, instead of a flat list that has to be kept symmetric by
* hand.
*/
fun List<TartanStripe>.pivoted(): List<TartanStripe> =
if (size < 3) this else this + drop(1).dropLast(1).reversed()
/**
* The Wallace sett, from its published threadcount: **K/4 R32 K32 Y/4**.
*
* The slashes mark the pivots, so this is the half-sett and it reflects out to
* `K4 R32 K32 Y4 K32 R32` — 136 threads, roughly half black, half red, with a
* yellow line worth 3%. Two consequences that are easy to get backwards and
* that define the pattern:
*
* - the yellow overstripe runs down the **centre of a wide black band**, not
* 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<TartanStripe> = 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<TartanStripe> = wallaceSett(WallaceHuntingGreen)
/** The Wallace threadcount woven with [field] in place of the red. */
private fun wallaceSett(field: Color): List<TartanStripe> = listOf(
TartanStripe(WallaceBlack, 4),
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<TartanStripe> =
if (BuildConfig.DEV_BUILD) WallaceHuntingSett else WallaceSett
/** Threads in one full repeat of a sett. */
val List<TartanStripe>.threadsPerRepeat: Int get() = sumOf { it.threads }
/**
* The thread width that fits [repeats] repeats of [sett] across [across].
*
* Scale has to be derived from the sett rather than fixed, because changing a
* threadcount changes how many threads a repeat contains — a fixed width and a
* larger sett silently zooms in until the cloth is a couple of enormous blocks
* and stops reading as tartan at all.
*/
fun threadWidthFor(
sett: List<TartanStripe> = AppSett,
across: Dp,
repeats: Float = 2f,
): Dp = across / (sett.threadsPerRepeat * repeats)
/**
* Draws a tartan by laying the sett down vertically as warp, then across as
* weft at half opacity. The overlap is what produces the blended squares and
* the sense of a woven cloth rather than a printed grid — red warp under black
* weft comes out a darker red, exactly as it does on the loom.
*
* [rotationDegrees] turns the cloth on the bias. The stripes are drawn well
* past the edges of the canvas so the corners stay covered once it is turned.
*/
fun DrawScope.drawTartan(
sett: List<TartanStripe> = AppSett,
threadSize: Float,
rotationDegrees: Float = 0f,
) {
if (sett.isEmpty() || threadSize <= 0f) return
val repeat = sett.sumOf { it.threads } * threadSize
if (repeat <= 0f) return
// Turning a square by 45 degrees needs about 0.21 of its size spare on
// every edge; three quarters is generous enough for any angle.
val pad = if (rotationDegrees == 0f) 0f else size.maxDimension * 0.75f
val left = -pad
val top = -pad
val right = size.width + pad
val bottom = size.height + pad
rotate(degrees = rotationDegrees) {
// Warp: full-height bands.
var x = left - (left.mod(repeat))
while (x < right) {
var offset = x
for (stripe in sett) {
val width = stripe.threads * threadSize
drawRect(
color = stripe.color,
topLeft = Offset(offset, top),
size = Size(width, bottom - top),
)
offset += width
}
x += repeat
}
// Weft: full-width bands, half opacity so the warp shows through.
var y = top - (top.mod(repeat))
while (y < bottom) {
var offset = y
for (stripe in sett) {
val height = stripe.threads * threadSize
drawRect(
color = stripe.color.copy(alpha = 0.5f),
topLeft = Offset(left, offset),
size = Size(right - left, height),
)
offset += height
}
y += repeat
}
}
}
/**
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
* a fine weave on a phone and a coarse one on a tablet without redrawing it.
*/
@Composable
fun TartanBackground(
modifier: Modifier = Modifier,
sett: List<TartanStripe> = AppSett,
threadWidth: Dp = 2.dp,
rotationDegrees: Float = 0f,
) {
Canvas(modifier = modifier) {
drawTartan(
sett = sett,
threadSize = threadWidth.toPx(),
rotationDegrees = rotationDegrees,
)
}
}
@@ -6,7 +6,6 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import org.prolibertate.games.BuildConfig
/** Card-table green, which the board and table surfaces are drawn on. */ /** Card-table green, which the board and table surfaces are drawn on. */
val FeltGreen = Color(0xFF1B5E20) val FeltGreen = Color(0xFF1B5E20)
@@ -15,31 +14,6 @@ val Parchment = Color(0xFFF4EFE4)
val CardRed = Color(0xFFB3261E) val CardRed = Color(0xFFB3261E)
val CardBlack = Color(0xFF1C1B1F) val CardBlack = Color(0xFF1C1B1F)
/**
* The brand palette for Pilgrims of Katan — original and neutral, shared by
* every game in the catalogue. Slate blue on production, deep green on dev, so
* the two installs are told apart at a glance; both must stay in step with the
* resValues in app/build.gradle.kts or the icon and the splash it opens on
* would be different brands.
*
* The upstream shipped a clan tartan here. This project carries no clan or
* third-party trade dress (WALA-118).
*/
val BrandField: Color = if (BuildConfig.DEV_BUILD) Color(0xFF2A6B54) else Color(0xFF2B4A78)
val BrandDeep: Color = if (BuildConfig.DEV_BUILD) Color(0xFF153B2F) else Color(0xFF182A44)
/** The parchment accent: the emblem on the icon, the second line of the splash. */
val BrandAccent: Color = Color(0xFFEFD9A4)
/**
* Accents used *inside* a game table — a highlight, a held die, a danger mark.
* Deliberately channel-free: what a game shows must not change with which
* install it is running in. Values unchanged from the upstream, so the games
* themselves look exactly as they did.
*/
val AccentRed = Color(0xFFE01B24)
val AccentGold = Color(0xFFF2C518)
val TeamColours: List<Color> = listOf( val TeamColours: List<Color> = listOf(
Color(0xFF1565C0), // blue Color(0xFF1565C0), // blue
Color(0xFFC62828), // red Color(0xFFC62828), // red
@@ -65,7 +39,7 @@ private val LightColours = lightColorScheme(
) )
@Composable @Composable
fun PilgrimsTheme( fun ProLibertateTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
@@ -12,7 +12,7 @@ import java.net.URI
* meaning to be had from comparing across them, so nothing does. * meaning to be had from comparing across them, so nothing does.
* *
* They also carry different applicationIds — a dev build installs as * They also carry different applicationIds — a dev build installs as
* `…pilgrimsofkatan.dev` — so both can sit on one device, each updating * `…prolibertategames.dev` — so both can sit on one device, each updating
* itself down its own line and leaving the other alone. * itself down its own line and leaving the other alone.
*/ */
enum class UpdateChannel(val label: String, val blurb: String) { enum class UpdateChannel(val label: String, val blurb: String) {
@@ -126,7 +126,7 @@ class Updater(private val activity: Activity) {
(URL(url).openConnection() as HttpURLConnection).apply { (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
setRequestProperty("Accept", "application/json") setRequestProperty("Accept", "application/json")
setRequestProperty("User-Agent", "PilgrimsOfKatan-Updater") setRequestProperty("User-Agent", "ProLibertateGames-Updater")
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 15_000 readTimeout = 15_000
} }
@@ -206,7 +206,7 @@ class Updater(private val activity: Activity) {
const val SERVER = "https://git.wallacenet.ca" const val SERVER = "https://git.wallacenet.ca"
const val SERVER_HOST = "git.wallacenet.ca" const val SERVER_HOST = "git.wallacenet.ca"
const val OWNER = "devadmin" const val OWNER = "devadmin"
const val REPO = "PilgrimsOfKatan" const val REPO = "ProLibertateGames"
/** Release routes, Gitea's `/api/v1` spelling of GitHub's API. */ /** Release routes, Gitea's `/api/v1` spelling of GitHub's API. */
const val API = "$SERVER/api/v1/repos/$OWNER/$REPO" const val API = "$SERVER/api/v1/repos/$OWNER/$REPO"
@@ -1,16 +1,70 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
The flat field behind the launcher emblem: brand_field, slate blue on A single square of the Wallace tartan, set on the bias.
production and deep green on dev, generated by the build (see resValue in
app/build.gradle.kts) so the two installs are told apart in the app drawer Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
by the icon alone. as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
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
covered. One full repeat fills the adaptive icon's safe zone.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <group
android:fillColor="@color/brand_field" android:rotation="45"
android:pathData="M0,0h108v108h-108z" /> android:pivotX="54.0"
android:pivotY="54.0">
<path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector> </vector>
@@ -1,23 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
The brand emblem: a hexagonal ring around a solid hexagonal core, in Deliberately empty. The icon is the cloth itself, so the adaptive icon's
parchment on the brand field behind it (see ic_launcher_background). foreground layer carries nothing — anything drawn here would sit on top of the
tartan and break the "single square of tartan" idea.
The hexagon is a game tile, not any game's mark — deliberately generic
branding for Pilgrims of Katan, carrying no third-party logo or trade dress.
Everything sits inside the adaptive icon's safe zone (a centred 66dp circle
on this 108dp canvas) so no launcher mask can clip it.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Ring and core in one even-odd path: outer hexagon, inner hexagon,
core hexagon — odd coverage fills, so the ring and the core are drawn
and the gap between them stays open to the field showing through. -->
<path <path
android:fillColor="@color/brand_accent" android:fillColor="#00000000"
android:fillType="evenOdd" android:pathData="M0,0h108v108h-108z" />
android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" />
</vector> </vector>
+60 -12
View File
@@ -1,22 +1,70 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
The legacy (pre-API 26) launcher icon: the same brand emblem as the adaptive A single square of the Wallace tartan, set on the bias.
icon's foreground, over the same brand field, in the one layer those
launchers get.
Field first, emblem second: two paths, because the emblem's ring relies on Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
even-odd parity and a filled background inside the same path would flip it. as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
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
covered. One full repeat fills the adaptive icon's safe zone.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:width="48dp"
android:height="48dp" android:height="48dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <group
android:fillColor="@color/brand_field" android:rotation="45"
android:pathData="M0,0h108v108h-108z" /> android:pivotX="54.0"
<path android:pivotY="54.0">
android:fillColor="@color/brand_accent" <path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
android:fillType="evenOdd" <path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector> </vector>
+60 -12
View File
@@ -1,22 +1,70 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
The legacy (pre-API 26) launcher icon: the same brand emblem as the adaptive A single square of the Wallace tartan, set on the bias.
icon's foreground, over the same brand field, in the one layer those
launchers get.
Field first, emblem second: two paths, because the emblem's ring relies on Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
even-odd parity and a filled background inside the same path would flip it. as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
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
covered. One full repeat fills the adaptive icon's safe zone.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:width="48dp"
android:height="48dp" android:height="48dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <group
android:fillColor="@color/brand_field" android:rotation="45"
android:pathData="M0,0h108v108h-108z" /> android:pivotX="54.0"
<path android:pivotY="54.0">
android:fillColor="@color/brand_accent" <path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
android:fillType="evenOdd" <path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" /> <path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
</group>
</vector> </vector>
+8 -8
View File
@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
The colours the brand shares between channels. The two that differ — The colours the sett shares between channels. The one that differs —
brand_field (slate blue on production, deep green on dev) and its darker tartan_field, red on production and hunting green on dev — is generated by the
shade splash_background — are generated by the build. See resValue in build, along with splash_background and ic_launcher_background, which are
app/build.gradle.kts. shades of it. See resValue in app/build.gradle.kts.
--> -->
<resources> <resources>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- The parchment accent drawn on the brand field: the emblem on the <!-- The ground the sett is woven on, and the overstripe down its centre.
launcher icon and the second line of the splash. Same number as the Same numbers as WallaceBlack and WallaceGold in Tartan.kt. -->
accent in Theme.kt. --> <color name="tartan_ground">#0C0C0C</color>
<color name="brand_accent">#EFD9A4</color> <color name="tartan_over">#F2C518</color>
</resources> </resources>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
app_name is generated by the build rather than declared here, because it app_name is generated by the build rather than declared here, because it
differs by channel: a dev build installs as "Pilgrims of Katan DEV" differs by channel: a dev build installs as "Pro Libertate Games DEV"
alongside the production copy. See resValue in app/build.gradle.kts. alongside the production copy. See resValue in app/build.gradle.kts.
--> -->
<resources> <resources>
+1 -1
View File
@@ -2,7 +2,7 @@
<resources> <resources>
<!-- Compose supplies the real theme; this only needs to be a Material 3 <!-- Compose supplies the real theme; this only needs to be a Material 3
base with no action bar so the app draws its own top bars. --> base with no action bar so the app draws its own top bars. -->
<style name="Theme.PilgrimsOfKatan" parent="android:Theme.Material.NoActionBar"> <style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item> <item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@color/splash_background</item> <item name="android:windowBackground">@color/splash_background</item>
@@ -106,17 +106,19 @@ class CatalogTest {
} }
@Test @Test
fun `every game in the catalogue can be played`() { fun `every reference game in the catalogue can be played`() {
// There is nothing left marked "coming soon". The machinery that hides // Every game the app ships is playable. The one exception is Katan,
// unfinished games from a production build is still there and still // listed so the slot exists and marked *Coming soon* until its engine
// tested above — it simply has nothing to hide at the moment, and this // and screen land — `available = false` is what keeps an unfinished
// is what will fail the day something is added to the list before its // game out of a production build while leaving it visible on a dev one.
// engine is written.
assertTrue(GameCatalog.all.all { it.available })
assertEquals( assertEquals(
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false), listOf(GameCatalog.CATAN),
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true), GameCatalog.all.filterNot { it.available }.map { it.id },
) )
// It is also the only difference the two channels see in a menu.
val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
} }
@Test @Test
@@ -22,7 +22,7 @@ class UpdatePolicyTest {
tag = tag, tag = tag,
versionCode = versionCode, versionCode = versionCode,
apkUrl = "https://example.invalid/$tag.apk", apkUrl = "https://example.invalid/$tag.apk",
apkName = "PilgrimsOfKatan-$tag.apk", apkName = "ProLibertateGames-$tag.apk",
channel = channel, channel = channel,
) )
@@ -60,16 +60,16 @@ class UpdatePolicyTest {
// The instance reports its canonical host; the app was told to trust // The instance reports its canonical host; the app was told to trust
// another. Same bytes, one host, whatever the server names itself. // another. Same bytes, one host, whatever the server names itself.
assertEquals( assertEquals(
"https://git.wallacenet.ca/devadmin/PilgrimsOfKatan/releases/download/" + "https://git.wallacenet.ca/devadmin/ProLibertateGames/releases/download/" +
"v1.4.1001-dev/PilgrimsOfKatan-v1.4.1001-dev.apk", "v1.4.1001-dev/ProLibertateGames-v1.4.1001-dev.apk",
onReportedHost( onReportedHost(
"https://git.saskvalleytech.com/devadmin/PilgrimsOfKatan/releases/download/" + "https://git.saskvalleytech.com/devadmin/ProLibertateGames/releases/download/" +
"v1.4.1001-dev/PilgrimsOfKatan-v1.4.1001-dev.apk", "v1.4.1001-dev/ProLibertateGames-v1.4.1001-dev.apk",
"git.wallacenet.ca", "git.wallacenet.ca",
), ),
) )
// A URL that already says the right host is left exactly as it is. // A URL that already says the right host is left exactly as it is.
val already = "https://git.wallacenet.ca/devadmin/PilgrimsOfKatan/releases/download/v1.4.1/a.apk" val already = "https://git.wallacenet.ca/devadmin/ProLibertateGames/releases/download/v1.4.1/a.apk"
assertEquals(already, onReportedHost(already, "git.wallacenet.ca")) assertEquals(already, onReportedHost(already, "git.wallacenet.ca"))
// Anything unparseable survives untouched rather than being dropped. // Anything unparseable survives untouched rather than being dropped.
assertEquals("not a url", onReportedHost("not a url", "git.wallacenet.ca")) assertEquals("not a url", onReportedHost("not a url", "git.wallacenet.ca"))
+18 -13
View File
@@ -1,11 +1,11 @@
# CI, releases, and OTA — decisions and how to build # CI, releases, and OTA — decisions and how to build
Status as of the Pilgrims of Katan bootstrap (WALA-118). Status as of the ProLibertateGames bootstrap (WALA-118).
## Decisions ## Decisions
1. **The release channel is this instance's own releases.** The app ships from 1. **The release channel is this instance's own releases.** The app ships from
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>, and the in-app updater <https://git.wallacenet.ca/devadmin/ProLibertateGames>, and the in-app updater
reads that repo's releases over Gitea's REST API. GitHub is not a channel for reads that repo's releases over Gitea's REST API. GitHub is not a channel for
this project; the only GitHub URLs left are the `upstream` remote and the this project; the only GitHub URLs left are the `upstream` remote and the
attribution link in the README. attribution link in the README.
@@ -80,15 +80,15 @@ release — the honest answer for a build nobody stamped.
export JAVA_HOME=... # a JDK 17 install export JAVA_HOME=... # a JDK 17 install
export ANDROID_HOME=... # SDK root (or set sdk.dir in local.properties) export ANDROID_HOME=... # SDK root (or set sdk.dir in local.properties)
./gradlew assembleDebug # dev-channel app (default: ca.wallacenet.pilgrimsofkatan.dev) ./gradlew assembleDebug # dev-channel app (default: ca.wallacenet.prolibertategames.dev)
PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet.pilgrimsofkatan) PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet.prolibertategames)
./gradlew testDebugUnitTest # JVM unit tests ./gradlew testDebugUnitTest # JVM unit tests
./gradlew lintDebug # Android lint (CI runs this too) ./gradlew lintDebug # Android lint (CI runs this too)
``` ```
The two variants install side by side: different applicationId, different The two variants install side by side: different applicationId, different
launcher name (`Pilgrims of Katan DEV` vs `Pilgrims of Katan`), different brand launcher name (`Pro Libertate Games DEV` vs `Pro Libertate Games`), different
colour. tartan sett.
## Manual release (works today, no runner needed) ## Manual release (works today, no runner needed)
@@ -97,10 +97,15 @@ GITEA_TOKEN=… ./scripts/publish-release.sh v1.4.1001 app/build/outputs/apk/deb
``` ```
The script takes `<tag> <apk> <prerelease|release> [target-sha]`, creates the The script takes `<tag> <apk> <prerelease|release> [target-sha]`, creates the
release through `POST /api/v1/repos/{owner}/{repo}/releases`, and attaches the release through `POST /api/v1/repos/{owner}/{repo}/releases`, attaches the APK
APK through `POST …/releases/{id}/assets` — which is the upload route Gitea's through `POST …/releases/{id}/assets` — which is the upload route Gitea's
Swagger declares for this instance (1.27.3). If the tag already exists it Swagger declares for this instance (1.27.3) — and then reads the release back
publishes into the existing release instead of failing, so a retry is safe. and corrects its state if it did not stick. That last step is not decoration:
this instance has been observed to accept `draft: false` at create time and
store the release as a draft after the attachment lands, and a draft is
invisible to the updater, so the script verifies rather than assumes. If the
tag already exists it publishes into the existing release instead of failing,
so a retry is safe.
## What the in-app updater reads ## What the in-app updater reads
@@ -108,8 +113,8 @@ publishes into the existing release instead of failing, so a retry is safe.
| Channel | Endpoint | | Channel | Endpoint |
| --- | --- | | --- | --- |
| production | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases/latest` | | production | `GET /api/v1/repos/devadmin/ProLibertateGames/releases/latest` |
| dev | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases?limit=30` | | dev | `GET /api/v1/repos/devadmin/ProLibertateGames/releases?limit=30` |
- `/releases/latest` is documented by Gitea as *the most recent non-prerelease, - `/releases/latest` is documented by Gitea as *the most recent non-prerelease,
non-draft release* — the same rule GitHub implements, so the production non-draft release* — the same rule GitHub implements, so the production
@@ -121,7 +126,7 @@ publishes into the existing release instead of failing, so a retry is safe.
`prerelease`, `draft`, and assets carrying `browser_download_url`. Gitea says `prerelease`, `draft`, and assets carrying `browser_download_url`. Gitea says
`limit` where GitHub says `per_page`. `limit` where GitHub says `per_page`.
- Each asset must be named so it contains its tag — - Each asset must be named so it contains its tag —
`PilgrimsOfKatan-v1.4.1001.apk` for tag `v1.4.1001` — because the updater `ProLibertateGames-v1.4.1001.apk` for tag `v1.4.1001` — because the updater
picks the versioned APK out of the release's assets by that rule. picks the versioned APK out of the release's assets by that rule.
`BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision: it is `BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision: it is
+29 -4
View File
@@ -11,7 +11,7 @@
# Environment: # Environment:
# GITEA_TOKEN required — a token that may create releases on the repo. # GITEA_TOKEN required — a token that may create releases on the repo.
# GITEA_API API base, default https://git.wallacenet.ca/api/v1 # GITEA_API API base, default https://git.wallacenet.ca/api/v1
# GITEA_REPO owner/name, default devadmin/PilgrimsOfKatan # GITEA_REPO owner/name, default devadmin/ProLibertateGames
# #
# The four arguments can also arrive as RELEASE_TAG, RELEASE_APK, # The four arguments can also arrive as RELEASE_TAG, RELEASE_APK,
# RELEASE_KIND and RELEASE_TARGET, which is how the workflows pass them. # RELEASE_KIND and RELEASE_TARGET, which is how the workflows pass them.
@@ -34,7 +34,7 @@ esac
[ -f "$apk" ] || { echo "publish-release: no such APK: ${apk:-<unset>}" >&2; exit 2; } [ -f "$apk" ] || { echo "publish-release: no such APK: ${apk:-<unset>}" >&2; exit 2; }
[ -n "${GITEA_TOKEN:-}" ] || { echo "publish-release: GITEA_TOKEN is not set" >&2; exit 2; } [ -n "${GITEA_TOKEN:-}" ] || { echo "publish-release: GITEA_TOKEN is not set" >&2; exit 2; }
api="${GITEA_API:-${GITHUB_API_URL:-https://git.wallacenet.ca/api/v1}}/repos/${GITEA_REPO:-${GITHUB_REPOSITORY:-devadmin/PilgrimsOfKatan}}" api="${GITEA_API:-${GITHUB_API_URL:-https://git.wallacenet.ca/api/v1}}/repos/${GITEA_REPO:-${GITHUB_REPOSITORY:-devadmin/ProLibertateGames}}"
tmp="$(mktemp -d)" tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM trap 'rm -rf "$tmp"' EXIT INT TERM
@@ -43,7 +43,7 @@ trap 'rm -rf "$tmp"' EXIT INT TERM
# updater picks the versioned APK out of a release's assets by finding the one # updater picks the versioned APK out of a release's assets by finding the one
# whose name contains the tag, so an asset called app-debug.apk would only be # whose name contains the tag, so an asset called app-debug.apk would only be
# found by the fallback rule. # found by the fallback rule.
apk_name="PilgrimsOfKatan-${tag}.apk" apk_name="ProLibertateGames-${tag}.apk"
# The tag and the file name are both derived from gradle.properties and a run # The tag and the file name are both derived from gradle.properties and a run
# number, so neither can carry a quote or a backslash into this JSON. # number, so neither can carry a quote or a backslash into this JSON.
@@ -56,7 +56,7 @@ else
fi fi
cat > "$tmp/release.json" <<JSON cat > "$tmp/release.json" <<JSON
{"tag_name":"${tag}","target_commitish":"${target}","name":"Pilgrims of Katan ${tag}","body":"${note}","prerelease":${prerelease},"draft":false} {"tag_name":"${tag}","target_commitish":"${target}","name":"Pro Libertate Games ${tag}","body":"${note}","prerelease":${prerelease},"draft":false}
JSON JSON
code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' -X POST \ code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' -X POST \
@@ -86,6 +86,10 @@ fi
id="$(grep -o '"id":[0-9]*' "$tmp/create.json" | head -n1 | tr -dc '0-9' || true)" id="$(grep -o '"id":[0-9]*' "$tmp/create.json" | head -n1 | tr -dc '0-9' || true)"
[ -n "$id" ] || { echo "publish-release: no release id in response" >&2; cat "$tmp/create.json" >&2; echo >&2; exit 1; } [ -n "$id" ] || { echo "publish-release: no release id in response" >&2; cat "$tmp/create.json" >&2; echo >&2; exit 1; }
# Attach the APK first, then check the stored state: uploading has been
# observed to leave a freshly created release as a draft, whatever the create
# response said, so the check has to be the last thing that happens rather
# than a step in the middle.
code="$(curl -sS -o "$tmp/asset.json" -w '%{http_code}' -X POST \ code="$(curl -sS -o "$tmp/asset.json" -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${apk};filename=${apk_name}" \ -F "attachment=@${apk};filename=${apk_name}" \
@@ -98,4 +102,25 @@ if [ "$code" != "201" ] && [ "$code" != "200" ]; then
exit 1 exit 1
fi fi
# Read back what the database actually stored before declaring success. A draft
# is invisible to the updater — exactly what a channel must not silently become
# — and this instance has been seen to report one as published at create time
# and store it as a draft by the time it is read.
curl -sS -o "$tmp/stored.json" -H "Authorization: token ${GITEA_TOKEN}" "$api/releases/${id}"
if grep -q '"draft":true' "$tmp/stored.json" ||
! grep -q "\"prerelease\":${prerelease}" "$tmp/stored.json"; then
state="$(curl -sS -o "$tmp/fix.json" -w '%{http_code}' -X PATCH \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"draft\":false,\"prerelease\":${prerelease}}" \
"$api/releases/${id}")"
if [ "$state" != "200" ]; then
echo "publish-release: release ${tag} is stored with the wrong state (HTTP ${state})" >&2
cat "$tmp/fix.json" >&2
echo >&2
exit 1
fi
echo "publish-release: corrected the stored state of ${tag} (it was saved as a draft)"
fi
echo "publish-release: ${tag} -> release ${id} on ${api#*/repos/}, asset ${apk_name}" echo "publish-release: ${tag} -> release ${id} on ${api#*/repos/}, asset ${apk_name}"
+1 -1
View File
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "PilgrimsOfKatan" rootProject.name = "ProLibertateGames"
include(":app") include(":app")