Author SHA1 Message Date
devadmin a778e4732f Merge pull request 'Merge feature/catan-engine into dev (Katan rules engine + AI, WALA-119)' (#2) from catan-into-dev into dev
CI / Build and check (push) Successful in 15m38s
CI / Deploy to Cloudflare Pages (push) Failing after 13s
Reviewed-on: #2
2026-10-14 15:57:04 -06:00
Claude fb59372802 Restore the Katan catalogue entry the engine depends on
CI / Build and check (pull_request) Successful in 13m46s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
The Option 2 migration dropped Gitea's 5c797c8, which added GameCatalog.CATAN
and a Katan entry (available = false: Coming soon on dev, hidden from
production). CatanRules.kt references GameCatalog.CATAN, so the merge did not
compile without it. Taken verbatim from feature/catan-engine, with the
matching CatalogTest assertions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 20:55:38 +00:00
Claude 653e7a17a3 Merge feature/catan-engine into dev
Brings in the Katan rules engine, board, model and AI (WALA-119), its 51 unit
tests and docs/RULES-catan.md. Only conflict was docs/README.md, which the
GitHub-lineage migration had dropped: kept the engine branch's index and
pointed the CI link (docs/CI.md no longer exists) at the root README.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 20:50:28 +00:00
rootandClaude Sonnet 5.5 33a253d80c CI: keep JVM crash logs and try C1-only JIT on the runner
CI / Build and check (push) Successful in 13m29s
CI / Deploy to Cloudflare Pages (push) Successful in 45s
Runs on the Gitea runner crash intermittently on three JDK vendors. Upload
hs_err/replay logs on failure so the failing frame survives the job
container, and drop C2 (TieredStopAtLevel=1) for the Gradle and Kotlin
daemons to test whether the optimising JIT is the trigger.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YGRgChhwbwWr5yYrHTd9pL
2026-10-14 14:34:10 -06:00
Claude 4ffe817d5f Stage script: use node instead of jq
CI / Build and check (push) Failing after 8m40s
CI / Deploy to Cloudflare Pages (push) Skipped
The Gitea runner's container image has node but no jq, so the first dev deploy
died with exit 127 in stage-pages-site.sh. Same behaviour, tested against mock
servers for: other channel published, 200-with-HTML, 404, and unreachable.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 20:13:21 +00:00
Claude fd352b4d14 Back to Temurin JDK 17 now that api.adoptium.net is reachable from the runner
CI / Build and check (push) Successful in 17m2s
CI / Deploy to Cloudflare Pages (push) Failing after 6s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 19:43:13 +00:00
Claude 7b5b041c42 Test: wait for the guest's sheet before comparing it
CI / Build and check (push) Failing after 7m34s
CI / Deploy to Cloudflare Pages (push) Skipped
The sheet arrives over the socket just after connected flips, so on a slower
runner the comparison raced it (ScorekeeperSessionTest, line 167). Same
pattern the sibling test already uses.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 17:23:42 +00:00
Claude e63c2cf7ac Use Zulu JDK 17: the Gitea runner cannot reach api.adoptium.net
setup-java's temurin distribution resolves versions through api.adoptium.net,
which fails TLS from the runner (EPROTO) while api.azul.com is reachable.
Any JDK 17 builds the project.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 17:02:54 +00:00
Claude 2314f5f842 Temporary: probe JDK hosts 2026-10-14 17:01:57 +00:00
Claude 2a193f8d5b Temporary: runner egress probe (to be removed) 2026-10-14 17:01:05 +00:00
Claude ad30ab9dea Move CI to Gitea: run-number offset, Cloudflare-only publishing
- plgVersionRunOffset=1000: stamped build number is run number + offset, so
  Gitea's restarted run counter cannot produce a versionCode below an
  installed one (GitHub reached prod 30, dev ~172). Tags and versionName use
  the same number.
- Drop the GitHub release steps; plg.wallacenet.ca is the only channel.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 17:00:01 +00:00
Claude 4c603f6d3d Adopt the GitHub lineage; supersede Gitea-only bootstrap commits
Keeps Gitea's history reachable (also preserved as gitea-pre-github-main)
while the tree stays the GitHub one: package org.prolibertate.games, OTA
from plg.wallacenet.ca. Gitea's package rename and Gitea-releases updater
are intentionally not carried over.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 16:59:38 +00:00
adrianoftyriel 0927660c15 Merge pull request #43 from adrianoftyriel/dev
Dev
2026-10-14 10:42:42 -06:00
adrianoftyriel 5d0f95361f Merge pull request #42 from adrianoftyriel/cf-real-404
Fix Pages carry-over: real 404 and tolerate 200-with-HTML
2026-10-14 10:18:01 -06:00
Claude 6fbe63f787 Fix carry-over on Pages: add 404.html and tolerate 200-with-HTML
Pages serves index.html with 200 for unknown paths when there is no
404.html, so a never-published channel looked published and jq failed on
the HTML. Add a real 404 page and treat a 200 without a latest.json as
absent.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 16:08:28 +00:00
adrianoftyriel 12d20565bd Merge pull request #41 from adrianoftyriel/cf-treat-1014-as-absent
Treat Cloudflare 1014 as 'channel not published yet'
2026-10-14 10:02:26 -06:00
Claude 2df140a3c1 Treat Cloudflare error 1014 as 'channel not published yet'
A push-triggered first deploy cannot set the first_deploy input, and the
domain answers 403/1014 until the Pages project exists. 1014 means nothing
exists to preserve, so handle it like a 404. Other 403s still abort.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 15:53:15 +00:00
adrianoftyriel 0b74b1b2b1 Merge pull request #40 from adrianoftyriel/cf-first-deploy-fix
Fix first Cloudflare deploy: add first_deploy dispatch input
2026-10-14 09:39:58 -06:00
Claude 4369d15cec Add first_deploy dispatch input so the first Cloudflare deploy can bootstrap
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
2026-10-14 15:39:03 +00:00
adrianoftyriel 1e9908569d Merge pull request #39 from adrianoftyriel/cloudflare-pages-deploy
Publish dev and prod builds to plg.wallacenet.ca (Cloudflare Pages)
2026-10-14 09:32:31 -06:00
Claude c0fc4fe7d6 Publish dev and prod builds to plg.wallacenet.ca (Cloudflare Pages)
Both workflows gain a deploy-pages job that stages their channel into one
Pages site, carrying the other channel over from the live site. The in-app
updater now reads /prod|/dev/latest.json from plg.wallacenet.ca instead of
the GitHub API. GitHub releases are still published for one transition
deployment so existing installs can fetch the new updater.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 15:20:47 +00:00
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
24 changed files with 588 additions and 1347 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Assembles the Cloudflare Pages site for plg.wallacenet.ca into ./pages-out.
#
# Usage: stage-pages-site.sh <prod|dev> <tag> <apk-path>
#
# A Pages deploy replaces the whole site, and both channels live in one site,
# so this stages the channel being published and then re-fetches the *other*
# channel's current files from the live site so the deploy does not erase them.
set -euo pipefail
channel="$1"; tag="$2"; apk="$3"
base="${PAGES_BASE_URL:-https://plg.wallacenet.ca}"
out="pages-out"
limit=$((25 * 1024 * 1024)) # Cloudflare Pages: 25 MiB per file
case "$channel" in prod) other=dev ;; dev) other=prod ;; *) echo "bad channel: $channel" >&2; exit 2 ;; esac
rm -rf "$out"
mkdir -p "$out/$channel" "$out/$other"
cp site/index.html site/404.html site/_headers "$out/"
name="$(basename "$apk")"
cp "$apk" "$out/$channel/$name"
cp "$apk" "$out/$channel/ProLibertateGames.apk"
# node rather than jq: the Gitea runner's image has node but not jq.
node -e 'console.log(JSON.stringify({tag: process.argv[1], apkName: process.argv[2], apkUrl: process.argv[3], channel: process.argv[4]}, null, 2))' \
"$tag" "$name" "$base/$channel/$name" "$channel" > "$out/$channel/latest.json"
# PLG_SKIP_OTHER=1 is for the very first deploy, when the site does not exist
# yet and the domain answers 403 (Cloudflare error 1014) instead of 404.
if [ -n "${PLG_SKIP_OTHER:-}" ]; then
echo "PLG_SKIP_OTHER set; not carrying over $other."
rm -rf "$out/$other"
find "$out" -type f | sort
exit 0
fi
# Carry the other channel over. 404 means it was never published; any other
# failure must stop the deploy rather than silently dropping that channel.
code="$(curl -sS -o "$out/$other/latest.json" -w '%{http_code}' "$base/$other/latest.json" || true)"
: "${code:=000}"
# Cloudflare error 1014 (403 + "error code: 1014") means the domain's CNAME
# points at a Pages project that does not exist yet, so there is nothing to
# preserve — the same as a 404, and what a push-triggered first deploy sees.
if [ "$code" = 403 ] && grep -q 'error code: 1014' "$out/$other/latest.json" 2>/dev/null; then
code=404
fi
if [ "$code" = 404 ]; then
echo "No $other channel published yet; skipping."
rm -rf "$out/$other"
elif [ "$code" = 200 ] && ! other_name="$(node -p 'const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if (!j.apkName) process.exit(1); j.apkName' "$out/$other/latest.json" 2>/dev/null)"; then
# A 200 that is not our JSON: Pages without a 404.html answers unknown paths
# with index.html. Nothing is published on that channel.
echo "No $other channel published yet (200 but no latest.json); skipping."
rm -rf "$out/$other"
elif [ "$code" = 200 ]; then
curl -fsS -o "$out/$other/$other_name" "$base/$other/$other_name"
cp "$out/$other/$other_name" "$out/$other/ProLibertateGames.apk"
else
echo "Fetching $other/latest.json returned HTTP $code; refusing to deploy." >&2
exit 1
fi
find "$out" -name '*.apk' -size +"${limit}c" -print | grep . >&2 && {
echo "APK exceeds Cloudflare Pages' 25 MiB per-file limit." >&2; exit 1; } || true
find "$out" -type f | sort
+91 -37
View File
@@ -1,27 +1,28 @@
name: CI name: CI
# Pre-merge checks. Runs on the dev branch and on every pull request aimed at # Pre-merge checks. Runs on the dev branch and on every pull request aimed at
# dev or main, so breakage is caught before it reaches main. # dev or main, so breakage is caught before it reaches main and triggers a
# release.
# #
# On a push to dev it also publishes the build as a dev-channel prerelease on # On a push to dev it also publishes the build as a dev-channel prerelease, so
# this Gitea instance, so the in-app updater can offer it to anyone on the dev # the in-app updater can offer it to anyone who has switched to that channel.
# channel. Prereleases are used deliberately: the "latest release" endpoint # Prereleases are used deliberately: GitHub's "latest release" endpoint skips
# skips them, so a production-channel user never sees a dev build. Pull # them, so a production-channel user never sees a dev build. Pull requests only
# requests only build and check — they never publish, which keeps a PR from a # build and check — they never publish, which keeps a PR from a fork from
# fork from shipping anything. # shipping anything.
#
# These files live in .github/workflows/ on purpose: Gitea Actions reads that
# path as well as .gitea/workflows/, so one file serves both. Publishing goes
# through Gitea's REST API (the same routes the app's updater reads) rather
# than the `gh` CLI, which a runner image need not carry.
on: on:
push: push:
branches: [ dev ] branches: [ dev ]
pull_request: pull_request:
branches: [ main, dev ] branches: [ main, dev ]
workflow_dispatch: workflow_dispatch:
inputs:
first_deploy:
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
type: boolean
default: false
# Needed by the job token to create the release and attach the APK. # Needed to publish the dev-channel prerelease below.
permissions: permissions:
contents: write contents: write
@@ -34,6 +35,8 @@ jobs:
build: build:
name: Build and check name: Build and check
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.stage.outputs.version }}
env: env:
# Stamp the APK as a dev-channel build exactly when it is a build of the # Stamp the APK as a dev-channel build exactly when it is a build of the
# dev branch, which is exactly when it can be published below. Pull # dev branch, which is exactly when it can be published below. Pull
@@ -41,7 +44,10 @@ jobs:
# <series>.0-dev: they are never published and must not claim a channel # <series>.0-dev: they are never published and must not claim a channel
# version. # version.
# #
# This condition has to stay in step with the publish steps' own. # This condition has to stay in step with the publish steps' own. Stamping
# a build the publish steps would ship, or shipping one this left
# unstamped, both end the same way — an APK claiming versionCode 1, which
# the updater can never see as an upgrade over anything.
PLG_CHANNEL: ${{ github.ref == 'refs/heads/dev' && 'dev' || '' }} PLG_CHANNEL: ${{ github.ref == 'refs/heads/dev' && 'dev' || '' }}
steps: steps:
- name: Checkout - name: Checkout
@@ -69,17 +75,30 @@ 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
- name: Upload JVM crash logs
# A JVM crash writes hs_err_pid*.log into the workspace, which dies
# with the job container; keep it so the failing frame can be read.
if: failure()
uses: actions/upload-artifact@v4
with:
name: ProLibertateGames-jvm-crash
path: |
hs_err_pid*.log
replay_pid*.log
if-no-files-found: ignore
retention-days: 14
- name: Upload lint and test reports - name: Upload lint and test reports
# Reports are most useful precisely when a step above failed. # Reports are most useful precisely when a step above failed.
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
@@ -89,39 +108,74 @@ jobs:
# branch itself, after it has built, tested and linted clean above. # branch itself, after it has built, tested and linted clean above.
# #
# Gated on the branch alone rather than on the event, which is how # Gated on the branch alone rather than on the event, which is how
# release.yml gates main. A pull request is still excluded without # release.yml has always gated main. A pull request is still excluded
# needing to name it: a PR build runs against refs/pull/N/merge, never # without needing to name it: a PR build runs against refs/pull/N/merge,
# refs/heads/dev, so a fork still cannot ship anything. # never refs/heads/dev, so a fork still cannot ship anything.
- name: Stage dev APK - name: Stage dev APK
id: stage id: stage
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev'
run: | run: |
set -euo pipefail set -euo pipefail
# The series and the offset come from gradle.properties, which is # The series comes from gradle.properties, which is the same file the
# the same file the build read when it stamped the APK — so the tag # build read when it stamped the APK — so the tag and what is inside
# and what is inside it cannot name different versions. The number # it cannot name different versions.
# is run number + offset, exactly the arithmetic build.gradle.kts
# did for versionCode.
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$series" test -n "$series"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$offset" test -n "$offset"
stamp="$(( ${{ github.run_number }} + offset ))" # Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
version="v${series}.${stamp}-dev" version="v${series}.$(( ${{ github.run_number }} + offset ))-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"
- name: Publish dev channel prerelease - name: Upload staged dev APK for the Pages deploy
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev'
uses: actions/upload-artifact@v4
with:
name: plg-dev-staged
path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error
retention-days: 1
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
# the only release channel: the in-app updater reads latest.json from there.
#
# Both workflows deploy into one Pages site, and a deploy replaces the whole
# site, so they share a concurrency group and never run at the same time.
deploy-pages:
name: Deploy to Cloudflare Pages
needs: build
if: github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
concurrency:
group: plg-pages-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download staged APK
uses: actions/download-artifact@v4
with:
name: plg-dev-staged
path: staged
- name: Assemble site
env: env:
# The job's built-in token: contents: write is declared above. PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: .github/scripts/stage-pages-site.sh dev "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
RELEASE_TAG: ${{ steps.stage.outputs.version }}
RELEASE_APK: ${{ steps.stage.outputs.apk }} - name: Deploy
RELEASE_KIND: prerelease env:
RELEASE_TARGET: ${{ github.sha }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# One script, also runnable by hand — see docs/CI.md. CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: ./scripts/publish-release.sh run: |
set -euo pipefail
# Creates the project on first run; fails harmlessly if it exists.
npx --yes wrangler@3 pages project create plg --production-branch main || true
# --branch main makes this the production deployment, which is what
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
+68 -32
View File
@@ -1,17 +1,18 @@
name: Build and Release APK name: Build and Release APK
# Production-channel build. Runs on a push to main, or by hand # Build an APK and publish it as a GitHub Release whenever commits land on main
# (workflow_dispatch), and publishes to this Gitea instance's releases — which # (for example when a pull request is merged). Can also be run manually.
# is the channel the in-app updater reads. See docs/CI.md.
#
# Publishing goes through Gitea's REST API rather than the `gh` CLI, which a
# runner image need not carry.
on: on:
push: push:
branches: [ main ] branches: [ main ]
workflow_dispatch: workflow_dispatch:
inputs:
first_deploy:
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
type: boolean
default: false
# Needed by the job token to create the release and attach the APK. # Allow the built-in token to create releases and upload the APK.
permissions: permissions:
contents: write contents: write
@@ -23,13 +24,16 @@ concurrency:
jobs: jobs:
build: build:
name: Build APK and publish release name: Build APK and publish release
# workflow_dispatch can be aimed at any branch from the Actions UI. The # workflow_dispatch can be aimed at any branch from the Actions UI. Releases
# production stamp belongs to main alone — anything else is a no-op rather # are what the in-app updater installs, so only main may produce one —
# than shipping unreviewed code to everybody. # anything else is a no-op rather than shipping unreviewed code to everyone.
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.stage.outputs.version }}
env: env:
# Stamps the APK as a production-channel build. # Stamps the APK as a production-channel build, versioned with the run
# number that matches the tag published below.
PLG_CHANNEL: production PLG_CHANNEL: production
steps: steps:
- name: Checkout - name: Checkout
@@ -45,44 +49,76 @@ jobs:
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v4 uses: android-actions/setup-android@v4
- name: Build production APK - name: Build debug APK
run: ./gradlew assembleDebug --no-daemon --stacktrace run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Stage APK - name: Stage APK for release
id: stage id: stage
run: | run: |
set -euo pipefail set -euo pipefail
# The series and the offset come from gradle.properties, which is # The series comes from gradle.properties, which is the same file the
# the same file the build read when it stamped the APK — so the tag # build read when it stamped the APK — so the tag and what is inside
# and what is inside it cannot name different versions. The number # it cannot name different versions.
# is run number + offset, exactly the arithmetic build.gradle.kts
# did for versionCode.
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$series" test -n "$series"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$offset" test -n "$offset"
stamp="$(( ${{ github.run_number }} + offset ))" # Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
version="v${series}.${stamp}" version="v${series}.$(( ${{ github.run_number }} + offset ))"
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"
# The same build again under a name that never changes. GitHub's
# /releases/latest/download/<name> redirect needs a constant file
# name, so this second copy is what makes a permanent download link
# possible — the versioned copy stays as the traceable one.
cp "$src" "ProLibertateGames.apk"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT"
- 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: plg-prod-staged
path: ${{ steps.stage.outputs.apk }} path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error if-no-files-found: error
- name: Publish production release # Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
# the only release channel: the in-app updater reads latest.json from there.
#
# Both workflows deploy into one Pages site, and a deploy replaces the whole
# site, so they share a concurrency group and never run at the same time.
deploy-pages:
name: Deploy to Cloudflare Pages
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: plg-pages-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download staged APK
uses: actions/download-artifact@v4
with:
name: plg-prod-staged
path: staged
- name: Assemble site
env: env:
# The job's built-in token: contents: write is declared above. PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: .github/scripts/stage-pages-site.sh prod "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
RELEASE_TAG: ${{ steps.stage.outputs.version }}
RELEASE_APK: ${{ steps.stage.outputs.apk }} - name: Deploy
RELEASE_KIND: release env:
RELEASE_TARGET: ${{ github.sha }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# One script, also runnable by hand — see docs/CI.md. CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: ./scripts/publish-release.sh run: |
set -euo pipefail
# Creates the project on first run; fails harmlessly if it exists.
npx --yes wrangler@3 pages project create plg --production-branch main || true
# --branch main makes this the production deployment, which is what
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
+101 -76
View File
@@ -1,95 +1,120 @@
# Working on this repo (agent notes) > **Moved to Gitea.** CI now runs on Gitea Actions at
> `git.wallacenet.ca/devadmin/ProLibertateGames`, publishing to Cloudflare Pages
> (`plg.wallacenet.ca`) only. The sandbox notes below describe the earlier GitHub
> setup (no CI run from session pushes, `workflow_dispatch` as the workaround);
> re-verify them against Gitea before relying on them.
ProLibertateGames — Wallacenet's build of an Android collection of card and # Working on this repo with Claude
board games, playable against AI or over Wi-Fi (LAN). Upstream:
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
(GPL-3.0, kept as the `upstream` remote). Canonical repo:
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>.
## Ground rules ## Nothing a session does triggers CI. Dispatch it explicitly.
- **Licences travel.** `LICENSE` is GPL-3.0 and must stay. The Uncial Antiqua GitHub creates **no workflow run** for anything this sandbox does — pushing a
font ships with its OFL licence at `docs/licenses/UncialAntiqua-OFL.txt`; branch, opening a pull request, or merging one. All of it goes through the same
never delete or edit either. Attribution to the upstream lives in the README. integration, and GitHub suppresses event-triggered runs from it. The pull
- **No Catan trademarks or brand assets** — the one hard line. The app's own request will say it was opened by the repo owner; that is only how the UI
branding is the upstream's, restored (see README → Branding): tartan launcher attributes it, and it does not change the suppression.
icon and splash, Wallace clan red on production and hunting green on dev,
launcher names *Pro Libertate Games* / *Pro Libertate Games DEV*. The
installed identity is `ca.wallacenet.pilgrimsofkatan` (+ `.dev`).
- **The existing catalogue is the regression safety net.** Keep every game
green; new games (Katan) arrive as separate tasks, not edits to old ones.
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/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.
## Build This was measured, not assumed:
```sh | Action | Result |
export JAVA_HOME=... # JDK 17 | --- | --- |
export ANDROID_HOME=... # SDK with platform 34, build-tools 34 | Human pushes a Claude-authored commit (`d165e5ef`, `469c900e`) | CI runs |
./gradlew assembleDebug # dev-channel APK (default) | Session pushes `dev` / `main` (`43394b8`, `742add4`) | no run |
PLG_CHANNEL=production ./gradlew assembleDebug # production APK | Session opens a PR against `dev` (#1) | no run |
./gradlew testDebugUnitTest # JVM unit tests (rules/AI/net/score) | Session calls `workflow_dispatch` | **runs** |
./gradlew lintDebug # Android lint (CI runs it)
```
On the Wallacenet project workspace a ready toolchain sits at `workflow_dispatch` works because it creates a run directly instead of relying
`../.toolchain/` (`jdk17/`, `android-sdk/`) — export `JAVA_HOME` and on an event. It is the only mechanism available from a session, so every build
`ANDROID_HOME` to it rather than installing again. Both variants must build: has to be asked for by hand.
they install side by side (different applicationId, launcher name, colour).
## Channels and OTA ## Verify before merging, without publishing
One flag — `isDevBuild` in `app/build.gradle.kts` — drives applicationId, `ci.yml` builds, tests and lints on any ref, but its staging and publishing
launcher name and brand colour together. Never set them individually. steps are gated on `github.ref == 'refs/heads/dev'`. Dispatching it on a
**feature branch** therefore gives the full check — build, unit tests, Android
lint — and publishes nothing.
In-app OTA points at this repo's own Gitea releases So the working loop is:
(`/api/v1/repos/devadmin/PilgrimsOfKatan/releases…`) and is **on**:
`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
what publishing requires. Setting the flag to `false` is the kill switch.
## CI 1. Commit and push the feature branch.
2. Dispatch `ci.yml` on that branch. Wait for green. This is the real review
gate, and it is the only verification that exists (see below).
3. Open the PR through the API and merge it there, so the change is reviewable
and the merge commit is honest about what happened. Neither step builds
anything.
4. Dispatch the target branch's workflow to actually build and publish:
`ci.yml` on `dev`, `release.yml` on `main`.
`.github/workflows/` — Actions-compatible so the Gitea instance runs them. Skipping step 4 leaves a branch whose head was never built and no release.
`ci.yml`: build + test + lint + artifact, and a dev prerelease from `dev`.
`release.yml`: production release from `main`. Both publish through
`scripts/publish-release.sh` (Gitea REST API), which is also runnable by hand.
**No Actions runner is registered on the instance yet**, so the workflows are ## Merging is a shipping action
idle until one is — the local gradle commands above are the source of truth,
and CI green means those plus lint. Do not treat a workflow that has never run
as evidence.
## Code map Both long-lived branches publish, so a merge is a release, not just an
integration:
- `game/` — rules engines and AI, **pure Kotlin, no Android types**, unit - **`dev`** → `ci.yml` publishes a `v<series>.N-dev` **prerelease**, offered by the
tested. `Catalog.kt` is the registry (id, title, blurb, options, `available`). in-app updater to anyone on the Dev channel.
- `net/` — wire protocol, LAN transport (mDNS + TCP), lobby, host-authoritative - **`main`** → `release.yml` publishes a **production** release, which the
match driver; `score/` the scorekeeper sheet, also pure. updater installs for everyone on the Production channel.
- `ui/` — Compose screens; `ui/theme/Theme.kt` holds the brand palette
(`BrandField`/`BrandDeep`/`BrandAccent`) and table accents.
- `update/` — OTA policy (`UpdatePolicy.kt`, pure and tested) and the gated
fetch (`Updater.kt`).
- `settings/` — DataStore preferences.
Host is authoritative over the wire; clients send intents, host re-validates Confirm before shipping to `main`. Note that `release.yml` runs `assembleDebug`
against the rules. Keep `game/` free of Android imports — the JVM tests depend only — no tests, no lint — so a `main` release is only as verified as the `dev`
on it. build it replays.
## Git ## Gradle does not run in the sandbox
- Branches: `main` (production), `dev` (integration). Feature branches → PR `./gradlew` cannot resolve the Android plugin here: the proxy blocks
into `dev`. `dl.google.com` with a 403. No local build, no local test run, no local lint. A
- Commits by Paperclip agents end with dispatched CI run is the only way to find out whether a change compiles.
`Co-Authored-By: Paperclip <[email protected]>`.
- Pull upstream fixes with `git pull upstream` (the GitHub source); do not
point `origin` anywhere but this repo's Gitea URL.
## Legacy files ## A permanent fix, if you want one
`.claude/` and `.rtk/` are the upstream author's assistant tooling, harmless The suppression is a property of the sandbox's credential, so it cannot be
and left as inherited. This file supersedes the upstream's CLAUDE.md (which fixed from inside a session. Giving the environment a personal access token to
documented their GitHub sandbox; that history is still in git). push with would make pushes and PRs behave normally for everyone. Until then,
the dispatch step above is the workaround.
## Bash output is filtered before it reaches the model
`.claude/settings.json` registers a `PreToolUse` hook on `Bash` that rewrites
each command through [rtk](https://github.com/rtk-ai/rtk): `git status` runs as
`rtk git status`, and rtk compresses the output. Write commands normally — the
rewrite is transparent and costs nothing to invoke.
Measured on this repo rather than quoted from rtk's README:
| Command | Raw | Filtered | Saved |
| --- | --- | --- | --- |
| `git diff HEAD~3` | 521 KB | 28 KB | 95% |
| `grep -rn "fun " app/src/main` | 117 KB | 19 KB | 84% |
| `find . -name "*.kt"` | 7.7 KB | 1.7 KB | 79% |
| `git log -20` | 20 KB | 5.3 KB | 74% |
| `git diff --stat` | 4.0 KB | 4.0 KB | 1% |
| `ls` of a single package | 50 B | 62 B | −24% |
The saving is all in the large outputs, which is the point — a full diff of a
few commits is otherwise most of a context window. Already-compact output grows
slightly, because rtk adds a header; at those sizes it does not matter. `rtk
gain` reports what the current session has actually saved.
Two limits are worth knowing. The `Read`, `Grep` and `Glob` tools are not
`Bash`, so they never reach the hook and are unaffected; a deliberate `rg` or
`find` in a shell *is* filtered. And filtering never truncates a file: `cat`
becomes `rtk read`, which was checked byte-for-byte against this repo's longest
Kotlin source (`GameSetupScreen.kt`, 946 lines) and came back identical. Commit
messages survive the rewrite intact, quotes and colons included. To bypass
filtering for one command, run `rtk proxy <cmd>`.
## rtk is reinstalled at the start of every session
The container starts empty, so `.claude/hooks/session-start.sh` installs rtk
before the session does anything. It pins `RTK_VERSION`, because the installer
otherwise asks `api.github.com` for the latest tag and the proxy answers 403 for
every repository except this one — the lookup fails and takes the install with
it. Bump that variable by hand.
The hook is written not to matter when it fails. Outside the sandbox it installs
nothing and just prints the command to run, since dropping a binary into
someone's `~/.local/bin` uninvited is not its business; if the download fails it
says so and exits clean. With rtk absent the rewrite hook no-ops, and the only
consequence is verbose output.
+150 -105
View File
@@ -3,30 +3,22 @@
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).
Wallacenet's build of [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
by adrianoftyriel (GNU GPL v3 — see [LICENSE](LICENSE)), hosted at
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>. Game rules documentation
lives under [docs/](docs/README.md); build, CI and release decisions live in
[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
The app shell is complete and every game in the catalogue is playable end to end: 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 | | **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage |
| **Table** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Peg Solitaire, Yahtzee, Pirates and Bulgars, Ta Yü, Katan *(coming soon)* | | **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
One entry is marked *Coming soon*: Katan, the island game, whose engine and Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
screen land with their own task. The machinery is still there and still tested: machinery for it is still there and still tested: a game listed in `GameCatalog`
a game listed in `GameCatalog` with `available = false` appears in the menu with `available = false` appears in the menu marked *Coming soon* **on dev builds
marked *Coming soon* **on dev builds only**, so a production release lists just only**, so a production release lists just what can actually be played and nobody
what can actually be played and nobody installs it and taps into a dead end. The installs it and taps into a dead end. The menu decides this from the installed
menu decides this from the installed APK's own version name — the same source APK's own version name — the same source the updater uses to know which channel
the updater uses to know which channel it is on. 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
@@ -119,53 +111,55 @@ it: every rule above is a pure function and unit-tested on the JVM.
- **Sound** on/off. - **Sound** on/off.
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long - **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
the computer appears to think. the computer appears to think.
- **Updates** — check on launch, or check on demand. The channel is this - **Updates** — check on launch, or check on demand. **There is no channel to
project's own Gitea releases ([docs/CI.md](docs/CI.md)), and **there is no choose**: a build updates from its own channel and no other. A production
channel to choose**: a build updates from its own channel and no other. The install is offered production builds from `main`; a dev install is offered dev
settings screen states which one is running rather than offering a switch. builds from `dev`. The settings screen states which one is running rather than
`BuildConfig.OTA_ENABLED` is the kill switch, not a preference. offering a switch.
The check on launch **only ever speaks when there is a build to install**. It 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 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, current — or a phone that cannot reach GitHub — says nothing, because opening
because opening the app was not a question. The button in Settings is a the app was not a question. The button in Settings is a question, so it
question, so it answers every time, including "you're on the latest" and answers every time, including "you're on the latest" and whatever went wrong.
whatever went wrong.
Wanting both means installing both — they are separate apps and each updates Wanting both means installing both — they are separate apps and each updates
itself, see [Both channels on one device](#both-channels-on-one-device). itself, see [Both channels on one device](#both-channels-on-one-device).
## Branding ## Branding
The app wears the upstream's own branding, restored (WALA-118 review): the The launcher icon is a single square of the Wallace tartan set on the bias, and
Wallace tartan is the launcher icon and the splash, drawn as vector drawables and the app opens on the same cloth carrying the name in a Celtic uncial hand for two
Compose code so it stays sharp at every density and needs no PNGs. The app opens seconds before fading into the menu.
on the tartan held under the name, set in a Celtic uncial hand for two seconds
before it fades into the menu.
A dev build wears the **hunting green** sett where production wears the **clan A dev build wears **Wallace Hunting** instead — the same threadcount with green
red**, and installs as *Pro Libertate Games DEV*. That is what tells the two where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is
copies apart when both are on the phone; see 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 [Both channels on one device](#both-channels-on-one-device).
by `ui/theme/Tartan.kt` (`TartanBackground`, `threadWidthFor`) off
`BuildConfig.DEV_BUILD`; the matching vector colours are generated per build by The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
`resValue` in `app/build.gradle.kts` — the icon vectors reference warp and then as weft at half opacity, which is what produces the blended
`@color/tartan_field` rather than a literal, which is how one set of vectors squares instead of a flat grid. The icon is the same construction as a vector
serves both colourways. drawable, so it stays sharp at every density and needs no PNGs.
Two honest caveats: 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. - **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
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.
- **Katan is the one place this app deliberately diverges.** No Catan
trademarks or brand assets ship anywhere in it; the island game is listed
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)
as the `upstream` remote.
## Screen sizes ## Screen sizes
@@ -189,70 +183,122 @@ 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 `org.prolibertate.games.dev` under
`ca.wallacenet.pilgrimsofkatan.dev` under the name *Pro Libertate Games DEV* on the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an
the hunting-green sett, so it goes on beside a production copy rather than over installed release rather than over it. Set `PLG_CHANNEL=production` to build the
it. Set `PLG_CHANNEL=production` to build the production package production package instead.
(`ca.wallacenet.pilgrimsofkatan`, clan red).
## CI ## CI
Full decision record: [docs/CI.md](docs/CI.md).
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull - `.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`. requests into `dev` or `main`. Its APK artifact is a dev build, versioned
- `.github/workflows/release.yml` — builds and publishes the production release `<series>.0-dev` — the series being whatever `plgVersionSeries` says in
on push to `main`, or by hand (`workflow_dispatch`). `gradle.properties`, currently **1.3**.
- Both publish through [scripts/publish-release.sh](scripts/publish-release.sh) - `.github/workflows/release.yml` — on push to `main`, builds the APK and
against Gitea's REST API, which is also runnable by hand. **No Actions runner publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
is registered on the instance yet**, so until one is, the local build is the reads that tag, which is why the APK's `versionCode` is the same run number.
guaranteed path (`./gradlew assembleDebug && ./gradlew testDebugUnitTest`)
and releases go out via the script. ### 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, and this is the only release
channel: the workflows no longer create GitHub or Gitea releases. They run on
Gitea Actions (`https://git.wallacenet.ca/devadmin/ProLibertateGames`); the GitHub
repository is frozen and should stay public with its last releases, because
installs that predate the Cloudflare updater still read them to reach it.
Build numbers are `run number + plgVersionRunOffset` (1000, in `gradle.properties`),
so moving CI hosts, which restarts run numbers, can never publish a `versionCode`
lower than one already installed.
### 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 is the frozen pre-migration release:
**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 ### The version series
A version is **`<series>.<build number>`**. Both values are set by hand, once, A version is **`<series>.<run number>`**. The series is set by hand, once, in
in `gradle.properties`: `gradle.properties`:
```properties ```properties
plgVersionSeries=1.4 plgVersionSeries=1.3
plgVersionRunOffset=1000
``` ```
Both workflows read those same properties when they name a release tag, and the Both workflows read that same property 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 build reads it 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 name different versions. Bumping a series is a one-line change to that file and
nothing else — but they are not equivalent: nothing else.
- The **series** is a name for people. Bumping it changes nothing about The number after it is the CI run number and keeps climbing on its own, which is
ordering. what makes a series bump safe: `versionCode` is the run number *alone*, so
- The **build number** is what the machines compare: `versionCode`, the tag's going from 1.0 to 1.1 never looks like a downgrade to Android and never
trailing number, and the number in `versionName`, all the same figure — interrupts an OTA update. The series is a name for people; the run number is
`CI run number + plgVersionRunOffset`. Because the offset never changes and what the machines compare.
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 ### Update channels
`ci.yml` stamps builds of `dev` as dev-channel, `release.yml` stamps `main` as Both pipelines publish, to two separate channels:
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); | Channel | Published by | Tag | Marked |
- dev prereleases are marked so the "latest release" endpoint can never hand one | --- | --- | --- | --- |
to a production install; | Production | `release.yml`, push to `main` | `v<series>.<n>` | release |
- pull requests build and check but never publish. | 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` **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 is the run number of the workflow that produced it, and the two workflows count
the two workflows count separately, so the numbers say nothing across the separately. Dev is on 69 while production is on 40, so the numbers say nothing
divide. across the divide.
That is why **an install only ever updates within its own channel**, and why 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 there is no setting for it. The app reads the `-dev` suffix in its own
@@ -264,18 +310,18 @@ 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`, `org.prolibertate.games.dev`,
`ca.wallacenet.pilgrimsofkatan.dev`, so it is a separate app as far as Android so it is a separate app as far as Android is concerned and can sit beside the
is concerned and can sit beside the production copy rather than replacing it. Each channel still updates itself 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.
Three things move together, all off `isDevBuild` in `app/build.gradle.kts`: 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` | `org.prolibertate.games` | `org.prolibertate.games.dev` |
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV | | Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
| Tartan sett | clan red | hunting green | | Sett | Wallace (red) | Wallace 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
@@ -285,11 +331,11 @@ 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, 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 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`, name and the colours reach the resources through `resValue`, so `app_name`,
`brand_field` and `splash_background` are generated by the build and are `tartan_field`, `splash_background` and `ic_launcher_background` are generated
deliberately *not* in `res/values/` — the icon vectors reference by the build and are deliberately *not* in `res/values/` — the icon vectors
`@color/brand_field` rather than a literal, which is how one set of vectors reference `@color/tartan_field` rather than a literal, which is how one set of
serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag into vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
Kotlin, where `BrandField` picks the matching Compose colour for the splash. 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 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 each then updates itself down its own line. Nothing in the app moves a phone
@@ -303,7 +349,7 @@ for a signature mismatch.
## Rules ## Rules
The exact rules implemented, and the options that vary them, are documented per The exact rules implemented, and the options that vary them, are documented per
game (index: [docs/README.md](docs/README.md)): game:
- [Euchre](docs/RULES-euchre.md) - [Euchre](docs/RULES-euchre.md)
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout** - [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
@@ -354,8 +400,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
net/ wire protocol, LAN transport, lobby, match driver net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
update/ OTA updater against this repo's Gitea releases, and the update/ Cloudflare Pages OTA updater, and the channel rule — pure, tested
channel rule — pure, tested
ui/ Compose screens ui/ Compose screens
``` ```
+9 -28
View File
@@ -16,18 +16,10 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0 val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
// The fixed floor every stamped build number is measured from, in // See plgVersionRunOffset in gradle.properties: the stamped build number is the
// gradle.properties. Gitea Actions restarts its run numbers at 1 for each // CI run number plus a permanent floor, so a counter that restarts on a new CI
// workflow, so an unstamped build (versionCode 1) and the first release of a // host can never produce a lower versionCode than one already installed.
// fresh instance would otherwise collide — and once a phone has installed
// versionCode N, Android refuses anything smaller as a downgrade. Adding a
// permanent offset is what makes "the counter never resets" true by
// construction: it is read from one place, it never changes, and the tag,
// versionName and versionCode all get the same number (see docs/CI.md).
val runOffset = (project.findProperty("plgVersionRunOffset") as String?)?.toIntOrNull() ?: 0 val runOffset = (project.findProperty("plgVersionRunOffset") as String?)?.toIntOrNull() ?: 0
// The stamped build number: what the release tag, the versionName and the
// versionCode are all named after.
val buildNumber = runNumber + runOffset val buildNumber = runNumber + runOffset
// The version series, from gradle.properties. Both workflows read the same // The version series, from gradle.properties. Both workflows read the same
@@ -46,30 +38,25 @@ val versionSeries = (project.findProperty("plgVersionSeries") as String?) ?: "1.
val isDevBuild = buildChannel != "production" val isDevBuild = buildChannel != "production"
android { android {
// Deliberately left at the upstream source package: R and BuildConfig stay
// in org.prolibertate.games for both channels, so no source file has to
// care which applicationId it ended up installed as. The *installed*
// identity is the applicationId below — that is what the rebrand changes
// (WALA-118), not the JVM package names.
namespace = "org.prolibertate.games" namespace = "org.prolibertate.games"
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
// 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. The namespace above is deliberately left alone:
applicationId = if (isDevBuild) "ca.wallacenet.pilgrimsofkatan.dev" else "ca.wallacenet.pilgrimsofkatan" // R and BuildConfig stay in org.prolibertate.games for both, so no
// source has to care which package it ended up installed as.
applicationId = if (isDevBuild) "org.prolibertate.games.dev" else "org.prolibertate.games"
minSdk = 24 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
// know its own channel, so it must survive into the shipped APK. It // know its own channel, so it must survive into the shipped APK. It
// tracks isDevBuild rather than the stamp alone, so an unstamped local // tracks isDevBuild rather than the stamp alone, so an unstamped local
// build cannot end up claiming a channel its applicationId contradicts. // build cannot end up claiming a channel its applicationId contradicts.
// versionCode is the stamped build number and nothing else, so it keeps // versionCode stays the run number and nothing else, so it keeps
// climbing across a change of series: bumping 1.0 to 1.1 must not make // climbing across a change of series: bumping 1.0 to 1.1 must not make
// an update look like a downgrade to Android. An unstamped build is 1, // an update look like a downgrade to Android.
// below every release, which is the honest answer for a build nobody
// stamped.
versionCode = if (stampVersion) buildNumber else 1 versionCode = if (stampVersion) buildNumber else 1
versionName = when { versionName = when {
stampVersion && buildChannel == "production" -> "$versionSeries.$buildNumber" stampVersion && buildChannel == "production" -> "$versionSeries.$buildNumber"
@@ -100,12 +87,6 @@ android {
resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B") resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B")
buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString()) buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString())
// In-app OTA updates, pointed at this project's own Gitea releases
// (docs/CI.md). True in this build — the channel exists. Flipping it
// false is the kill switch: every check then reports that and fetches
// nothing, with the machinery and its tests still compiled.
buildConfigField("boolean", "OTA_ENABLED", "true")
} }
signingConfigs { signingConfigs {
+1 -3
View File
@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- LAN play over TCP sockets, plus hand-a-down permission for in-app <!-- OTA updates from plg.wallacenet.ca, plus LAN play over TCP sockets. -->
installs — the OTA updater installs releases published on this repo's
Gitea; see docs/CI.md. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Network Service Discovery needs multicast to see hosts on the LAN. --> <!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
@@ -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
@@ -49,7 +49,7 @@ fun LaunchUpdateCheck(env: AppEnv, visible: Boolean) {
} }
// Held back behind the splash, so an update prompt cannot appear over the // Held back behind the splash, so an update prompt cannot appear over the
// splash before the app itself has. // tartan before the app itself has.
val pending = release val pending = release
if (pending == null || dismissed || !visible) return if (pending == null || dismissed || !visible) return
@@ -17,10 +17,6 @@ import org.prolibertate.games.game.chess.ChessAi
import org.prolibertate.games.game.chess.ChessMove import org.prolibertate.games.game.chess.ChessMove
import org.prolibertate.games.game.chess.ChessRules import org.prolibertate.games.game.chess.ChessRules
import org.prolibertate.games.game.chess.ChessState import org.prolibertate.games.game.chess.ChessState
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.crazyeights.CrazyEightsAi import org.prolibertate.games.game.crazyeights.CrazyEightsAi
import org.prolibertate.games.game.crazyeights.CrazyEightsMove import org.prolibertate.games.game.crazyeights.CrazyEightsMove
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
@@ -116,7 +112,6 @@ import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.BackgammonScreen import org.prolibertate.games.ui.game.BackgammonScreen
import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CatanScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CrazyEightsScreen
import org.prolibertate.games.ui.game.CribbageScreen import org.prolibertate.games.ui.game.CribbageScreen
import org.prolibertate.games.ui.game.HeartsScreen import org.prolibertate.games.ui.game.HeartsScreen
@@ -673,23 +668,6 @@ fun PlayScreen(
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
} }
GameCatalog.CATAN -> {
val controller = remember(route) {
MatchController<CatanState, CatanMove>(
rules = CatanRules,
ai = CatanAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
CatanScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
else -> Text("That game isn't playable yet.") else -> Text("That game isn't playable yet.")
} }
} }
@@ -1,610 +0,0 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
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.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
private const val TITLE = "Katan"
// Board paint colours, kept local
private val TileForest = Color(0xFF6B8E6B)
private val TilePasture = Color(0xFFB8D48A)
private val TileFields = Color(0xFFEBCB84)
private val TileHills = Color(0xFFCD853F)
private val TileMountains = Color(0xFF878787)
private val TileDesert = Color(0xFFE5D6A8)
private val TileBorder = Color(0xFF6E6350)
private val TokenRed = Color(0xFFFFEB3B)
private val TokenWhite = Color(0xFFFFFFFF)
private val PlayerColors = listOf(Color(0xFF6B4226), Color(0xFFC71585), Color(0xFF006400), Color(0xFF1974D2))
private val HintColor = Color(0x7F1976D2)
/**
* Catan screen — consumes only the game/catan/ engine API.
* Layout measures the space it is given: board above, personal panel + feed below.
* Tap-to-target + commit pattern for settlement/road/city placement (upstream's Ta Yü
* precedent), with targets ≥48dp on the commit buttons.
* Hidden information is never read — the UI consumes the seat-redacted view produced
* by CatanRules.viewFor, which masks devHands/devDeck for non-local seats.
*/
@Composable
fun CatanScreen(
controller: MatchController<CatanState, CatanMove>,
localSeat: Int,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
// Placement flow state: which vertex/edge the player has tapped, and the
// legal move currently lined up on it. Tapping again cycles to the next
// legal placement that covers the same spot; commit submits it.
var tappedVertex by remember { mutableStateOf<Int?>(null) }
var tappedEdge by remember { mutableStateOf<Int?>(null) }
var linedMove by remember { mutableStateOf<CatanMove?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = TITLE, onBack = onExit) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
}
return
}
// Reset lined placement when the legal set changes (a move landed, phase advanced).
LaunchedEffect(legal, current.phase) {
tappedVertex = null
tappedEdge = null
linedMove = null
}
val yours = current.turn == localSeat && current.phase != CatanPhase.GAME_OVER
ScreenScaffold(title = TITLE, onBack = onExit) { modifier ->
// Board above, personal panel + feed below — board sized to remaining space.
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(modifier = Modifier.weight(1.6f).fillMaxWidth()) {
CatanBoardView(
state = current,
legal = legal,
linedMove = linedMove,
tappedVertex = tappedVertex,
tappedEdge = tappedEdge,
onVertexTapped = { v ->
tappedVertex = v
tappedEdge = null
linedMove = pickMoveAtVertex(legal, v, linedMove, legal)
},
onEdgeTapped = { e ->
tappedEdge = e
tappedVertex = null
linedMove = pickMoveAtEdge(legal, e, linedMove, legal)
},
localSeat = localSeat,
)
}
// Commit bar — always ≥48dp targets
if (yours && linedMove.value != null) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { controller.submit(linedMove.value!!) },
modifier = Modifier.weight(1f).height(48.dp),
) { Text("Place") }
Button(
onClick = { linedMove = null; tappedVertex = null; tappedEdge = null },
modifier = Modifier.weight(1f).height(48.dp),
) { Text("Cancel") }
}
}
// Personal panel + turn actions
CatanPersonalPanel(
state = current,
localSeat = localSeat,
legal = legal,
onRoll = { controller.submit(RollDice()) },
onEndTurn = { controller.submit(EndTurn()) },
)
CatanEventFeed(log = current.log)
}
abandoned?.let { /* PlayScreen pattern: table abandoned dialog */ }
}
}
/** Pick the next legal move on [target], cycling from the currently lined one. */
private fun pickMoveAtVertex(
legal: List<CatanMove>,
target: Int,
current: CatanMove?,
allLegal: List<CatanMove>,
): CatanMove? {
val matching = allLegal.filter { m ->
when (m) {
is BuildSettlement -> m.vertex == target
is BuildCity -> m.vertex == target
is org.prolibertate.games.game.catan.SetupPlace -> m.vertex == target
else -> false
}
}
if (matching.isEmpty()) return current
val pos = matching.indexOf(current)
return if (pos >= 0 && matching.size > 1) {
matching[(pos + 1) % matching.size]
} else {
matching.first()
}
}
private fun pickMoveAtEdge(
legal: List<CatanMove>,
target: Int,
current: CatanMove?,
allLegal: List<CatanMove>,
): CatanMove? {
val matching = allLegal.filter { m ->
when (m) {
is BuildRoad -> m.edge == target
is org.prolibertate.games.game.catan.SetupPlace -> m.edge == target
else -> false
}
}
if (matching.isEmpty()) return current
val pos = matching.indexOf(current)
return if (pos >= 0 && matching.size > 1) {
matching[(pos + 1) % matching.size]
} else {
matching.first()
}
}
// ---------------------------------------------------------------------------
// Board
// ---------------------------------------------------------------------------
/** Geometry for a board that fills its canvas. */
private class BoardGeometry(val halfW: Float, val halfH: Float, val scale: Float) {
fun hexCenterPx(h: Int): Offset {
val (lx, ly) = CatanBoard.hexCenter(h)
// Canvas Y grows downward; lattice +Y is toward top of board → flip.
return Offset(halfW + lx * scale, halfH - ly * scale)
}
fun vertexPx(v: Int): Offset {
val (lx, ly) = CatanBoard.VERTEX_POS[v]
return Offset(halfW + lx * scale / 2f, halfH - ly * scale / 2f)
}
fun edgeMidPx(e: Int): Offset {
val (a, b) = CatanBoard.EDGE_VERTICES[e]
val pa = vertexPx(a)
val pb = vertexPx(b)
return Offset((pa.x + pb.x) / 2f, (pa.y + pb.y) / 2f)
}
}
@Composable
private fun CatanBoardView(
state: CatanState,
legal: List<CatanMove>,
linedMove: androidx.compose.runtime.MutableState<CatanMove?>,
tappedVertex: Int?,
tappedEdge: Int?,
onVertexTapped: (Int) -> Unit,
onEdgeTapped: (Int) -> Unit,
localSeat: Int,
) {
val geometry = remember { BoardGeometry(0f, 0f, 0f) }
Canvas(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFE8DCC8))
.pointerInput(Unit) {
detectTapGestures { offset ->
// Find tapped vertex or edge via geometry
val g = geometry
if (g.scale == 0f) return@detectTapGestures
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val pos = g.vertexPx(v)
if (Offset(offset.x - pos.x, offset.y - pos.y).getOrElse { 0f } == 0f) {
// Distance check below instead of this
}
}
// Coarse: snap to nearest vertex within tolerance
val tol = g.scale * 0.20f
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val pos = g.vertexPx(v)
val dist = kotlin.math.hypot(offset.x - pos.x, offset.y - pos.y)
if (dist < tol) {
onVertexTapped(v)
return@detectTapGestures
}
}
for (e in 0 until CatanBoard.EDGE_COUNT) {
val pos = g.edgeMidPx(e)
val dist = kotlin.math.hypot(offset.x - pos.x, offset.y - pos.y)
if (dist < tol) {
onEdgeTapped(e)
return@detectTapGestures
}
}
}
},
) {
val halfW = size.width / 2f
val halfH = size.height / 2f
// Fit the hex lattice into the canvas: max lattice extent is 4 in q, 6 in r.
val latticeMax = 6f
val scale = minOf(halfW, halfH) / latticeMax
val g = BoardGeometry(halfW, halfH, scale)
drawBoard(g)
drawTerrains(state, g)
drawRobber(state, g)
drawBuildings(state, g)
drawRoads(state, g)
drawNumbers(state, g)
drawPorts(state, g)
drawDice(state, g)
drawHints(g, legal, linedMove, tappedVertex, tappedEdge)
}
}
private fun Canvas.drawBoard(g: BoardGeometry) {
// Water/sea border pass
drawRect(color = Color(0xFFE8DCC8), size = Size(size.width, size.height))
}
private fun Canvas.drawTerrains(state: CatanState, g: BoardGeometry) {
val radius = g.scale * 0.38f
for (h in 0 until CatanBoard.HEX_COUNT) {
val c = g.hexCenterPx(h)
val col = when (state.terrains[h]) {
org.prolibertate.games.game.catan.Terrain.FOREST -> TileForest
org.prolibertate.games.game.catan.Terrain.PASTURE -> TilePasture
org.prolibertate.games.game.catan.Terrain.FIELDS -> TileFields
org.prolibertate.games.game.catan.Terrain.HILLS -> TileHills
org.prolibertate.games.game.catan.Terrain.MOUNTAINS -> TileMountains
org.prolibertate.games.game.catan.Terrain.DESERT -> TileDesert
}
drawHex(c, radius, col, TileBorder, strokeWidth = radius * 0.1f)
}
}
private fun Canvas.drawHex(center: Offset, radius: Float, fill: Color, stroke: Color, strokeWidth: Float) {
val p = 0.866f * radius
val pts = listOf(
Offset(center.x, center.y - radius),
Offset(center.x + p, center.y - radius / 2),
Offset(center.x + p, center.y + radius / 2),
Offset(center.x, center.y + radius),
Offset(center.x - p, center.y + radius / 2),
Offset(center.x - p, center.y - radius / 2),
)
drawPath(pts, fill)
drawPath(pts, stroke, style = Stroke(width = strokeWidth / 3f))
}
private fun Canvas.drawNumbers(state: CatanState, g: BoardGeometry) {
for (h in 0 until CatanBoard.HEX_COUNT) {
val num = state.numbers[h]
if (num == 0) continue
val c = g.hexCenterPx(h)
val r = minOf(size.width, size.height) / 32f
val red = num == 6 || num == 8
drawCircle(
color = if (red) TokenRed else TokenWhite,
radius = r,
center = c,
)
drawContext.canvas.nativeCanvas.let {
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.parseColor("#333333")
textSize = r * 1.2f
textAlign = android.graphics.Paint.Align.CENTER
}
it.drawText(num.toString(), c.x, c.y + r * 0.4f, paint)
}
}
}
private fun Canvas.drawRobber(state: CatanState, g: BoardGeometry) {
val hex = state.robber
val c = g.hexCenterPx(hex)
drawCircle(color = Color(0xFF2A2A2A), radius = minOf(size.width, size.height) / 30f, center = c)
}
private fun Canvas.drawBuildings(state: CatanState, g: BoardGeometry) {
val r = minOf(size.width, size.height) / 40f
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val b = state.buildings[v] ?: continue
val c = g.vertexPx(v)
val col = PlayerColors.getOrElse(b.owner) { Color.Gray }
if (b.type == BuildingType.CITY) {
drawCircle(color = col, radius = r * 1.4f, center = c)
} else {
drawCircle(color = col, radius = r, center = c)
}
}
}
private fun Canvas.drawRoads(state: CatanState, g: BoardGeometry) {
val w = minOf(size.width, size.height) / 60f
for (e in 0 until CatanBoard.EDGE_COUNT) {
val owner = state.roads[e]
if (owner == NO_PLAYER) continue
val (a, b) = CatanBoard.EDGE_VERTICES[e]
val pa = g.vertexPx(a)
val pb = g.vertexPx(b)
val col = PlayerColors.getOrElse(owner) { Color.Gray }
drawLine(color = col, start = pa, end = pb, strokeWidth = w, cap = StrokeCap.Round)
}
}
private fun Canvas.drawPorts(state: CatanState, g: BoardGeometry) {
for (port in state.ports) {
val c = g.edgeMidPx(port.edge)
val r = minOf(size.width, size.height) / 32f
val label = if (port.resource == null) "3:1" else "2:1"
drawContext.canvas.nativeCanvas.let {
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.parseColor("#000000")
textSize = r * 1.2f
textAlign = android.graphics.Paint.Align.CENTER
}
it.drawText(label, c.x, c.y + r * 0.4f, paint)
}
}
}
private fun Canvas.drawDice(state: CatanState, g: BoardGeometry) {
val dice = state.lastDice
if (dice.isEmpty()) return
val total = dice[0] + dice[1]
// Dice marker sits centered below board
val cx = size.width / 2f
val cy = size.height - minOf(size.width, size.height) / 14f
val r = minOf(size.width, size.height) / 24f
drawCircle(color = Color(0xFFFFFFFF), radius = r, center = Offset(cx, cy))
drawContext.canvas.nativeCanvas.let {
val paint = android.graphics.Paint().apply {
color = android.graphics.Color.parseColor("#333333")
textSize = r * 1.0f
textAlign = android.graphics.Paint.Align.CENTER
}
it.drawText("$total", cx, cy + r * 0.4f, paint)
}
}
private fun Canvas.drawHints(
g: BoardGeometry,
legal: List<CatanMove>,
linedMove: androidx.compose.runtime.MutableState<CatanMove?>,
tappedVertex: Int?,
tappedEdge: Int?,
) {
val r = minOf(size.width, size.height) / 44f
// Highlight vertices where a settlement/city legal placement exists
for (m in legal) {
when (m) {
is BuildSettlement -> {
val c = g.vertexPx(m.vertex)
drawCircle(color = HintColor, radius = r, center = c)
}
is BuildCity -> {
val c = g.vertexPx(m.vertex)
drawCircle(color = HintColor, radius = r * 1.3f, center = c)
}
is BuildRoad -> {
val (a, b) = CatanBoard.EDGE_VERTICES[m.edge]
val pa = g.vertexPx(a); val pb = g.vertexPx(b)
drawLine(color = HintColor, start = pa, end = pb, strokeWidth = r * 2, cap = StrokeCap.Round)
}
is org.prolibertate.games.game.catan.SetupPlace -> {
val vc = g.vertexPx(m.vertex)
val ec = g.edgeMidPx(m.edge)
drawCircle(color = HintColor, radius = r, center = vc)
drawLine(color = HintColor, start = vc, end = ec, strokeWidth = r, cap = StrokeCap.Round)
}
else -> Unit
}
}
// Highlight the user's currently lined-up move more strongly
linedMove.value?.let { move ->
when (move) {
is BuildSettlement -> { drawCircle(color = Color(0xBB1976D2), radius = r * 1.3f, center = g.vertexPx(move.vertex)) }
is BuildCity -> { drawCircle(color = Color(0xBB1976D2), radius = r * 1.6f, center = g.vertexPx(move.vertex)) }
is BuildRoad -> {
val (a, b) = CatanBoard.EDGE_VERTICES[move.edge]
drawLine(color = Color(0xBB1976D2), start = g.vertexPx(a), end = g.vertexPx(b), strokeWidth = r * 3, cap = StrokeCap.Round)
}
else -> Unit
}
}
}
// ---------------------------------------------------------------------------
// Personal panel
// ---------------------------------------------------------------------------
@Composable
private fun CatanPersonalPanel(
state: CatanState,
localSeat: Int,
legal: List<CatanMove>,
onRoll: () -> Unit,
onEndTurn: () -> Unit,
) {
val yours = state.turn == localSeat && state.phase != CatanPhase.GAME_OVER
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Seat $localSeat — turn ${state.turnNumber}, ${state.phase}",
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
)
// Resources — counts only (Card counts public in Catan)
val res = state.resources[localSeat]
val parts = listOf(
"Lumber" to (res[Resource.LUMBER] ?: 0),
"Brick" to (res[Resource.BRICK] ?: 0),
"Wool" to (res[Resource.WOOL] ?: 0),
"Grain" to (res[Resource.GRAIN] ?: 0),
"Ore" to (res[Resource.ORE] ?: 0),
)
Text(
text = parts.joinToString(" ") { "${it.first.take(1)} ${it.second}" },
style = MaterialTheme.typography.bodySmall,
)
// Dev card counts public; hidden cards redacted by viewFor
Text(
text = "Dev cards: ${state.devHandCounts[localSeat]}",
style = MaterialTheme.typography.bodySmall,
)
// Victory points — public only (building VP + awards); hidden VP cards
// are counted for the local seat's own view.
val vp = CatanRules.publicVictoryPoints(state)[localSeat] +
state.devHands[localSeat].count { it.type == DevCardType.VICTORY_POINT }
val banners = buildList<String>().apply {
if (state.longestRoadOwner == localSeat) add("🛣️ Longest Road (${CatanRules.longestRoadFor(state, localSeat)})")
if (state.largestArmyOwner == localSeat) add("⚔️ Largest Army (${state.knightsPlayed[localSeat]})")
}
Text(
text = "VP: $vp ${if (banners.isNotEmpty()) banners.joinToString(" ") else ""}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
// Build menu — gated by available resources
val canBuildSettlement = (res[Resource.LUMBER] ?: 0) >= 1 && (res[Resource.BRICK] ?: 0) >= 1 && (res[Resource.WOOL] ?: 0) >= 1 && (res[Resource.GRAIN] ?: 0) >= 1
val canBuildCity = (res[Resource.ORE] ?: 0) >= 3 && (res[Resource.GRAIN] ?: 0) >= 2
val canBuyDev = (res[Resource.ORE] ?: 0) >= 1 && (res[Resource.WOOL] ?: 0) >= 1 && (res[Resource.GRAIN] ?: 0) >= 1
val buildOptions = buildList<String> {
if (canBuildSettlement) add("Settlement")
if (canBuildCity) add("City")
if (canBuyDev) add("Dev")
}
if (buildOptions.isNotEmpty() && yours) {
Text("Build: ${buildOptions.joinToString(", ")}", style = MaterialTheme.typography.bodySmall)
}
if (yours) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onRoll,
enabled = state.phase == CatanPhase.AWAIT_ROLL,
modifier = Modifier.weight(1f).height(48.dp),
) { Text("Roll") }
Button(
onClick = onEndTurn,
enabled = state.phase == CatanPhase.PLAYING,
modifier = Modifier.weight(1f).height(48.dp),
) { Text("End Turn") }
}
} else {
Text(
text = when {
state.phase == CatanPhase.GAME_OVER -> "Game over — seat ${state.winner} wins!"
yours -> "Your turn — place a settëment or roll."
else -> "Seat ${state.turn} is playing…"
},
style = MaterialTheme.typography.bodySmall,
)
}
}
}
// ---------------------------------------------------------------------------
// Event feed
// ---------------------------------------------------------------------------
@Composable
private fun CatanEventFeed(log: List<String>) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text("Events", style = MaterialTheme.typography.labelLarge)
val lines = log.takeLast(4)
if (lines.isEmpty()) {
Text("Waiting for the first event…", style = MaterialTheme.typography.bodySmall, color = Color.Gray)
}
lines.forEach { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}
@@ -1,19 +1,17 @@
package org.prolibertate.games.update package org.prolibertate.games.update
import java.net.URI
/** /**
* Which line of builds an APK belongs to. * Which line of builds an APK belongs to.
* *
* This is a property of the build, never a preference. The two channels are * This is a property of the build, never a preference. The two channels are
* published by different workflows and have entirely separate version * published by different workflows and have entirely separate version
* sequences — a dev build's versionCode is its CI build number, a production * sequences — a dev build's versionCode is its CI run number, a production
* build's is its release build number, and the two are unrelated. There is no * build's is its release run number, and the two are unrelated. There is no
* 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 * `…games.dev` — so both can sit on one device, each updating itself down its
* itself down its own line and leaving the other alone. * 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) {
PRODUCTION("Production", "Stable builds released from main"), PRODUCTION("Production", "Stable builds released from main"),
@@ -23,7 +21,7 @@ enum class UpdateChannel(val label: String, val blurb: String) {
/** Marks a tag or a version name as belonging to the dev channel. */ /** Marks a tag or a version name as belonging to the dev channel. */
const val DEV_SUFFIX = "-dev" const val DEV_SUFFIX = "-dev"
/** One published build, as read off a repository release. */ /** One published build, as read off a channel's `latest.json`. */
data class Release( data class Release(
val tag: String, val tag: String,
val versionCode: Int, val versionCode: Int,
@@ -51,14 +49,14 @@ fun channelOfVersionName(versionName: String): UpdateChannel =
* The channel a release belongs to. * The channel a release belongs to.
* *
* Two independent marks, and either is enough: the `-dev` suffix the workflow * Two independent marks, and either is enough: the `-dev` suffix the workflow
* puts on the tag, and the release's own prerelease flag. A release would have * puts on the tag, and GitHub's own prerelease flag. A release would have to
* to lose both to be mistaken for the other channel. * lose both to be mistaken for the other channel.
*/ */
fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel = fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
if (tag.endsWith(DEV_SUFFIX) || prerelease) UpdateChannel.DEV else UpdateChannel.PRODUCTION if (tag.endsWith(DEV_SUFFIX) || prerelease) UpdateChannel.DEV else UpdateChannel.PRODUCTION
/** /**
* The build number out of a `v<series>.<n>` tag, which is what an APK's * The run number out of a `v<series>.<n>` tag, which is what an APK's
* versionCode is. * versionCode is.
* *
* Only the trailing number is read. The series is a name for people, and * Only the trailing number is read. The series is a name for people, and
@@ -67,37 +65,15 @@ fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
fun versionCodeOfTag(tag: String): Int? = fun versionCodeOfTag(tag: String): Int? =
tag.substringAfterLast('.').removeSuffix(DEV_SUFFIX).toIntOrNull() tag.substringAfterLast('.').removeSuffix(DEV_SUFFIX).toIntOrNull()
/**
* Rewrites a download URL onto [host], keeping its scheme, path and query.
*
* A release server can answer for more than one host name, and it hands out
* asset URLs on whichever one it treats as canonical — which need not be the
* one the app was told to talk to. Fetching the release list from one host and
* the APK from another is a single DNS change away from a channel that half
* works, so the app stays on the host it decided to trust and takes only the
* path from what the server reported.
*
* Anything that cannot be parsed is returned unchanged: a broken URL fails
* loudly at download time, while a swallowed one would fail as a silent
* "something went wrong".
*/
fun onReportedHost(url: String, host: String): String = try {
val reported = URI(url)
val scheme = reported.scheme ?: "https"
URI(scheme, null, host, -1, reported.path, reported.query, null).toString()
} catch (e: Exception) {
url
}
/** /**
* The build a check on launch should interrupt somebody for, or null to say * The build a check on launch should interrupt somebody for, or null to say
* nothing at all. * nothing at all.
* *
* Only an actual update is worth a word. Opening the app is not asking a * Only an actual update is worth a word. Opening the app is not asking a
* question, so a check that finds nothing — or that cannot reach the release * question, so a check that finds nothing — or that cannot reach the update server at all
* server at all — stays quiet; a phone on a train would otherwise be told off * — stays quiet; a phone on a train would otherwise be told off for it every
* for it every single launch. The button in Settings *is* asking a question, and * single launch. The button in Settings *is* asking a question, and reports
* reports every answer including those two. * every answer including those two.
*/ */
fun launchOffer(verdict: UpdateVerdict): Release? = fun launchOffer(verdict: UpdateVerdict): Release? =
(verdict as? UpdateVerdict.Install)?.release (verdict as? UpdateVerdict.Install)?.release
@@ -8,36 +8,17 @@ import android.provider.Settings
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.prolibertate.games.BuildConfig
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/** /**
* Over-the-air updates from the repository's releases. * Over-the-air updates from plg.wallacenet.ca (Cloudflare Pages).
* *
* The channel is the Gitea instance this app ships from * Release tags are v<series>.<run-number> and the APK's versionCode is that same run
* (`git.wallacenet.ca`), not GitHub. Gitea's release API answers with the * number, so comparing the two is enough to tell whether a newer build exists.
* same shape the updater reads — `tag_name`, `prerelease`, `draft`, and * Everything there is public — no token is embedded in the app.
* assets carrying `browser_download_url` — so this is the same code the
* upstream ran, pointed at `/api/v1/repos/...` routes instead of
* `api.github.com`, with `limit` where GitHub says `per_page`.
*
* Release tags are v<series>.<n>, where **n is the stamped build number the
* APK's versionCode was built from** (the CI run number plus the fixed
* `plgVersionRunOffset` — see docs/CI.md), so comparing the two is enough to
* tell whether a newer build exists.
*
* The repository and its releases are public: no token is embedded in the
* app, and every download is anonymous by design. If the repo were ever made
* private, every check would simply start failing rather than leaking
* anything.
*
* `BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision — it is
* true in this build; setting it false makes every check report that and
* fetch nothing.
* *
* There is nothing to choose here. The channel comes from the running APK, so * There is nothing to choose here. The channel comes from the running APK, so
* a dev install looks only at dev builds and a production install only at * a dev install looks only at dev builds and a production install only at
@@ -52,11 +33,6 @@ class Updater(private val activity: Activity) {
* this phone is on, and the phone already knows. * this phone is on, and the phone already knows.
*/ */
suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) { suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) {
if (!BuildConfig.OTA_ENABLED) {
return@withContext UpdateVerdict.Refused(
"In-app updates are not enabled for this build yet."
)
}
try { try {
val channel = installedChannel() val channel = installedChannel()
verdictFor( verdictFor(
@@ -70,70 +46,37 @@ class Updater(private val activity: Activity) {
} }
/** /**
* Production reads the "latest release", which by definition skips * Each channel publishes a `latest.json` next to its APK on Cloudflare
* prereleases — Gitea implements it as the most recent non-prerelease, * Pages: `/prod/latest.json` and `/dev/latest.json`. The two live in one
* non-draft release, so the rule holds on this instance too. * site but separate paths, so a build can only ever look down its own line.
*
* Dev walks the release list for the newest prerelease, which is what the
* CI pipeline publishes from the dev branch. The list is taken as the
* highest versionCode among the dev releases rather than the first one
* returned, so the answer does not depend on how the server chose to
* order the page.
*/ */
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) { private fun fetchLatestRelease(channel: UpdateChannel): Release? {
UpdateChannel.PRODUCTION -> val path = if (channel == UpdateChannel.DEV) "dev" else "prod"
getJsonObject("$API/releases/latest") return getJsonObject("$BASE_URL/$path/latest.json")?.let { toRelease(it) }
?.let { toRelease(it) }
UpdateChannel.DEV ->
getJsonArray("$API/releases?limit=30")
?.let { releases ->
(0 until releases.length())
.mapNotNull { toRelease(releases.getJSONObject(it)) }
.filter { it.channel == UpdateChannel.DEV }
.maxByOrNull { it.versionCode }
}
} }
private fun toRelease(json: JSONObject): Release? { private fun toRelease(json: JSONObject): Release? {
val tag = json.optString("tag_name").ifBlank { return null } val tag = json.optString("tag").ifBlank { return null }
val versionCode = versionCodeOfTag(tag) ?: return null val versionCode = versionCodeOfTag(tag) ?: return null
val channel = channelOfTag(tag, json.optBoolean("prerelease")) val apkUrl = json.optString("apkUrl").ifBlank { return null }
val assets = json.optJSONArray("assets") ?: return null
val apks = (0 until assets.length())
.map { assets.getJSONObject(it) }
.filter { it.optString("name").endsWith(".apk") }
// A production release may carry the same build twice: once named for
// its version, and once under a fixed name so a permanent download link
// exists. Pick the versioned one explicitly rather than whichever was
// uploaded first, so what gets installed is traceable to its tag.
val chosen = apks.firstOrNull { it.optString("name").contains(tag) }
?: apks.firstOrNull()
?: return null
return Release( return Release(
tag = tag, tag = tag,
versionCode = versionCode, versionCode = versionCode,
apkUrl = onReportedHost(chosen.getString("browser_download_url"), SERVER_HOST), apkUrl = apkUrl,
apkName = chosen.optString("name"), apkName = json.optString("apkName"),
channel = channel, channel = channelOfTag(tag, prerelease = false),
) )
} }
private fun openConnection(url: String): HttpURLConnection = private fun getJsonObject(url: String): JSONObject? {
(URL(url).openConnection() as HttpURLConnection).apply { val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
setRequestProperty("Accept", "application/json") setRequestProperty("User-Agent", "ProLibertateGames-Updater")
setRequestProperty("User-Agent", "PilgrimsOfKatan-Updater")
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 15_000 readTimeout = 15_000
} }
private fun getJsonObject(url: String): JSONObject? {
val connection = openConnection(url)
try { try {
// 404 means the channel has nothing published yet.
if (connection.responseCode != 200) return null if (connection.responseCode != 200) return null
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() }) return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
} finally { } finally {
@@ -141,16 +84,6 @@ class Updater(private val activity: Activity) {
} }
} }
private fun getJsonArray(url: String): JSONArray? {
val connection = openConnection(url)
try {
if (connection.responseCode != 200) return null
return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
} finally {
connection.disconnect()
}
}
/** Read back from the installed APK's own version name. */ /** Read back from the installed APK's own version name. */
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName()) fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
@@ -202,13 +135,6 @@ class Updater(private val activity: Activity) {
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown" activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
private companion object { private companion object {
/** The Gitea instance that publishes this app's releases. */ const val BASE_URL = "https://plg.wallacenet.ca"
const val SERVER = "https://git.wallacenet.ca"
const val SERVER_HOST = "git.wallacenet.ca"
const val OWNER = "devadmin"
const val REPO = "PilgrimsOfKatan"
/** Release routes, Gitea's `/api/v1` spelling of GitHub's API. */
const val API = "$SERVER/api/v1/repos/$OWNER/$REPO"
} }
} }
@@ -164,6 +164,9 @@ class ScorekeeperSessionTest {
await("the guest to be let in") { guest.state.value.connected } await("the guest to be let in") { guest.state.value.connected }
assertFalse(guest.state.value.awaiting) assertFalse(guest.state.value.awaiting)
// The sheet follows the connection over the socket, so on a slow runner
// it can still be in flight when `connected` flips: wait for it.
await("the guest to receive the sheet") { guest.state.value.sheet == host.state.sheet }
assertEquals(host.state.sheet, guest.state.value.sheet) assertEquals(host.state.sheet, guest.state.value.sheet)
assertTrue(guest.state.value.sheet.started) assertTrue(guest.state.value.sheet.started)
assertTrue(host.state.requests.isEmpty()) assertTrue(host.state.requests.isEmpty())
@@ -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,
) )
@@ -53,28 +53,6 @@ class UpdatePolicyTest {
assertNull(versionCodeOfTag("nightly")) assertNull(versionCodeOfTag("nightly"))
} }
// -- Where the download happens ----------------------------------------
@Test
fun `a release asset is fetched from this app's host, keeping its path`() {
// The instance reports its canonical host; the app was told to trust
// another. Same bytes, one host, whatever the server names itself.
assertEquals(
"https://git.wallacenet.ca/devadmin/PilgrimsOfKatan/releases/download/" +
"v1.4.1001-dev/PilgrimsOfKatan-v1.4.1001-dev.apk",
onReportedHost(
"https://git.saskvalleytech.com/devadmin/PilgrimsOfKatan/releases/download/" +
"v1.4.1001-dev/PilgrimsOfKatan-v1.4.1001-dev.apk",
"git.wallacenet.ca",
),
)
// 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"
assertEquals(already, onReportedHost(already, "git.wallacenet.ca"))
// Anything unparseable survives untouched rather than being dropped.
assertEquals("not a url", onReportedHost("not a url", "git.wallacenet.ca"))
}
// -- The wall ----------------------------------------------------------- // -- The wall -----------------------------------------------------------
@Test @Test
@@ -151,7 +129,7 @@ class UpdatePolicyTest {
assertNull("an up-to-date app has no business interrupting", launchOffer(UpdateVerdict.UpToDate)) assertNull("an up-to-date app has no business interrupting", launchOffer(UpdateVerdict.UpToDate))
assertNull( assertNull(
"a phone with no network has nothing to apologise for", "a phone with no network has nothing to apologise for",
launchOffer(UpdateVerdict.Refused("Unable to resolve host git.wallacenet.ca")), launchOffer(UpdateVerdict.Refused("Unable to resolve host api.github.com")),
) )
// Including the refusal that this whole change exists to produce: being // Including the refusal that this whole change exists to produce: being
// shown the other channel's build is not a thing to be told about. // shown the other channel's build is not a thing to be told about.
-141
View File
@@ -1,141 +0,0 @@
# CI, releases, and OTA — decisions and how to build
Status as of the Pilgrims of Katan bootstrap (WALA-118).
## Decisions
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
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
attribution link in the README.
The repo is **public on purpose**: the updater downloads anonymously, with no
token embedded in the APK. A private repo would make every check fail
(the API answers 404 to anonymous callers), not leak anything — but it would
also stop updates dead.
2. **CI lives in Actions-compatible workflows in `.github/workflows/`.**
Gitea Actions runs workflows from `.github/workflows/` (or `.gitea/workflows/`),
so these files serve both a GitHub-style runner and the Gitea instance.
Publishing goes through `scripts/publish-release.sh` — Gitea's REST API, the
same routes the app's updater reads — rather than the `gh` CLI, which a runner
image need not carry.
| Workflow | Trigger | Publishes |
| --- | --- | --- |
| `ci.yml` | push to `dev`, PRs into `dev`/`main`, manual | dev prerelease, from `dev` only |
| `release.yml` | push to `main`, manual | production release, from `main` only |
Pull requests build and check but never publish. Prereleases are marked so
the "latest release" endpoint — which skips them — can never hand a dev build
to a production install.
3. **A runner has to be registered before any of that executes.** As of the
bootstrap the instance has **no Actions runner**, so the workflows are
correct but idle: a Gitea runner (Docker or host) must be registered against
the instance and able to resolve `actions/checkout`,
`actions/setup-java`, `android-actions/setup-android` and
`actions/upload-artifact`. Until then the manual build below and the manual
publish script are the working paths.
4. **The local build is the guaranteed path.** Workflows need a runner that may
not exist yet; `./gradlew assembleDebug` + `./gradlew testDebugUnitTest`
work on any machine with JDK 17 and the Android SDK (compileSdk 34), and CI's
green means exactly these two commands plus lint.
## Build numbers, tags, and why updates never downgrade
An APK is **`<series>.<build number>`**, the release tag is **`v<series>.<build
number>`**, and the build number is what Android sees as `versionCode`:
```
build number = CI run number + plgVersionRunOffset (gradle.properties)
```
- `plgVersionSeries` (currently `1.4`) is the human-facing series, set by hand
in `gradle.properties`.
- `plgVersionRunOffset` (currently `1000`) is a **permanent floor**, also set by
hand in `gradle.properties`, set once at bootstrap and never changed.
Why the offset exists: Gitea Actions restarts run numbers at 1 for each
workflow, so without it the first release of a fresh instance would be build
`1` — the number an unstamped local build already carries — and any reset of
the counter would hand Android a *lower* `versionCode`, which it refuses as a
downgrade. With the offset, every stamped build starts at 1001 and only ever
climbs, because the run number is monotonic and the offset never moves. The
gradle build and both workflows read the same two properties, so the tag and
the APK inside it cannot name different versions.
The series is a name for people: bumping it changes nothing about ordering,
because `versionCode` never includes the series.
Unstamped builds (local, or a PR build) are `versionCode 1`, below every
release — the honest answer for a build nobody stamped.
## Manual build (works today, no runner needed)
```sh
# JDK 17, Android SDK with platform 34 + build-tools 34
export JAVA_HOME=... # a JDK 17 install
export ANDROID_HOME=... # SDK root (or set sdk.dir in local.properties)
./gradlew assembleDebug # dev-channel app (default: ca.wallacenet.pilgrimsofkatan.dev)
PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet.pilgrimsofkatan)
./gradlew testDebugUnitTest # JVM unit tests
./gradlew lintDebug # Android lint (CI runs this too)
```
The two variants install side by side: different applicationId, different
launcher name (`Pro Libertate Games DEV` vs `Pro Libertate Games`), different
tartan sett.
## Manual release (works today, no runner needed)
```sh
GITEA_TOKEN=… ./scripts/publish-release.sh v1.4.1001 app/build/outputs/apk/debug/app-debug.apk release "$(git rev-parse HEAD)"
```
The script takes `<tag> <apk> <prerelease|release> [target-sha]`, creates the
release through `POST /api/v1/repos/{owner}/{repo}/releases`, attaches the APK
through `POST …/releases/{id}/assets` — which is the upload route Gitea's
Swagger declares for this instance (1.27.3) — and then reads the release back
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
`app/src/main/java/org/prolibertate/games/update/Updater.kt`:
| Channel | Endpoint |
| --- | --- |
| production | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases/latest` |
| dev | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases?limit=30` |
- `/releases/latest` is documented by Gitea as *the most recent non-prerelease,
non-draft release* — the same rule GitHub implements, so the production
channel keeps skipping dev builds.
- The dev path takes the highest `versionCode` among the prereleases rather
than whatever came first on the page, so it does not depend on server
ordering.
- The JSON shape is the one the code already parsed from GitHub: `tag_name`,
`prerelease`, `draft`, and assets carrying `browser_download_url`. Gitea says
`limit` where GitHub says `per_page`.
- Each asset must be named so it contains its tag —
`PilgrimsOfKatan-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.
`BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision: it is
`true`, and setting it `false` makes every check report that and fetch nothing.
`UpdatePolicy.kt` — channel rules, tag parsing, verdicts — is pure and stays
under test either way.
## Keystore
`app/debug.keystore` is committed deliberately and must never be rotated:
every build — local, CI, release — signs with it, so an OTA update installs
over the previous build instead of being rejected for a signature mismatch.
+2 -1
View File
@@ -40,7 +40,8 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
## Other docs ## Other docs
- [CI.md](CI.md) — build, CI, releases, OTA decisions. - Build, CI, releases and OTA are described in the CI section of the root
[README](../README.md).
- `licenses/` — third-party licences that must travel with the app - `licenses/` — third-party licences that must travel with the app
(Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`). (Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`).
- Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root. - Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root.
+14 -7
View File
@@ -1,5 +1,12 @@
# Project-wide Gradle settings. # Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # C1-only JIT (-XX:TieredStopAtLevel=1): the Gitea runner's JVMs have been
# crashing intermittently (SIGSEGV in HotSpot's compiler, bad Kotlin compiler
# codegen) on Zulu, Corretto and Temurin alike. Dropping C2 is the test for
# whether the optimising JIT is the trigger; it costs some build speed. The
# Kotlin daemon needs its own copy: setting its options stops it inheriting
# org.gradle.jvmargs.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:TieredStopAtLevel=1
kotlin.daemon.jvm.options=-Xmx2048m,-XX:TieredStopAtLevel=1
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
@@ -24,10 +31,10 @@ android.nonTransitiveRClass=true
# when this changes. # when this changes.
plgVersionSeries=1.4 plgVersionSeries=1.4
# The permanent floor under every stamped build number: versionCode (and the # The permanent floor under every stamped build number: versionCode, versionName
# release tag, and versionName) are `run number + this`. Gitea Actions starts # and the release tag are all `run number + this`. Run numbers restart at 1 on a
# run numbers at 1 for each workflow, so without it the first Gitea-era release # new CI host (Gitea Actions), and Android refuses any versionCode lower than the
# would be build 1 — the same number an unstamped local build already carries — # one installed, so without a floor the first build after a move would look like
# and any later reset would make Android reject the next build as a downgrade. # a downgrade. 1000 sits above every number GitHub reached (prod 30, dev ~172).
# Set once, at bootstrap, and never changed again: see docs/CI.md. # Set once and never changed.
plgVersionRunOffset=1000 plgVersionRunOffset=1000
-126
View File
@@ -1,126 +0,0 @@
#!/bin/sh
# Publish a built APK to this repository's Gitea releases — the channel the
# in-app updater reads (docs/CI.md).
#
# The workflows call this, and so can a person: it is the whole release step,
# runnable by hand on any machine with curl, which matters because a Gitea
# Actions runner has to exist before the workflows can run at all.
#
# scripts/publish-release.sh <tag> <apk> <prerelease|release> [target-sha]
#
# Environment:
# GITEA_TOKEN required — a token that may create releases on the repo.
# GITEA_API API base, default https://git.wallacenet.ca/api/v1
# GITEA_REPO owner/name, default devadmin/PilgrimsOfKatan
#
# The four arguments can also arrive as RELEASE_TAG, RELEASE_APK,
# RELEASE_KIND and RELEASE_TARGET, which is how the workflows pass them.
set -eu
usage() {
sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' >&2
}
tag="${1:-${RELEASE_TAG:-}}"
apk="${2:-${RELEASE_APK:-}}"
kind="${3:-${RELEASE_KIND:-}}"
target="${4:-${RELEASE_TARGET:-${GITHUB_SHA:-HEAD}}}"
case "$kind" in
prerelease|release) ;;
*) usage; exit 2 ;;
esac
[ -n "$tag" ] || { usage; 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; }
api="${GITEA_API:-${GITHUB_API_URL:-https://git.wallacenet.ca/api/v1}}/repos/${GITEA_REPO:-${GITHUB_REPOSITORY:-devadmin/PilgrimsOfKatan}}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
# Named for its tag, not for whatever the build happened to call it: the
# 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
# found by the fallback rule.
apk_name="PilgrimsOfKatan-${tag}.apk"
# 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.
if [ "$kind" = "prerelease" ]; then
prerelease=true
note="Automated dev-channel build of ${target}. Newer than production and less tested — switch to the Dev update channel in Settings to be offered these. Published as a prerelease so production-channel installs never see it."
else
prerelease=false
note="Production-channel build of ${target}. Download ${apk_name} below and install it on an Android device (you may need to allow installs from unknown apps). It is signed with the repo's fixed keystore, which is what lets the in-app updater install over it."
fi
cat > "$tmp/release.json" <<JSON
{"tag_name":"${tag}","target_commitish":"${target}","name":"Pilgrims of Katan ${tag}","body":"${note}","prerelease":${prerelease},"draft":false}
JSON
code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data @"$tmp/release.json" \
"$api/releases")"
# 422 is the usual answer when the tag already exists — a re-run of a
# workflow, or a second attempt by hand. Publishing into the release that is
# already there is the right thing to do, not a failure.
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"$api/releases/tags/${tag}")"
fi
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
echo "publish-release: creating release ${tag} failed (HTTP ${code})" >&2
cat "$tmp/create.json" >&2
echo >&2
exit 1
fi
# Gitea answers compact JSON, and the release's own id is the first "id" in
# it — the author's object comes later in the document.
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; }
# 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 \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${apk};filename=${apk_name}" \
"$api/releases/${id}/assets")"
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
echo "publish-release: attaching ${apk_name} to release ${id} failed (HTTP ${code})" >&2
cat "$tmp/asset.json" >&2
echo >&2
exit 1
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}"
+1 -1
View File
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "PilgrimsOfKatan" rootProject.name = "ProLibertateGames"
include(":app") include(":app")
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Not found</title></head>
<body><h1>404</h1><p>Not found. <a href="/">Pro Libertate Games downloads</a></p></body></html>
+7
View File
@@ -0,0 +1,7 @@
/*/latest.json
Cache-Control: no-cache
Access-Control-Allow-Origin: *
/*.apk
Content-Type: application/vnd.android.package-archive
Cache-Control: public, max-age=300
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pro Libertate Games</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
.card { border: 1px solid #8884; border-radius: .5rem; padding: 1rem; margin: 1rem 0; }
a.dl { display: inline-block; padding: .5rem 1rem; border-radius: .4rem; background: #2563eb; color: #fff; text-decoration: none; }
small { opacity: .7; }
</style>
</head>
<body>
<h1>Pro Libertate Games</h1>
<p>Android builds. Allow <em>Install unknown apps</em> for your browser to sideload.</p>
<div class="card" id="production"><h2>Production</h2><p>Loading…</p></div>
<div class="card" id="dev"><h2>Dev</h2><p>Loading…</p></div>
<script>
for (const [channel, path] of [["production", "prod"], ["dev", "dev"]]) {
const box = document.getElementById(channel);
const title = box.querySelector("h2").outerHTML;
fetch(`/${path}/latest.json`, { cache: "no-cache" })
.then(r => r.ok ? r.json() : Promise.reject())
.then(j => {
box.innerHTML = `${title}<p><a class="dl" href="${j.apkUrl}">Download ${j.tag}</a></p>` +
`<small>Always newest: <a href="/${path}/ProLibertateGames.apk">/${path}/ProLibertateGames.apk</a></small>`;
})
.catch(() => { box.innerHTML = `${title}<p>No build published yet.</p>`; });
}
</script>
</body>
</html>