Build and Release APK / Build APK and publish release (push) Canceled after 0s
Import of ProLibertateGames (upstream main, GPL-3.0) rebranded for Wallacenet and repointed at git.wallacenet.ca. - Identity: applicationId ca.wallacenet.pilgrimsofkatan (+ .dev for the dev channel), launcher names "Pilgrims of Katan" / "Pilgrims of Katan DEV", neutral hexagon branding replacing the Wallace tartan. Uncial Antiqua keeps its OFL licence, and the GPL-3.0 LICENSE plus upstream attribution stay. - OTA: Updater.kt now reads this repo's own Gitea releases (/api/v1/repos/devadmin/PilgrimsOfKatan/releases...) and BuildConfig. OTA_ENABLED is on; Gitea answers the same JSON shape the code already parsed. - Build numbers: versionCode = CI run number + plgVersionRunOffset (1000), with the tag and versionName derived from the same figure. The offset is permanent, because Gitea Actions restarts run numbers at 1 and a reset would otherwise make Android reject the next build as a downgrade. - CI: ci.yml publishes a dev prerelease from dev, release.yml a production release from main, both through scripts/publish-release.sh (Gitea REST API, runnable by hand until an Actions runner is registered). - Docs: docs/CI.md records the channel, numbering and runner decisions; docs/README.md seeds the per-game RULES-* conventions; CLAUDE.md carries the agent ground rules. Verified: ./gradlew assembleDebug and testDebugUnitTest green on JDK 17 / compileSdk 34, both variants stamped, installing side by side under the new identity. Co-Authored-By: Paperclip <[email protected]>
358 lines
17 KiB
Markdown
358 lines
17 KiB
Markdown
# Pilgrims of Katan
|
||
|
||
A collection of card and board games for Android, playable against the computer
|
||
or against other people over Wi-Fi (LAN).
|
||
|
||
Derived from [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
|
||
by adrianoftyriel, used under the GNU GPL v3 — see [LICENSE](LICENSE). Game
|
||
rules documentation lives under [docs/](docs/README.md); build, CI and release
|
||
decisions live in [docs/CI.md](docs/CI.md). Catan itself arrives in later work;
|
||
this app ships the reference catalogue unchanged as the regression safety net.
|
||
|
||
## 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. The channel is this
|
||
project's own Gitea releases ([docs/CI.md](docs/CI.md)), and **there is no
|
||
channel to choose**: a build updates from its own channel and no other. The
|
||
settings screen states which one is running rather than offering a switch.
|
||
`BuildConfig.OTA_ENABLED` is the kill switch, not a preference.
|
||
|
||
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 the release server — 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
|
||
|
||
Neutral, original, and deliberately *not* Catan's: no third-party marks or
|
||
trade dress ship in this app (rebrand scope: WALA-118). The launcher icon is a
|
||
hexagonal ring around a solid core — a game tile, not any game's logo — drawn
|
||
as vector drawables so it stays sharp at every density and needs no PNGs. The
|
||
app opens on the brand field fading to its deep shade, the name set in a Celtic
|
||
uncial hand for two seconds before it fades into the menu.
|
||
|
||
A dev build wears the **dev green** field where production wears **slate
|
||
blue**, and installs as *Pilgrims of Katan 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 palette lives
|
||
in `ui/theme/Theme.kt` (`BrandField`, `BrandDeep`, `BrandAccent`) and the two
|
||
channel colours are generated per build by `resValue` in `app/build.gradle.kts`
|
||
— the icon vectors reference `@color/brand_field` rather than a literal, which
|
||
is how one set of vectors serves both colourways.
|
||
|
||
Two honest caveats:
|
||
|
||
- **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.
|
||
- **The upstream's clan tartan is gone; its code is not.** The Wallace tartan
|
||
was the upstream's brand and left with the rebrand. The GPL-licensed history
|
||
that produced this app remains in git, and the source stays at
|
||
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
|
||
as the `upstream` remote.
|
||
|
||
## 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
|
||
`ca.wallacenet.pilgrimsofkatan.dev` under the name *Pilgrims of Katan DEV* on
|
||
the dev green field, so it goes on beside a production copy rather than over it.
|
||
Set `PLG_CHANNEL=production` to build the production package
|
||
(`ca.wallacenet.pilgrimsofkatan`, slate blue).
|
||
|
||
## CI
|
||
|
||
Full decision record: [docs/CI.md](docs/CI.md).
|
||
|
||
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
|
||
requests into `dev` or `main`, and publishes a dev prerelease from `dev`.
|
||
- `.github/workflows/release.yml` — builds and publishes the production release
|
||
on push to `main`, or by hand (`workflow_dispatch`).
|
||
- Both publish through [scripts/publish-release.sh](scripts/publish-release.sh)
|
||
against Gitea's REST API, which is also runnable by hand. **No Actions runner
|
||
is registered on the instance yet**, so until one is, the local build is the
|
||
guaranteed path (`./gradlew assembleDebug && ./gradlew testDebugUnitTest`)
|
||
and releases go out via the script.
|
||
|
||
### The version series
|
||
|
||
A version is **`<series>.<build number>`**. Both values are set by hand, once,
|
||
in `gradle.properties`:
|
||
|
||
```properties
|
||
plgVersionSeries=1.4
|
||
plgVersionRunOffset=1000
|
||
```
|
||
|
||
Both workflows read those same properties when they name a release tag, and the
|
||
build reads them when it stamps the APK, so the tag and what is inside it cannot
|
||
name different versions. Changing either is a one-line change to that file and
|
||
nothing else — but they are not equivalent:
|
||
|
||
- The **series** is a name for people. Bumping it changes nothing about
|
||
ordering.
|
||
- The **build number** is what the machines compare: `versionCode`, the tag's
|
||
trailing number, and the number in `versionName`, all the same figure —
|
||
`CI run number + plgVersionRunOffset`. Because the offset never changes and
|
||
the run number only climbs, going from 1.4 to 1.5 never looks like a downgrade
|
||
to Android and never interrupts an OTA update.
|
||
|
||
The offset exists because Gitea Actions restarts run numbers at 1 for each
|
||
workflow: without a floor, the first release of a fresh instance would collide
|
||
with the `versionCode 1` every unstamped local build carries, and a counter
|
||
reset would make Android refuse the next build outright. Full reasoning in
|
||
[docs/CI.md](docs/CI.md).
|
||
|
||
### Update channels
|
||
|
||
`ci.yml` stamps builds of `dev` as dev-channel, `release.yml` stamps `main` as
|
||
production, so an APK's identity is decided where it is built. Publishing goes
|
||
to this project's Gitea releases, and the rules it keeps:
|
||
|
||
- production comes only from `main` (`release.yml` refuses any other branch);
|
||
- dev prereleases are marked so the "latest release" endpoint can never hand one
|
||
to a production install;
|
||
- pull requests build and check but never publish.
|
||
|
||
**The two channels have independent version sequences** — a build's `versionCode`
|
||
is the build number of the workflow that produced it (run number + offset), and
|
||
the two workflows count separately, 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`,
|
||
`ca.wallacenet.pilgrimsofkatan.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` | `ca.wallacenet.pilgrimsofkatan` | `ca.wallacenet.pilgrimsofkatan.dev` |
|
||
| Launcher name | Pilgrims of Katan | Pilgrims of Katan DEV |
|
||
| Brand field | slate blue | deep 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`,
|
||
`brand_field` and `splash_background` are generated by the build and are
|
||
deliberately *not* in `res/values/` — the icon vectors reference
|
||
`@color/brand_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 `BrandField` picks the matching Compose colour 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 (index: [docs/README.md](docs/README.md)):
|
||
|
||
- [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/ OTA updater against this repo's Gitea releases, 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.
|