The domain answers 403 (error 1014) until the Pages project exists, which made the carry-over fetch abort the very deploy that would create it. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
400 lines
20 KiB
Markdown
400 lines
20 KiB
Markdown
# Pro Libertate Games
|
||
|
||
A collection of card and board games for Android, playable against the computer
|
||
or against other people over Wi-Fi (LAN).
|
||
|
||
## What works today
|
||
|
||
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 |
|
||
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
|
||
|
||
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
|
||
machinery for it is still there and still tested: a game listed in `GameCatalog`
|
||
with `available = false` appears in the menu marked *Coming soon* **on dev builds
|
||
only**, so a production release lists just what can actually be played and nobody
|
||
installs it and taps into a dead end. The menu decides this from the installed
|
||
APK's own version name — the same source the updater uses to know which channel
|
||
it is on.
|
||
|
||
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
||
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
|
||
layers are game-agnostic and need no changes.
|
||
|
||
## Playing with other people
|
||
|
||
Pick a game, choose **Host a game for others to join**, and the device advertises
|
||
itself on the network. Other devices choose **Join a game nearby** and
|
||
pick the host from the list. Seats start out filled by the computer and are
|
||
handed to people as they join, so any mix of humans and AI works — whatever is
|
||
still marked *Computer* when the host starts stays AI.
|
||
|
||
- **Wi-Fi (LAN)** — both devices must be on the same network. Discovery uses
|
||
mDNS/NSD; play runs over a TCP socket.
|
||
|
||
### How a match is kept in step
|
||
|
||
The host is authoritative. Clients send *intents* ("I would like to play this
|
||
card") and the host re-validates every one against the rules before it takes
|
||
effect — a client cannot make an illegal move by sending a malformed message.
|
||
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, rummy, whatever is actually on the table. It is laid out as the paper
|
||
it replaces: a column per player with their name at the head, a row per round
|
||
running down the page, and the running total ruled off at the foot.
|
||
|
||
```
|
||
Alice Bob Carol
|
||
1 +3 −1 +2
|
||
2 +5 +7 •
|
||
3 [ ] [ ] [ ] ← the row being filled in
|
||
Σ 8 6 2
|
||
```
|
||
|
||
The bottom row is always the one being filled in. Finish it and it is written
|
||
down, and a fresh empty row opens above the totals for the next round. The
|
||
totals and that button stay put while the rounds scroll under them, so the
|
||
tally is never off-screen.
|
||
|
||
Tap a column head to name it or remove that player; **drag it sideways to move
|
||
the column**, and the whole column travels with the name. 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 a round number down the left edge opens
|
||
that row for correction or lets it be struck out, and the totals follow.
|
||
|
||
Points are taken away by typing a minus, or with the **±** button, which flips
|
||
the last number typed. Not every keyboard offers a minus on its number pad, and
|
||
subtracting has to work on all of them.
|
||
|
||
### Sharing a sheet between devices
|
||
|
||
Tap **Share** and this phone becomes the host of its sheet; others choose
|
||
**Scorekeeper → Join a shared sheet nearby**. Discovery and joining by address
|
||
work exactly as they do for a game, hotspot included. Nobody is let in on their
|
||
own say-so: the host gets a prompt with the guest's name and has to **Allow** or
|
||
**Deny** it. A guest who was let in and drops out — a locked phone, say — is let
|
||
straight back in rather than asked again, and the host can **Remove** anybody
|
||
from the Sharing panel.
|
||
|
||
Everyone on the sheet can add rounds, correct them, rename and move columns. The
|
||
host's copy is the truth, and what travels is what was *done* ("add a round
|
||
with these points"), not whole sheets, so two people entering a round at once
|
||
get two rounds rather than one overwriting the other. A guest's own change shows
|
||
immediately and is laid over whatever the host sends until the host confirms it.
|
||
**Sync** asks the host for the sheet as it stands, and happens by itself when the
|
||
app comes back to the front. The host keeps the sheet as its own saved one;
|
||
a guest's view is not saved, and leaving puts them back on their own sheet. Only
|
||
the host can start a new game.
|
||
|
||
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.
|
||
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
|
||
the computer appears to think.
|
||
- **Updates** — check on launch, or check on demand. **There is no channel to
|
||
choose**: a build updates from its own channel and no other. A production
|
||
install is offered production builds from `main`; a dev install is offered dev
|
||
builds from `dev`. The settings screen states which one is running rather than
|
||
offering a switch.
|
||
|
||
The check on launch **only ever speaks when there is a build to install**. It
|
||
runs behind the splash and waits for it to clear, and an app that is already
|
||
current — or a phone that cannot reach GitHub — says nothing, because opening
|
||
the app was not a question. The button in Settings is a question, so it
|
||
answers every time, including "you're on the latest" and whatever went wrong.
|
||
|
||
Wanting both means installing both — they are separate apps and each updates
|
||
itself, 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
|
||
drawable, so it stays sharp at every density and needs no PNGs.
|
||
|
||
Two honest caveats:
|
||
|
||
- **The sett is the published threadcount: `K/4 R32 K32 Y/4`.** The slashes are
|
||
pivots, so `WallaceSett` holds the half-sett and reflects it out to
|
||
`K4 R32 K32 Y4 K32 R32` — 136 threads, about half black, half red, with a
|
||
yellow line worth 3%. Two details define the pattern and are easy to get
|
||
backwards: the yellow overstripe runs down the *centre of a wide black band*,
|
||
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. 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
|
||
[docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and
|
||
must stay with the font — the OFL requires it to be distributed alongside.
|
||
|
||
## Screen sizes
|
||
|
||
There are no fixed orientations or hard-coded card sizes. Layouts measure the
|
||
space they are given: the menu grid reflows by width, the Sequence board is
|
||
always square and sized to fit whatever is left after the hand, and card widths
|
||
are a fraction of the smaller screen dimension. Rotation is handled inside the
|
||
activity, so a hand is not lost when the device turns.
|
||
|
||
The Ta Yü board is the tightest fit of the lot — 18 × 18 cells comes out under
|
||
20dp a cell on a phone — which is why laying a tile there is a tap to line the
|
||
placement up and a button to commit it, rather than a single tap on a target
|
||
smaller than a fingertip.
|
||
|
||
## Building
|
||
|
||
```sh
|
||
./gradlew assembleDebug # APK at app/build/outputs/apk/debug/
|
||
./gradlew testDebugUnitTest # rules engines and AI
|
||
```
|
||
|
||
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
|
||
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
|
||
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
||
`gradle.properties`, currently **1.3**.
|
||
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
||
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
||
|
||
### Hosting: plg.wallacenet.ca
|
||
|
||
Both channels are also deployed to one Cloudflare Pages project (`plg`), served at
|
||
`https://plg.wallacenet.ca`:
|
||
|
||
```
|
||
/ landing page (reads both latest.json files)
|
||
/prod/latest.json {tag, apkName, apkUrl, channel} — what the updater reads
|
||
/prod/ProLibertateGames.apk, /prod/ProLibertateGames-v<series>.<n>.apk
|
||
/dev/... same, for the dev channel
|
||
```
|
||
|
||
Each workflow's `deploy-pages` job runs `.github/scripts/stage-pages-site.sh`,
|
||
which stages its own channel and re-fetches the other channel from the live site,
|
||
because a Pages deploy replaces everything. The two share a concurrency group so
|
||
they cannot race. Needs repo secrets `CLOUDFLARE_API_TOKEN` (Account → Cloudflare
|
||
Pages → Edit) and `CLOUDFLARE_ACCOUNT_ID`. Pages caps files at 25 MiB.
|
||
|
||
**First deploy:** the site does not exist yet, so the domain answers 403 (Cloudflare error 1014) and the carry-over fetch would abort. Dispatch the workflow once with `first_deploy` ticked, then attach `plg.wallacenet.ca` as a custom domain on the new `plg` Pages project. Later runs need no input.
|
||
|
||
The in-app updater reads these `latest.json` files. GitHub Releases are still
|
||
published for one transition deployment so installs that predate this change can
|
||
fetch it; after that, remove the GitHub release steps from both workflows.
|
||
|
||
### Installing without the app
|
||
|
||
The newest production build is always at **https://plg.wallacenet.ca/prod/ProLibertateGames.apk** (dev: `/dev/ProLibertateGames.apk`). The GitHub link below remains during the transition:
|
||
|
||
**https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk**
|
||
|
||
Every release carries the same APK twice — once named for its version, and once
|
||
as plain `ProLibertateGames.apk`, because GitHub's `/releases/latest/download/`
|
||
redirect needs a file name that stays put. The in-app updater deliberately picks
|
||
the versioned copy, so an installed build can always be traced back to its tag.
|
||
|
||
There is no equivalent link for the dev channel: `/releases/latest` skips
|
||
prereleases by design, and that is exactly what keeps a production install from
|
||
being handed a dev build. A dev build is reached by the in-app updater of a dev
|
||
install — which looks at the prerelease list rather than that endpoint — or from
|
||
the releases page.
|
||
|
||
### The version series
|
||
|
||
A version is **`<series>.<run number>`**. The series is set by hand, once, in
|
||
`gradle.properties`:
|
||
|
||
```properties
|
||
plgVersionSeries=1.3
|
||
```
|
||
|
||
Both workflows read that same property when they name a release tag, and the
|
||
build reads it when it stamps the APK, so the tag and what is inside it cannot
|
||
name different versions. Bumping a series is a one-line change to that file and
|
||
nothing else.
|
||
|
||
The number after it is the CI run number and keeps climbing on its own, which is
|
||
what makes a series bump safe: `versionCode` is the run number *alone*, so
|
||
going from 1.0 to 1.1 never looks like a downgrade to Android and never
|
||
interrupts an OTA update. The series is a name for people; the run number is
|
||
what the machines compare.
|
||
|
||
### Update channels
|
||
|
||
Both pipelines publish, to two separate channels:
|
||
|
||
| Channel | Published by | Tag | Marked |
|
||
| --- | --- | --- | --- |
|
||
| Production | `release.yml`, push to `main` | `v<series>.<n>` | release |
|
||
| Dev | `ci.yml`, any build of `dev` | `v<series>.<n>-dev` | prerelease |
|
||
|
||
Dev builds are published as **prereleases** deliberately: GitHub's "latest
|
||
release" endpoint skips prereleases, so a production-channel install can never
|
||
be handed a dev build even by accident. Pull requests build and check but never
|
||
publish, so a PR from a fork cannot ship anything.
|
||
|
||
**Both pipelines gate publishing on the branch rather than on what triggered
|
||
the build**, so a manual run from the Actions tab publishes exactly as a push
|
||
does — useful when a push cannot fire the workflow itself, which is the case
|
||
for anything pushed by an automation token. The safety property is unchanged,
|
||
because publishing still happens only after that same job has built, tested and
|
||
linted clean. A pull request is still excluded without having to be named: a PR
|
||
build runs against `refs/pull/<n>/merge`, never `refs/heads/dev`.
|
||
|
||
In `ci.yml` the `PLG_CHANNEL` stamp is gated on the same condition as the
|
||
publish steps, and the two have to stay in step. Stamping a build that then
|
||
ships, or shipping one that was left unstamped, both end with an APK claiming
|
||
`versionCode` 1 — which the updater can never see as an upgrade over anything.
|
||
|
||
`release.yml` refuses to run off any branch but `main`, so a manual dispatch
|
||
aimed elsewhere does nothing rather than shipping unreviewed code.
|
||
|
||
**The two channels have independent version sequences** — a build's `versionCode`
|
||
is the run number of the workflow that produced it, and the two workflows count
|
||
separately. Dev is on 69 while production is on 40, so the numbers say nothing
|
||
across the divide.
|
||
|
||
That is why **an install only ever updates within its own channel**, and why
|
||
there is no setting for it. The app reads the `-dev` suffix in its own
|
||
`versionName` to know which line it is on, asks only that channel, and refuses
|
||
anything that comes back belonging to the other one — checked again at the point
|
||
of decision rather than trusted from the fetch. The rule is a pure function,
|
||
`verdictFor` in `update/UpdatePolicy.kt`, with no Android in it, so it is
|
||
unit-tested on the JVM like the rules engines are.
|
||
|
||
### 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.
|
||
|
||
Because they are separate apps, having both is a matter of installing both, and
|
||
each then updates itself down its own line. Nothing in the app moves a phone
|
||
from one channel to the other: the settings screen states which build is
|
||
running and where its updates come from, and that is all it does.
|
||
|
||
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
|
||
for a signature mismatch.
|
||
|
||
## Rules
|
||
|
||
The exact rules implemented, and the options that vary them, are documented per
|
||
game:
|
||
|
||
- [Euchre](docs/RULES-euchre.md)
|
||
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
|
||
- [Kaiser](docs/RULES-kaiser.md)
|
||
- [President](docs/RULES-president.md)
|
||
- [Golf](docs/RULES-golf.md)
|
||
- [Wizard](docs/RULES-wizard.md)
|
||
- [Crazy 8s](docs/RULES-crazy8s.md)
|
||
- [Cribbage](docs/RULES-cribbage.md) — two, three or four, and **note that four
|
||
play in partnerships pegging on one score**
|
||
- [Chess](docs/RULES-chess.md)
|
||
- [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings**
|
||
- [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube**
|
||
- [Mastermind](docs/RULES-mastermind.md) — played as a duel: each player sets
|
||
the code the other has to break
|
||
- [Nine Men's Morris](docs/RULES-morris.md)
|
||
- [Pirates and Bulgars](docs/RULES-pirates.md) — **note the caveats about the
|
||
reconstruction**: the book the theme comes from could not be obtained, so the
|
||
document says which rules are attested and which were chosen
|
||
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
||
the game is out of print and the rulebook could not be obtained, so the
|
||
document says which parts are attested and which were derived
|
||
|
||
## Layout
|
||
|
||
```
|
||
game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||
cards/ deck, suits, ranks
|
||
engine/ GameRules / GameAi contracts, seats, table config
|
||
euchre/ Euchre model, rules, AI
|
||
kaiser/ Kaiser model, bidding, rules, AI
|
||
sequence/ Sequence model, board, rules, AI
|
||
president/ President model, rules, AI
|
||
golf/ Golf model, scoring, rules, AI
|
||
wizard/ Wizard model, rules, AI
|
||
crazyeights/ Crazy 8s model, rules, AI
|
||
cribbage/ Cribbage the crib, the play, the show, and a counting AI
|
||
chess/ Chess model, FEN, move generation, search
|
||
morris/ Nine Men's Morris board geometry, mills, rules, search
|
||
checkers/ Checkers board, compulsory captures, crowning, search
|
||
backgammon/ Backgammon points, dice, the maximal-roll rule, turn search
|
||
mastermind/ Mastermind codes, scoring, and a code breaker
|
||
pirates/ Pirates and Bulgars — the cross board, the hunt, 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/ Cloudflare Pages OTA updater, and the channel rule — pure, tested
|
||
ui/ Compose screens
|
||
```
|
||
|
||
Keeping `game/` free of Android types is deliberate: the same code drives the
|
||
AI, the host's authoritative state and the JVM unit tests.
|