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]>
358 lines
18 KiB
Markdown
358 lines
18 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, Hearts, Klondike, FreeCell, Spider, Pyramid |
|
||
| **Table** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Peg Solitaire, Yahtzee, 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.
|