Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f69f94c21 | ||
|
|
81feeee132 | ||
|
|
1f7fced24c | ||
|
|
3a930f5423 |
@@ -1,66 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
jq -n --arg tag "$tag" --arg name "$name" --arg ch "$channel" --arg url "$base/$channel/$name" \
|
|
||||||
'{tag: $tag, apkName: $name, apkUrl: $url, channel: $ch}' > "$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 ] && ! jq -e .apkName "$out/$other/latest.json" >/dev/null 2>&1; 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
|
|
||||||
other_name="$(jq -er .apkName "$out/$other/latest.json")"
|
|
||||||
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
|
|
||||||
+38
-79
@@ -1,28 +1,27 @@
|
|||||||
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 and triggers a
|
# dev or main, so breakage is caught before it reaches main.
|
||||||
# release.
|
|
||||||
#
|
#
|
||||||
# On a push to dev it also publishes the build as a dev-channel prerelease, so
|
# On a push to dev it also publishes the build as a dev-channel prerelease on
|
||||||
# the in-app updater can offer it to anyone who has switched to that channel.
|
# this Gitea instance, so the in-app updater can offer it to anyone on the dev
|
||||||
# Prereleases are used deliberately: GitHub's "latest release" endpoint skips
|
# channel. Prereleases are used deliberately: the "latest release" endpoint
|
||||||
# them, so a production-channel user never sees a dev build. Pull requests only
|
# skips them, so a production-channel user never sees a dev build. Pull
|
||||||
# build and check — they never publish, which keeps a PR from a fork from
|
# requests only build and check — they never publish, which keeps a PR from a
|
||||||
# shipping anything.
|
# fork from 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 to publish the dev-channel prerelease below.
|
# Needed by the job token to create the release and attach the APK.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -35,8 +34,6 @@ 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
|
||||||
@@ -44,10 +41,7 @@ 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. Stamping
|
# This condition has to stay in step with the publish steps' own.
|
||||||
# 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
|
||||||
@@ -56,7 +50,7 @@ jobs:
|
|||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: zulu
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
@@ -75,7 +69,7 @@ jobs:
|
|||||||
- name: Upload debug APK
|
- name: Upload debug APK
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ProLibertateGames-debug-apk
|
name: PilgrimsOfKatan-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
|
||||||
@@ -85,7 +79,7 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ProLibertateGames-reports
|
name: PilgrimsOfKatan-reports
|
||||||
path: |
|
path: |
|
||||||
app/build/reports/
|
app/build/reports/
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
@@ -95,74 +89,39 @@ 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 has always gated main. A pull request is still excluded
|
# release.yml gates main. A pull request is still excluded without
|
||||||
# without needing to name it: a PR build runs against refs/pull/N/merge,
|
# needing to name it: a PR build runs against refs/pull/N/merge, never
|
||||||
# never refs/heads/dev, so a fork still cannot ship anything.
|
# 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 comes from gradle.properties, which is the same file the
|
# The series and the offset come from gradle.properties, which is
|
||||||
# build read when it stamped the APK — so the tag and what is inside
|
# the same file the build read when it stamped the APK — so the tag
|
||||||
# it cannot name different versions.
|
# and what is inside it cannot name different versions. The number
|
||||||
|
# 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)"
|
||||||
test -n "$series"
|
|
||||||
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
||||||
|
test -n "$series"
|
||||||
test -n "$offset"
|
test -n "$offset"
|
||||||
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
|
stamp="$(( ${{ github.run_number }} + offset ))"
|
||||||
version="v${series}.$(( ${{ github.run_number }} + offset ))-dev"
|
version="v${series}.${stamp}-dev"
|
||||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||||
dest="ProLibertateGames-${version}.apk"
|
dest="PilgrimsOfKatan-${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: Upload staged dev APK for the Pages deploy
|
- name: Publish dev channel prerelease
|
||||||
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:
|
||||||
PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
|
# The job's built-in token: contents: write is declared above.
|
||||||
run: .github/scripts/stage-pages-site.sh dev "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ steps.stage.outputs.version }}
|
||||||
- name: Deploy
|
RELEASE_APK: ${{ steps.stage.outputs.apk }}
|
||||||
env:
|
RELEASE_KIND: prerelease
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
RELEASE_TARGET: ${{ github.sha }}
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
# One script, also runnable by hand — see docs/CI.md.
|
||||||
run: |
|
run: ./scripts/publish-release.sh
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
name: Build and Release APK
|
name: Build and Release APK
|
||||||
|
|
||||||
# Build an APK and publish it as a GitHub Release whenever commits land on main
|
# Production-channel build. Runs on a push to main, or by hand
|
||||||
# (for example when a pull request is merged). Can also be run manually.
|
# (workflow_dispatch), and publishes to this Gitea instance's releases — which
|
||||||
|
# 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
|
|
||||||
|
|
||||||
# Allow the built-in token to create releases and upload the APK.
|
# Needed by the job token to create the release and attach the APK.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -24,16 +23,13 @@ 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. Releases
|
# workflow_dispatch can be aimed at any branch from the Actions UI. The
|
||||||
# are what the in-app updater installs, so only main may produce one —
|
# production stamp belongs to main alone — anything else is a no-op rather
|
||||||
# anything else is a no-op rather than shipping unreviewed code to everyone.
|
# than shipping unreviewed code to everybody.
|
||||||
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, versioned with the run
|
# Stamps the APK as a production-channel build.
|
||||||
# number that matches the tag published below.
|
|
||||||
PLG_CHANNEL: production
|
PLG_CHANNEL: production
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -42,83 +38,51 @@ jobs:
|
|||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: zulu
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v4
|
uses: android-actions/setup-android@v4
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build production APK
|
||||||
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Stage APK for release
|
- name: Stage APK
|
||||||
id: stage
|
id: stage
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# The series comes from gradle.properties, which is the same file the
|
# The series and the offset come from gradle.properties, which is
|
||||||
# build read when it stamped the APK — so the tag and what is inside
|
# the same file the build read when it stamped the APK — so the tag
|
||||||
# it cannot name different versions.
|
# and what is inside it cannot name different versions. The number
|
||||||
|
# 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)"
|
||||||
test -n "$series"
|
|
||||||
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
||||||
|
test -n "$series"
|
||||||
test -n "$offset"
|
test -n "$offset"
|
||||||
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
|
stamp="$(( ${{ github.run_number }} + offset ))"
|
||||||
version="v${series}.$(( ${{ github.run_number }} + offset ))"
|
version="v${series}.${stamp}"
|
||||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||||
dest="ProLibertateGames-${version}.apk"
|
dest="PilgrimsOfKatan-${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: plg-prod-staged
|
name: PilgrimsOfKatan-apk
|
||||||
path: ${{ steps.stage.outputs.apk }}
|
path: ${{ steps.stage.outputs.apk }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
|
- name: Publish production release
|
||||||
# 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:
|
||||||
PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
|
# The job's built-in token: contents: write is declared above.
|
||||||
run: .github/scripts/stage-pages-site.sh prod "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ steps.stage.outputs.version }}
|
||||||
- name: Deploy
|
RELEASE_APK: ${{ steps.stage.outputs.apk }}
|
||||||
env:
|
RELEASE_KIND: release
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
RELEASE_TARGET: ${{ github.sha }}
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
# One script, also runnable by hand — see docs/CI.md.
|
||||||
run: |
|
run: ./scripts/publish-release.sh
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,120 +1,95 @@
|
|||||||
> **Moved to Gitea.** CI now runs on Gitea Actions at
|
# Working on this repo (agent notes)
|
||||||
> `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.
|
|
||||||
|
|
||||||
# Working on this repo with Claude
|
ProLibertateGames — Wallacenet's build of an Android collection of card and
|
||||||
|
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>.
|
||||||
|
|
||||||
## Nothing a session does triggers CI. Dispatch it explicitly.
|
## Ground rules
|
||||||
|
|
||||||
GitHub creates **no workflow run** for anything this sandbox does — pushing a
|
- **Licences travel.** `LICENSE` is GPL-3.0 and must stay. The Uncial Antiqua
|
||||||
branch, opening a pull request, or merging one. All of it goes through the same
|
font ships with its OFL licence at `docs/licenses/UncialAntiqua-OFL.txt`;
|
||||||
integration, and GitHub suppresses event-triggered runs from it. The pull
|
never delete or edit either. Attribution to the upstream lives in the README.
|
||||||
request will say it was opened by the repo owner; that is only how the UI
|
- **No Catan trademarks or brand assets** — the one hard line. The app's own
|
||||||
attributes it, and it does not change the suppression.
|
branding is the upstream's, restored (see README → Branding): tartan launcher
|
||||||
|
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.
|
||||||
|
|
||||||
This was measured, not assumed:
|
## Build
|
||||||
|
|
||||||
| Action | Result |
|
```sh
|
||||||
| --- | --- |
|
export JAVA_HOME=... # JDK 17
|
||||||
| Human pushes a Claude-authored commit (`d165e5ef`, `469c900e`) | CI runs |
|
export ANDROID_HOME=... # SDK with platform 34, build-tools 34
|
||||||
| Session pushes `dev` / `main` (`43394b8`, `742add4`) | no run |
|
./gradlew assembleDebug # dev-channel APK (default)
|
||||||
| Session opens a PR against `dev` (#1) | no run |
|
PLG_CHANNEL=production ./gradlew assembleDebug # production APK
|
||||||
| Session calls `workflow_dispatch` | **runs** |
|
./gradlew testDebugUnitTest # JVM unit tests (rules/AI/net/score)
|
||||||
|
./gradlew lintDebug # Android lint (CI runs it)
|
||||||
|
```
|
||||||
|
|
||||||
`workflow_dispatch` works because it creates a run directly instead of relying
|
On the Wallacenet project workspace a ready toolchain sits at
|
||||||
on an event. It is the only mechanism available from a session, so every build
|
`../.toolchain/` (`jdk17/`, `android-sdk/`) — export `JAVA_HOME` and
|
||||||
has to be asked for by hand.
|
`ANDROID_HOME` to it rather than installing again. Both variants must build:
|
||||||
|
they install side by side (different applicationId, launcher name, colour).
|
||||||
|
|
||||||
## Verify before merging, without publishing
|
## Channels and OTA
|
||||||
|
|
||||||
`ci.yml` builds, tests and lints on any ref, but its staging and publishing
|
One flag — `isDevBuild` in `app/build.gradle.kts` — drives applicationId,
|
||||||
steps are gated on `github.ref == 'refs/heads/dev'`. Dispatching it on a
|
launcher name and brand colour together. Never set them individually.
|
||||||
**feature branch** therefore gives the full check — build, unit tests, Android
|
|
||||||
lint — and publishes nothing.
|
|
||||||
|
|
||||||
So the working loop is:
|
In-app OTA points at this repo's own Gitea releases
|
||||||
|
(`/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.
|
||||||
|
|
||||||
1. Commit and push the feature branch.
|
## CI
|
||||||
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`.
|
|
||||||
|
|
||||||
Skipping step 4 leaves a branch whose head was never built and no release.
|
`.github/workflows/` — Actions-compatible so the Gitea instance runs them.
|
||||||
|
`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.
|
||||||
|
|
||||||
## Merging is a shipping action
|
**No Actions runner is registered on the instance yet**, so the workflows are
|
||||||
|
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.
|
||||||
|
|
||||||
Both long-lived branches publish, so a merge is a release, not just an
|
## Code map
|
||||||
integration:
|
|
||||||
|
|
||||||
- **`dev`** → `ci.yml` publishes a `v<series>.N-dev` **prerelease**, offered by the
|
- `game/` — rules engines and AI, **pure Kotlin, no Android types**, unit
|
||||||
in-app updater to anyone on the Dev channel.
|
tested. `Catalog.kt` is the registry (id, title, blurb, options, `available`).
|
||||||
- **`main`** → `release.yml` publishes a **production** release, which the
|
- `net/` — wire protocol, LAN transport (mDNS + TCP), lobby, host-authoritative
|
||||||
updater installs for everyone on the Production channel.
|
match driver; `score/` the scorekeeper sheet, also pure.
|
||||||
|
- `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.
|
||||||
|
|
||||||
Confirm before shipping to `main`. Note that `release.yml` runs `assembleDebug`
|
Host is authoritative over the wire; clients send intents, host re-validates
|
||||||
only — no tests, no lint — so a `main` release is only as verified as the `dev`
|
against the rules. Keep `game/` free of Android imports — the JVM tests depend
|
||||||
build it replays.
|
on it.
|
||||||
|
|
||||||
## Gradle does not run in the sandbox
|
## Git
|
||||||
|
|
||||||
`./gradlew` cannot resolve the Android plugin here: the proxy blocks
|
- Branches: `main` (production), `dev` (integration). Feature branches → PR
|
||||||
`dl.google.com` with a 403. No local build, no local test run, no local lint. A
|
into `dev`.
|
||||||
dispatched CI run is the only way to find out whether a change compiles.
|
- Commits by Paperclip agents end with
|
||||||
|
`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.
|
||||||
|
|
||||||
## A permanent fix, if you want one
|
## Legacy files
|
||||||
|
|
||||||
The suppression is a property of the sandbox's credential, so it cannot be
|
`.claude/` and `.rtk/` are the upstream author's assistant tooling, harmless
|
||||||
fixed from inside a session. Giving the environment a personal access token to
|
and left as inherited. This file supersedes the upstream's CLAUDE.md (which
|
||||||
push with would make pushes and PRs behave normally for everyone. Until then,
|
documented their GitHub sandbox; that history is still in git).
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
# Pro Libertate Games
|
# ProLibertateGames
|
||||||
|
|
||||||
A collection of card and board games for Android, playable against the computer
|
A collection of card and board games for Android, playable against the computer
|
||||||
or against other people over Wi-Fi (LAN).
|
or against other people over Wi-Fi (LAN).
|
||||||
|
|
||||||
|
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 |
|
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage, Hearts, Klondike, FreeCell, Spider, Pyramid |
|
||||||
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
|
| **Table** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Peg Solitaire, Yahtzee, Pirates and Bulgars, Ta Yü, Katan *(coming soon)* |
|
||||||
|
|
||||||
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
|
One entry is marked *Coming soon*: Katan, the island game, whose engine and
|
||||||
machinery for it is still there and still tested: a game listed in `GameCatalog`
|
screen land with their own task. The machinery is still there and still tested:
|
||||||
with `available = false` appears in the menu marked *Coming soon* **on dev builds
|
a game listed in `GameCatalog` with `available = false` appears in the menu
|
||||||
only**, so a production release lists just what can actually be played and nobody
|
marked *Coming soon* **on dev builds only**, so a production release lists just
|
||||||
installs it and taps into a dead end. The menu decides this from the installed
|
what can actually be played and nobody installs it and taps into a dead end. The
|
||||||
APK's own version name — the same source the updater uses to know which channel
|
menu decides this from the installed APK's own version name — the same source
|
||||||
it is on.
|
the updater uses to know which channel it is on.
|
||||||
|
|
||||||
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
||||||
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
|
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
|
||||||
@@ -111,55 +119,53 @@ 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. **There is no channel to
|
- **Updates** — check on launch, or check on demand. The channel is this
|
||||||
choose**: a build updates from its own channel and no other. A production
|
project's own Gitea releases ([docs/CI.md](docs/CI.md)), and **there is no
|
||||||
install is offered production builds from `main`; a dev install is offered dev
|
channel to choose**: a build updates from its own channel and no other. The
|
||||||
builds from `dev`. The settings screen states which one is running rather than
|
settings screen states which one is running rather than offering a switch.
|
||||||
offering a switch.
|
`BuildConfig.OTA_ENABLED` is the kill switch, not a preference.
|
||||||
|
|
||||||
The check on launch **only ever speaks when there is a build to install**. It
|
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 GitHub — says nothing, because opening
|
current — or a phone that cannot reach the release server — says nothing,
|
||||||
the app was not a question. The button in Settings is a question, so it
|
because opening the app was not a question. The button in Settings is a
|
||||||
answers every time, including "you're on the latest" and whatever went wrong.
|
question, so it answers every time, including "you're on the latest" and
|
||||||
|
whatever went wrong.
|
||||||
|
|
||||||
Wanting both means installing both — they are separate apps and each updates
|
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 launcher icon is a single square of the Wallace tartan set on the bias, and
|
The app wears the upstream's own branding, restored (WALA-118 review): the
|
||||||
the app opens on the same cloth carrying the name in a Celtic uncial hand for two
|
Wallace tartan is the launcher icon and the splash, drawn as vector drawables and
|
||||||
seconds before fading into the menu.
|
Compose code so it stays sharp at every density and needs no PNGs. The app opens
|
||||||
|
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 **Wallace Hunting** instead — the same threadcount with green
|
A dev build wears the **hunting green** sett where production wears the **clan
|
||||||
where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is
|
red**, and installs as *Pro Libertate Games DEV*. That is what tells the two
|
||||||
what tells the two copies apart when both are on the phone; see
|
copies apart when both are on the phone; see
|
||||||
[Both channels on one device](#both-channels-on-one-device).
|
[Both channels on one device](#both-channels-on-one-device). The tartan is drawn
|
||||||
|
by `ui/theme/Tartan.kt` (`TartanBackground`, `threadWidthFor`) off
|
||||||
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
|
`BuildConfig.DEV_BUILD`; the matching vector colours are generated per build by
|
||||||
warp and then as weft at half opacity, which is what produces the blended
|
`resValue` in `app/build.gradle.kts` — the icon vectors reference
|
||||||
squares instead of a flat grid. The icon is the same construction as a vector
|
`@color/tartan_field` rather than a literal, which is how one set of vectors
|
||||||
drawable, so it stays sharp at every density and needs no PNGs.
|
serves both colourways.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -183,122 +189,70 @@ 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 `org.prolibertate.games.dev` under
|
A local build is a dev build: it installs as
|
||||||
the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an
|
`ca.wallacenet.pilgrimsofkatan.dev` under the name *Pro Libertate Games DEV* on
|
||||||
installed release rather than over it. Set `PLG_CHANNEL=production` to build the
|
the hunting-green sett, so it goes on beside a production copy rather than over
|
||||||
production package instead.
|
it. Set `PLG_CHANNEL=production` to build the production package
|
||||||
|
(`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`. Its APK artifact is a dev build, versioned
|
requests into `dev` or `main`, and publishes a dev prerelease from `dev`.
|
||||||
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
- `.github/workflows/release.yml` — builds and publishes the production release
|
||||||
`gradle.properties`, currently **1.3**.
|
on push to `main`, or by hand (`workflow_dispatch`).
|
||||||
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
- Both publish through [scripts/publish-release.sh](scripts/publish-release.sh)
|
||||||
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
against Gitea's REST API, which is also runnable by hand. **No Actions runner
|
||||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
is registered on the instance yet**, so until one is, the local build is the
|
||||||
|
guaranteed path (`./gradlew assembleDebug && ./gradlew testDebugUnitTest`)
|
||||||
### Hosting: plg.wallacenet.ca
|
and releases go out via the script.
|
||||||
|
|
||||||
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>.<run number>`**. The series is set by hand, once, in
|
A version is **`<series>.<build number>`**. Both values are set by hand, once,
|
||||||
`gradle.properties`:
|
in `gradle.properties`:
|
||||||
|
|
||||||
```properties
|
```properties
|
||||||
plgVersionSeries=1.3
|
plgVersionSeries=1.4
|
||||||
|
plgVersionRunOffset=1000
|
||||||
```
|
```
|
||||||
|
|
||||||
Both workflows read that same property when they name a release tag, and the
|
Both workflows read those same properties when they name a release tag, and the
|
||||||
build reads it when it stamps the APK, so the tag and what is inside it cannot
|
build reads them when it stamps the APK, so the tag and what is inside it cannot
|
||||||
name different versions. Bumping a series is a one-line change to that file and
|
name different versions. Changing either is a one-line change to that file and
|
||||||
nothing else.
|
nothing else — but they are not equivalent:
|
||||||
|
|
||||||
The number after it is the CI run number and keeps climbing on its own, which is
|
- The **series** is a name for people. Bumping it changes nothing about
|
||||||
what makes a series bump safe: `versionCode` is the run number *alone*, so
|
ordering.
|
||||||
going from 1.0 to 1.1 never looks like a downgrade to Android and never
|
- The **build number** is what the machines compare: `versionCode`, the tag's
|
||||||
interrupts an OTA update. The series is a name for people; the run number is
|
trailing number, and the number in `versionName`, all the same figure —
|
||||||
what the machines compare.
|
`CI run number + plgVersionRunOffset`. Because the offset never changes and
|
||||||
|
the run number only climbs, going from 1.4 to 1.5 never looks like a downgrade
|
||||||
|
to Android and never interrupts an OTA update.
|
||||||
|
|
||||||
|
The offset exists because Gitea Actions restarts run numbers at 1 for each
|
||||||
|
workflow: without a floor, the first release of a fresh instance would collide
|
||||||
|
with the `versionCode 1` every unstamped local build carries, and a counter
|
||||||
|
reset would make Android refuse the next build outright. Full reasoning in
|
||||||
|
[docs/CI.md](docs/CI.md).
|
||||||
|
|
||||||
### Update channels
|
### Update channels
|
||||||
|
|
||||||
Both pipelines publish, to two separate channels:
|
`ci.yml` stamps builds of `dev` as dev-channel, `release.yml` stamps `main` as
|
||||||
|
production, so an APK's identity is decided where it is built. Publishing goes
|
||||||
|
to this project's Gitea releases, and the rules it keeps:
|
||||||
|
|
||||||
| Channel | Published by | Tag | Marked |
|
- production comes only from `main` (`release.yml` refuses any other branch);
|
||||||
| --- | --- | --- | --- |
|
- dev prereleases are marked so the "latest release" endpoint can never hand one
|
||||||
| Production | `release.yml`, push to `main` | `v<series>.<n>` | release |
|
to a production install;
|
||||||
| Dev | `ci.yml`, any build of `dev` | `v<series>.<n>-dev` | prerelease |
|
- pull requests build and check but never publish.
|
||||||
|
|
||||||
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 run number of the workflow that produced it, and the two workflows count
|
is the build number of the workflow that produced it (run number + offset), and
|
||||||
separately. Dev is on 69 while production is on 40, so the numbers say nothing
|
the two workflows count separately, so the numbers say nothing across the
|
||||||
across the divide.
|
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
|
||||||
@@ -310,18 +264,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`, `org.prolibertate.games.dev`,
|
A dev build installs under its own `applicationId`,
|
||||||
so it is a separate app as far as Android is concerned and can sit beside the
|
`ca.wallacenet.pilgrimsofkatan.dev`, so it is a separate app as far as Android
|
||||||
production copy rather than replacing it. Each channel still updates itself
|
is concerned and can sit beside the production copy rather than replacing it. Each channel still updates itself
|
||||||
normally, because an update within a channel keeps the same package.
|
normally, because an update within a channel keeps the same package.
|
||||||
|
|
||||||
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` | `org.prolibertate.games` | `org.prolibertate.games.dev` |
|
| `applicationId` | `ca.wallacenet.pilgrimsofkatan` | `ca.wallacenet.pilgrimsofkatan.dev` |
|
||||||
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
|
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
|
||||||
| Sett | Wallace (red) | Wallace Hunting (green) |
|
| Tartan sett | clan red | hunting green |
|
||||||
|
|
||||||
Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build,
|
Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build,
|
||||||
so a local `assembleDebug` is a dev build too and installs alongside a release
|
so a local `assembleDebug` is a dev build too and installs alongside a release
|
||||||
@@ -331,11 +285,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`,
|
||||||
`tartan_field`, `splash_background` and `ic_launcher_background` are generated
|
`brand_field` and `splash_background` are generated by the build and are
|
||||||
by the build and are deliberately *not* in `res/values/` — the icon vectors
|
deliberately *not* in `res/values/` — the icon vectors reference
|
||||||
reference `@color/tartan_field` rather than a literal, which is how one set of
|
`@color/brand_field` rather than a literal, which is how one set of vectors
|
||||||
vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
|
serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag into
|
||||||
into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
|
Kotlin, where `BrandField` picks the matching Compose colour for the splash.
|
||||||
|
|
||||||
Because they are separate apps, having both is a matter of installing both, and
|
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
|
||||||
@@ -349,7 +303,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:
|
game (index: [docs/README.md](docs/README.md)):
|
||||||
|
|
||||||
- [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**
|
||||||
@@ -372,6 +326,8 @@ game:
|
|||||||
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
||||||
the game is out of print and the rulebook could not be obtained, so the
|
the game is out of print and the rulebook could not be obtained, so the
|
||||||
document says which parts are attested and which were derived
|
document says which parts are attested and which were derived
|
||||||
|
- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the
|
||||||
|
rules engine and AI are in place, the screen lands with its own task
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -394,10 +350,12 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
|||||||
mastermind/ Mastermind codes, scoring, and a code breaker
|
mastermind/ Mastermind codes, scoring, and a code breaker
|
||||||
pirates/ Pirates and Bulgars — the cross board, the hunt, search
|
pirates/ Pirates and Bulgars — the cross board, the hunt, search
|
||||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||||
|
catan/ Katan island geometry, production, trade, dev cards, heuristic AI
|
||||||
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/ Cloudflare Pages OTA updater, and the channel rule — pure, tested
|
update/ OTA updater against this repo's Gitea releases, and the
|
||||||
|
channel rule — pure, tested
|
||||||
ui/ Compose screens
|
ui/ Compose screens
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+28
-9
@@ -16,10 +16,18 @@ 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
|
||||||
|
|
||||||
// See plgVersionRunOffset in gradle.properties: the stamped build number is the
|
// The fixed floor every stamped build number is measured from, in
|
||||||
// CI run number plus a permanent floor, so a counter that restarts on a new CI
|
// gradle.properties. Gitea Actions restarts its run numbers at 1 for each
|
||||||
// host can never produce a lower versionCode than one already installed.
|
// workflow, so an unstamped build (versionCode 1) and the first release of a
|
||||||
|
// 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
|
||||||
@@ -38,25 +46,30 @@ 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. The namespace above is deliberately left alone:
|
// replacing the other.
|
||||||
// R and BuildConfig stay in org.prolibertate.games for both, so no
|
applicationId = if (isDevBuild) "ca.wallacenet.pilgrimsofkatan.dev" else "ca.wallacenet.pilgrimsofkatan"
|
||||||
// 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 stays the run number and nothing else, so it keeps
|
// versionCode is the stamped build 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 update look like a downgrade to Android. An unstamped build is 1,
|
||||||
|
// 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"
|
||||||
@@ -87,6 +100,12 @@ 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,7 +1,9 @@
|
|||||||
<?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">
|
||||||
|
|
||||||
<!-- OTA updates from plg.wallacenet.ca, plus LAN play over TCP sockets. -->
|
<!-- LAN play over TCP sockets, plus hand-a-down permission for in-app
|
||||||
|
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. -->
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ object GameCatalog {
|
|||||||
const val MORRIS = "morris"
|
const val MORRIS = "morris"
|
||||||
const val PIRATES = "pirates"
|
const val PIRATES = "pirates"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The island game the collection is named for. Listed under Board Games
|
||||||
|
* and marked *Coming soon*; the engine and its screen land with the game
|
||||||
|
* task, and `available` flips on once two-device play is verified.
|
||||||
|
*/
|
||||||
|
const val CATAN = "catan"
|
||||||
|
|
||||||
val all: List<GameDescriptor> = listOf(
|
val all: List<GameDescriptor> = listOf(
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = EUCHRE,
|
id = EUCHRE,
|
||||||
@@ -333,6 +340,19 @@ object GameCatalog {
|
|||||||
"because your score multiplies them.",
|
"because your score multiplies them.",
|
||||||
available = true,
|
available = true,
|
||||||
),
|
),
|
||||||
|
GameDescriptor(
|
||||||
|
id = CATAN,
|
||||||
|
title = "Katan",
|
||||||
|
menus = setOf(GameMenu.BOARD),
|
||||||
|
minPlayers = 3,
|
||||||
|
maxPlayers = 4,
|
||||||
|
teamBased = false,
|
||||||
|
blurb = "Trade, build, and settle the island.",
|
||||||
|
// Listed, not playable: the engine and screen land with the Katan
|
||||||
|
// game task. Dev builds show the entry as *Coming soon*; a
|
||||||
|
// production build lists only what can actually be played.
|
||||||
|
available = false,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
|
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heuristic Catan opponent.
|
||||||
|
*
|
||||||
|
* It plays a legal, mildly strategic game: it settles on the best-producing
|
||||||
|
* intersections it can reach, raises cities, chases Largest Army with its
|
||||||
|
* knights, spends its progress cards when they complete something, trades
|
||||||
|
* its surplus to the bank or the table to close a one-card gap, and robs
|
||||||
|
* whoever is winning.
|
||||||
|
*
|
||||||
|
* Honesty is a design constraint, not an accident: this runs on the host,
|
||||||
|
* where the authoritative state is fully visible. It reads only public
|
||||||
|
* information — terrain, numbers, buildings, roads, resource counts,
|
||||||
|
* dev-card counts, knights played — plus its own hand, and never looks
|
||||||
|
* into the deck, another seat's development cards, or the seed beyond the
|
||||||
|
* roll the rules derive from it.
|
||||||
|
*/
|
||||||
|
class CatanAi : GameAi<CatanState, CatanMove> {
|
||||||
|
|
||||||
|
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
|
||||||
|
"No legal move for seat $seat"
|
||||||
|
}
|
||||||
|
return when (state.phase) {
|
||||||
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||||
|
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
||||||
|
|
||||||
|
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
|
||||||
|
|
||||||
|
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
|
||||||
|
|
||||||
|
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
|
||||||
|
|
||||||
|
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||||
|
?: error("The game is over; nobody chooses a move")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Setup
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
|
||||||
|
require(places.isNotEmpty()) { "No legal settlement placement" }
|
||||||
|
return places.maxByOrNull { place ->
|
||||||
|
spotValue(state, place.vertex) +
|
||||||
|
0.25 * placeDirection(state, place)
|
||||||
|
} ?: places.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the setup road points: the best settlement spot two edges out
|
||||||
|
* through the far end of the road. The vertex next to the new
|
||||||
|
* settlement can never hold one (the distance rule), so what a road is
|
||||||
|
* worth is what it reaches past it.
|
||||||
|
*/
|
||||||
|
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
|
||||||
|
val far = CatanBoard.otherEnd(place.edge, place.vertex)
|
||||||
|
return CatanBoard.VERTEX_NEIGHBORS[far]
|
||||||
|
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
|
||||||
|
.maxOfOrNull { spotValue(state, it) }
|
||||||
|
?: 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Dice, discards and the robber
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseDiscard(state: CatanState, seat: Int, discards: List<Discard>): Discard {
|
||||||
|
require(discards.isNotEmpty()) { "No legal discard" }
|
||||||
|
// Throw away what it wants least; grain and ore are the city, wool
|
||||||
|
// is mostly a dev-card tax.
|
||||||
|
return discards.minByOrNull { discard ->
|
||||||
|
discard.cards.sumOf { want(it) }
|
||||||
|
} ?: discards.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
|
||||||
|
require(moves.isNotEmpty()) { "No legal robber move" }
|
||||||
|
val points = CatanRules.publicVictoryPoints(state)
|
||||||
|
return moves.maxByOrNull { move ->
|
||||||
|
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||||
|
var score = 0.0
|
||||||
|
for (v in CatanBoard.HEX_CORNERS[hex]) {
|
||||||
|
val building = state.buildings[v] ?: continue
|
||||||
|
if (building.owner == seat) continue
|
||||||
|
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||||
|
score += worth * CatanBoard.numberOdds(state.numbers[hex]) / 36.0
|
||||||
|
}
|
||||||
|
if (move.victim != null) {
|
||||||
|
score += 0.15 * state.resourceCount(move.victim) +
|
||||||
|
0.3 * points[move.victim]
|
||||||
|
}
|
||||||
|
score
|
||||||
|
} ?: moves.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// The main phase
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
// A city doubles the best production the seat already owns.
|
||||||
|
legal.filterIsInstance<BuildCity>().maxByOrNull {
|
||||||
|
spotValue(state, it.vertex)
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildSettlement>().maxByOrNull {
|
||||||
|
spotValue(state, it.vertex)
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
// A knight is worth playing whenever one is playable: it blocks an
|
||||||
|
// opponent's best hex and marches toward Largest Army.
|
||||||
|
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
|
||||||
|
|
||||||
|
yearOfPlenty(state, legal)?.let { return it }
|
||||||
|
|
||||||
|
monopoly(state, legal)?.let { return it }
|
||||||
|
|
||||||
|
// Road Building is worth two free segments whenever there is
|
||||||
|
// somewhere to put them.
|
||||||
|
if (legal.filterIsInstance<PlayRoadBuilding>().isNotEmpty() &&
|
||||||
|
legal.filterIsInstance<BuildRoad>().size >= 2
|
||||||
|
) {
|
||||||
|
return legal.filterIsInstance<PlayRoadBuilding>().first()
|
||||||
|
}
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
|
||||||
|
|
||||||
|
// A paid road only when it reaches toward a spot worth settling.
|
||||||
|
legal.filterIsInstance<BuildRoad>()
|
||||||
|
.filter { roadValue(state, it) > 0.1 }
|
||||||
|
.maxByOrNull { roadValue(state, it) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
// A bank trade only when it completes a build this turn; trading
|
||||||
|
// four for one on spec is how hands evaporate.
|
||||||
|
legal.filterIsInstance<MaritimeTrade>()
|
||||||
|
.filter { completesBuild(state, seat, it) }
|
||||||
|
.maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
offer(state, seat)?.let { return it }
|
||||||
|
|
||||||
|
return legal.lastOrNull { it is EndTurn } ?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year of Plenty: two of whatever the nearest build is shortest of. */
|
||||||
|
private fun yearOfPlenty(state: CatanState, legal: List<CatanMove>): PlayYearOfPlenty? {
|
||||||
|
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
|
||||||
|
if (takes.isEmpty()) return null
|
||||||
|
val shortage = shortage(state) ?: return null
|
||||||
|
return takes.maxByOrNull { take ->
|
||||||
|
take.take.count { it == shortage } * 2 + take.take.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monopoly: declare the resource everyone else is hoarding, of the ones wanted. */
|
||||||
|
private fun monopoly(state: CatanState, legal: List<CatanMove>): PlayMonopoly? {
|
||||||
|
val plays = legal.filterIsInstance<PlayMonopoly>()
|
||||||
|
if (plays.isEmpty()) return null
|
||||||
|
val best = plays.maxByOrNull {
|
||||||
|
19 - CatanRules.bankSupply(state, it.resource) + want(it.resource)
|
||||||
|
} ?: return null
|
||||||
|
// Only worth the card when the table is actually holding a pile.
|
||||||
|
return if (19 - CatanRules.bankSupply(state, best.resource) >= 10) best else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the seat is shortest of for its next city, else its next settlement. */
|
||||||
|
private fun shortage(state: CatanState): Resource? {
|
||||||
|
val seat = state.turn
|
||||||
|
val targets = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
|
||||||
|
for (cost in targets) {
|
||||||
|
val missing = cost.entries.filter { (res, need) ->
|
||||||
|
(state.resources[seat][res] ?: 0) < need
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
return missing.maxByOrNull { (res, need) -> need - (state.resources[seat][res] ?: 0) }?.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun completesBuild(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
|
||||||
|
val hand = state.resources[seat].toMutableMap()
|
||||||
|
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
|
||||||
|
hand[move.give] = (hand[move.give] ?: 0) - ratio
|
||||||
|
hand[move.get] = (hand[move.get] ?: 0) + 1
|
||||||
|
return covers(hand, CatanRules.CITY_COST) || covers(hand, CatanRules.SETTLEMENT_COST)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun covers(hand: Map<Resource, Int>, cost: Map<Resource, Int>): Boolean =
|
||||||
|
cost.all { (res, need) -> (hand[res] ?: 0) >= need }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A domestic offer for exactly the card that closes a build, paid for
|
||||||
|
* with two of the surplus — offered to whoever visibly holds the most
|
||||||
|
* of what is missing. Constructed rather than enumerated (see
|
||||||
|
* [OfferTrade]), so it is checked against the rules before it is spoken.
|
||||||
|
*
|
||||||
|
* At most one offer a turn: a declined offer changes nothing, so
|
||||||
|
* re-offering it would only bounce between the same two seats forever.
|
||||||
|
*/
|
||||||
|
private fun offer(state: CatanState, seat: Int): OfferTrade? {
|
||||||
|
if (offeredThisTurn(state, seat)) return null
|
||||||
|
val seatHand = state.resources[seat]
|
||||||
|
val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
|
||||||
|
for (cost in builds) {
|
||||||
|
val missing = cost.entries.filter { (res, need) -> (seatHand[res] ?: 0) < need }
|
||||||
|
if (missing.size != 1) continue
|
||||||
|
val need = missing.first().key
|
||||||
|
val surplus = Resource.entries
|
||||||
|
.filter { it != need }
|
||||||
|
.filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) }
|
||||||
|
.sortedByDescending { seatHand[it] ?: 0 }
|
||||||
|
if (surplus.isEmpty()) continue
|
||||||
|
val target = (0 until state.playerCount)
|
||||||
|
.filter { it != seat }
|
||||||
|
.filter { (state.resources[it][need] ?: 0) > 0 }
|
||||||
|
.maxByOrNull { state.resources[it][need] ?: 0 }
|
||||||
|
?: continue
|
||||||
|
val give = listOf(surplus.first(), surplus.first())
|
||||||
|
val trade = OfferTrade(to = target, give = give, get = listOf(need))
|
||||||
|
if (CatanRules.isLegalMove(state, seat, trade)) return trade
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [seat] has already put an offer on the table this turn, read
|
||||||
|
* from the public log: the lines after this seat's last roll are this
|
||||||
|
* turn's, and an offer can only be spoken on the offerer's own turn.
|
||||||
|
*/
|
||||||
|
private fun offeredThisTurn(state: CatanState, seat: Int): Boolean {
|
||||||
|
val sinceRoll = state.log
|
||||||
|
.drop(state.log.indexOfLast { it.startsWith("Seat $seat rolls") } + 1)
|
||||||
|
return sinceRoll.any { it.startsWith("Seat $seat offers") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Free roads and trade responses
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val roads = legal.filterIsInstance<BuildRoad>()
|
||||||
|
.filter { roadValue(state, it) > 0.05 }
|
||||||
|
.maxByOrNull { roadValue(state, it) }
|
||||||
|
return roads ?: legal.firstOrNull { it is EndFreeRoads }
|
||||||
|
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
|
||||||
|
?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull() ?: run {
|
||||||
|
return legal.first { it is DeclineTrade }
|
||||||
|
}
|
||||||
|
val offer = state.pendingTrade ?: return legal.first()
|
||||||
|
// Accept when what arrives is worth more than what leaves: a mild
|
||||||
|
// table, not a shark.
|
||||||
|
val incoming = offer.get.sumOf { want(it) }
|
||||||
|
val outgoing = offer.give.sumOf { want(it) }
|
||||||
|
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Valuations
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What a seat would think of building on [vertex]: production and ports. */
|
||||||
|
private fun spotValue(state: CatanState, vertex: Int): Double {
|
||||||
|
if (state.buildings[vertex] != null) return -1.0
|
||||||
|
var value = 0.0
|
||||||
|
val kinds = mutableSetOf<Resource>()
|
||||||
|
for (h in CatanBoard.VERTEX_HEXES[vertex]) {
|
||||||
|
val resource = state.terrains[h].produces ?: continue
|
||||||
|
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
|
||||||
|
kinds += resource
|
||||||
|
}
|
||||||
|
value += 0.12 * kinds.size // variety keeps a build plan alive
|
||||||
|
for (e in CatanBoard.VERTEX_EDGES[vertex]) {
|
||||||
|
val port = state.ports.firstOrNull { it.edge == e } ?: continue
|
||||||
|
value += if (port.resource == null) 0.1 else 0.2
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a road on this edge leads to: the best settlement spot it brings
|
||||||
|
* within reach, discounted for being one step further out.
|
||||||
|
*/
|
||||||
|
private fun roadValue(state: CatanState, move: BuildRoad): Double {
|
||||||
|
var best = 0.0
|
||||||
|
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
|
||||||
|
for (endpoint in listOf(from, to)) {
|
||||||
|
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
|
||||||
|
best = maxOf(best, spotValue(state, near) * 0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much the AI wants to hold one card of [resource]. */
|
||||||
|
private fun want(resource: Resource): Double = when (resource) {
|
||||||
|
Resource.ORE -> 1.20 // the city
|
||||||
|
Resource.GRAIN -> 1.15 // the city and the dev cards
|
||||||
|
Resource.LUMBER -> 1.0 // every road and settlement
|
||||||
|
Resource.BRICK -> 0.95
|
||||||
|
Resource.WOOL -> 0.85 // abundant and only half of a dev card
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
|
||||||
|
* shared edges, the coast and the nine harbour positions.
|
||||||
|
*
|
||||||
|
* Everything is derived once, deterministically, from axial coordinates —
|
||||||
|
* there is no randomness and no state here; the layout of a particular
|
||||||
|
* game (which terrain and number sits on which hex) is [generate], and the
|
||||||
|
* live state lives in [CatanState].
|
||||||
|
*
|
||||||
|
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
|
||||||
|
* position is kept on an exact integer lattice so vertices shared by
|
||||||
|
* neighbouring hexes are recognised by position instead of by floating
|
||||||
|
* point: the centre of hex (q, r) is at lattice point (2q + r, 3r), and its
|
||||||
|
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
|
||||||
|
* in half-units of the hex grid; rendering scales and flips them (lattice
|
||||||
|
* +Y is toward the top of the board).
|
||||||
|
*/
|
||||||
|
object CatanBoard {
|
||||||
|
|
||||||
|
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
|
||||||
|
val HEXES: List<HexCoord> = buildList {
|
||||||
|
for (r in -2..2) {
|
||||||
|
for (q in -2..2) {
|
||||||
|
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val HEX_COUNT: Int = HEXES.size
|
||||||
|
|
||||||
|
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
|
||||||
|
* corner 0 is the top corner, then clockwise.
|
||||||
|
*/
|
||||||
|
private val CORNER_OFFSETS = listOf(
|
||||||
|
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Unit steps to a hex's six neighbours, matching the corner order. */
|
||||||
|
private val NEIGHBOR_STEPS = listOf(
|
||||||
|
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The six board-corner hexes: pairwise non-adjacent by construction. */
|
||||||
|
private val CORNER_HEXES = listOf(
|
||||||
|
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
|
||||||
|
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Vertices and edges
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Lattice position of each vertex, by vertex id. */
|
||||||
|
val VERTEX_POS: List<Pair<Int, Int>>
|
||||||
|
|
||||||
|
val VERTEX_COUNT: Int
|
||||||
|
|
||||||
|
/** Vertex ids of hex h's six corners, corner 0 first (see [CORNER_OFFSETS]). */
|
||||||
|
val HEX_CORNERS: List<List<Int>>
|
||||||
|
|
||||||
|
/** Both endpoints of each edge, as vertex ids, low id first. */
|
||||||
|
val EDGE_VERTICES: List<Pair<Int, Int>>
|
||||||
|
|
||||||
|
val EDGE_COUNT: Int
|
||||||
|
|
||||||
|
/** Edges touching each vertex. */
|
||||||
|
val VERTEX_EDGES: List<List<Int>>
|
||||||
|
|
||||||
|
/** Vertices one edge away from each vertex: the distance rule's horizon. */
|
||||||
|
val VERTEX_NEIGHBORS: List<List<Int>>
|
||||||
|
|
||||||
|
/** Hexes touching each vertex. */
|
||||||
|
val VERTEX_HEXES: List<List<Int>>
|
||||||
|
|
||||||
|
/** Hexes touching each edge: two for interior edges, one on the coast. */
|
||||||
|
val EDGE_HEXES: List<List<Int>>
|
||||||
|
|
||||||
|
/** Edges adjacent to each edge (sharing a vertex). */
|
||||||
|
val EDGE_NEIGHBORS: List<List<Int>>
|
||||||
|
|
||||||
|
/** Hexes adjacent to each hex, in [NEIGHBOR_STEPS] order. */
|
||||||
|
val HEX_NEIGHBORS: List<List<Int>>
|
||||||
|
|
||||||
|
/** The coastal edges (those with only one hex) in ring order. */
|
||||||
|
val COASTAL: List<Int>
|
||||||
|
|
||||||
|
/** The nine fixed harbour positions, spread around the coast. */
|
||||||
|
val PORT_POSITIONS: List<Int>
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Vertices: assign ids in first-seen order over hexes then corners.
|
||||||
|
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
|
||||||
|
val vertexPos = mutableListOf<Pair<Int, Int>>()
|
||||||
|
val hexCorners = HEXES.map { hex ->
|
||||||
|
val cx = 2 * hex.q + hex.r
|
||||||
|
val cy = 3 * hex.r
|
||||||
|
CORNER_OFFSETS.map { (dx, dy) ->
|
||||||
|
val pos = (cx + dx) to (cy + dy)
|
||||||
|
vertexIds.getOrPut(pos) {
|
||||||
|
vertexPos += pos
|
||||||
|
vertexPos.size - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VERTEX_POS = vertexPos
|
||||||
|
VERTEX_COUNT = vertexPos.size
|
||||||
|
HEX_CORNERS = hexCorners
|
||||||
|
|
||||||
|
// Edges between a hex's consecutive corners, deduplicated by the
|
||||||
|
// pair of vertex ids.
|
||||||
|
val edgeIds = mutableMapOf<Pair<Int, Int>, Int>()
|
||||||
|
val edgeVertices = mutableListOf<Pair<Int, Int>>()
|
||||||
|
hexCorners.forEach { corners ->
|
||||||
|
for (k in 0..5) {
|
||||||
|
val a = corners[k]
|
||||||
|
val b = corners[(k + 1) % 6]
|
||||||
|
val key = minOf(a, b) to maxOf(a, b)
|
||||||
|
edgeIds.getOrPut(key) {
|
||||||
|
edgeVertices += key
|
||||||
|
edgeVertices.size - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EDGE_VERTICES = edgeVertices
|
||||||
|
EDGE_COUNT = edgeVertices.size
|
||||||
|
|
||||||
|
val vertexEdges = List(VERTEX_COUNT) { mutableListOf<Int>() }
|
||||||
|
val vertexHexes = List(VERTEX_COUNT) { mutableSetOf<Int>() }
|
||||||
|
val edgeHexes = List(EDGE_COUNT) { mutableSetOf<Int>() }
|
||||||
|
hexCorners.forEachIndexed { h, corners ->
|
||||||
|
corners.forEach { v -> vertexHexes[v] += h }
|
||||||
|
for (k in 0..5) {
|
||||||
|
val a = corners[k]
|
||||||
|
val b = corners[(k + 1) % 6]
|
||||||
|
val key = minOf(a, b) to maxOf(a, b)
|
||||||
|
val e = edgeIds.getValue(key)
|
||||||
|
edgeHexes[e] += h
|
||||||
|
vertexEdges[a] += e
|
||||||
|
vertexEdges[b] += e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Distinct: an interior edge is walked once from each of the two hexes
|
||||||
|
// it separates, so without this every shared edge would be counted
|
||||||
|
// twice and an interior vertex would appear to have six edges.
|
||||||
|
VERTEX_EDGES = vertexEdges.map { it.distinct() }
|
||||||
|
VERTEX_HEXES = vertexHexes.map { it.sorted() }
|
||||||
|
EDGE_HEXES = edgeHexes.map { it.sorted() }
|
||||||
|
VERTEX_NEIGHBORS = List(VERTEX_COUNT) { v ->
|
||||||
|
VERTEX_EDGES[v].map { otherEnd(it, v) }.distinct().sorted()
|
||||||
|
}
|
||||||
|
EDGE_NEIGHBORS = List(EDGE_COUNT) { e ->
|
||||||
|
val (a, b) = EDGE_VERTICES[e]
|
||||||
|
(VERTEX_EDGES[a] + VERTEX_EDGES[b]).distinct().filter { it != e }.sorted()
|
||||||
|
}
|
||||||
|
HEX_NEIGHBORS = HEXES.map { hex ->
|
||||||
|
NEIGHBOR_STEPS.mapNotNull { (dq, dr) ->
|
||||||
|
hexIndex[HexCoord(hex.q + dq, hex.r + dr)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The coast in walking order: sort the coastal edges by the angle of
|
||||||
|
// their midpoint around the board centre, which is the lattice
|
||||||
|
// origin. The board is a hexagon, so this yields one closed ring.
|
||||||
|
COASTAL = EDGE_HEXES.withIndex()
|
||||||
|
.filter { it.value.size == 1 }
|
||||||
|
.map { (e, _) -> e }
|
||||||
|
.sortedBy { e -> angleOf(midpoint(e)) }
|
||||||
|
// Harbours sit roughly every third or fourth coastal edge, like the
|
||||||
|
// physical frame: nine positions, steps summing to the whole ring.
|
||||||
|
PORT_POSITIONS = buildList {
|
||||||
|
var at = 0
|
||||||
|
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
|
||||||
|
add(COASTAL[at % COASTAL.size])
|
||||||
|
at += step
|
||||||
|
}
|
||||||
|
}.distinct()
|
||||||
|
|
||||||
|
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
|
||||||
|
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
|
||||||
|
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
|
||||||
|
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
|
||||||
|
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
|
||||||
|
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The other endpoint of [edge], given one [vertex] on it. */
|
||||||
|
fun otherEnd(edge: Int, vertex: Int): Int {
|
||||||
|
val (a, b) = EDGE_VERTICES[edge]
|
||||||
|
return if (vertex == a) b else a
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lattice position of hex h's centre. */
|
||||||
|
fun hexCenter(h: Int): Pair<Int, Int> {
|
||||||
|
val hex = HEXES[h]
|
||||||
|
return (2 * hex.q + hex.r) to (3 * hex.r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two hexes share an edge. */
|
||||||
|
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
|
||||||
|
|
||||||
|
/** Hex-grid distance between two axial positions. */
|
||||||
|
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
|
||||||
|
val dq = q1 - q2
|
||||||
|
val dr = r1 - r2
|
||||||
|
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often a number is expected to come up, in thirty-sixths. A 6 or an
|
||||||
|
* 8 comes up five times in thirty-six, a 2 or a 12 once.
|
||||||
|
*/
|
||||||
|
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
|
||||||
|
|
||||||
|
private fun midpoint(edge: Int): Pair<Double, Double> {
|
||||||
|
val (a, b) = EDGE_VERTICES[edge]
|
||||||
|
val pa = VERTEX_POS[a]
|
||||||
|
val pb = VERTEX_POS[b]
|
||||||
|
return ((pa.first + pb.first) / 2.0) to ((pa.second + pb.second) / 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun angleOf(point: Pair<Double, Double>): Double =
|
||||||
|
atan2(point.second, point.first)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Island generation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The island layout for one game: terrain and number per hex, the robber
|
||||||
|
* on the desert, and the nine harbours.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Generated(
|
||||||
|
val terrains: List<Terrain>,
|
||||||
|
/** 0 where there is no token (the desert). */
|
||||||
|
val numbers: List<Int>,
|
||||||
|
val robber: Int,
|
||||||
|
val ports: List<Port>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The terrain tile mix: four forests, four pastures, four fields, three hills, three mountains, one desert. */
|
||||||
|
private val TERRAIN_TILES: List<Terrain> = listOf(
|
||||||
|
Terrain.FOREST, Terrain.FOREST, Terrain.FOREST, Terrain.FOREST,
|
||||||
|
Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE,
|
||||||
|
Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS,
|
||||||
|
Terrain.HILLS, Terrain.HILLS, Terrain.HILLS,
|
||||||
|
Terrain.MOUNTAINS, Terrain.MOUNTAINS, Terrain.MOUNTAINS,
|
||||||
|
Terrain.DESERT,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The 18 number tokens, the two hardest numbers withheld: 7 never appears. */
|
||||||
|
private val RED_TOKENS = listOf(6, 6, 8, 8)
|
||||||
|
private val OTHER_TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
|
||||||
|
|
||||||
|
/** Shuffle attempts before falling back to the corner placement for 6s and 8s. */
|
||||||
|
private const val RED_ATTEMPTS = 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the island from [seed].
|
||||||
|
*
|
||||||
|
* Terrain is shuffled over the hexes, exactly as shuffling the land
|
||||||
|
* tiles face down does. Number tokens are shuffled over the land hexes
|
||||||
|
* with one repair rule: no two of the red tokens (6 and 8) may sit on
|
||||||
|
* adjacent hexes, the balance convention the published game asks for.
|
||||||
|
* The robber starts on the desert. Harbour *positions* are fixed (the
|
||||||
|
* physical frame is not shuffled); the nine harbour *types* — five
|
||||||
|
* 2:1 resource ports and four 3:1 ports — are shuffled onto them.
|
||||||
|
*
|
||||||
|
* The 6/8 separation is guaranteed, not merely probable: if no shuffled
|
||||||
|
* placement separates them (about one shuffle in five does), the red
|
||||||
|
* tokens go to four of the six board-corner hexes, which are pairwise
|
||||||
|
* non-adjacent, and at most one corner can be desert.
|
||||||
|
*/
|
||||||
|
fun generate(seed: Long): Generated {
|
||||||
|
val random = Random(seed)
|
||||||
|
val terrains = TERRAIN_TILES.shuffled(random)
|
||||||
|
val desert = terrains.indexOf(Terrain.DESERT)
|
||||||
|
val land = terrains.indices.filter { it != desert }
|
||||||
|
|
||||||
|
var redHexes: List<Int>? = null
|
||||||
|
var attempt = 0
|
||||||
|
while (redHexes == null && attempt < RED_ATTEMPTS) {
|
||||||
|
val candidate = land.shuffled(random).take(4)
|
||||||
|
val separated = candidate.withIndex().all { (i, a) ->
|
||||||
|
candidate.drop(i + 1).none { hexesAdjacent(a, it) }
|
||||||
|
}
|
||||||
|
if (separated) redHexes = candidate
|
||||||
|
attempt++
|
||||||
|
}
|
||||||
|
val reds = redHexes ?: CORNER_HEXES
|
||||||
|
.mapNotNull { hexIndex[it] }
|
||||||
|
.filter { it != desert }
|
||||||
|
.shuffled(random)
|
||||||
|
.take(4)
|
||||||
|
|
||||||
|
val numbers = IntArray(HEX_COUNT)
|
||||||
|
RED_TOKENS.forEachIndexed { i, token -> numbers[reds[i]] = token }
|
||||||
|
val rest = land.filter { it !in reds }.shuffled(random)
|
||||||
|
OTHER_TOKENS.forEachIndexed { i, token -> numbers[rest[i]] = token }
|
||||||
|
|
||||||
|
val portTypes = (
|
||||||
|
listOf(
|
||||||
|
Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE,
|
||||||
|
) + List(4) { null }
|
||||||
|
).shuffled(random)
|
||||||
|
val ports = PORT_POSITIONS.mapIndexed { i, edge ->
|
||||||
|
val resource = portTypes[i]
|
||||||
|
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Generated(terrains, numbers.toList(), desert, ports)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
|
||||||
|
const val NO_PLAYER = -1
|
||||||
|
|
||||||
|
/** Road segments required to hold Longest Road. */
|
||||||
|
const val LONGEST_ROAD_MIN = 5
|
||||||
|
|
||||||
|
/** Knights required to take Largest Army. */
|
||||||
|
const val LARGEST_ARMY_MIN = 3
|
||||||
|
|
||||||
|
/** Resource cards of each type the bank starts with. */
|
||||||
|
const val BANK_PER_RESOURCE = 19
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five resources the island produces.
|
||||||
|
*
|
||||||
|
* Catan names two of them after the terrain rather than the commodity
|
||||||
|
* (wood becomes lumber, sheep become wool); those names travel with the
|
||||||
|
* trademark, so the engine speaks in resources only.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Resource { LUMBER, BRICK, WOOL, GRAIN, ORE }
|
||||||
|
|
||||||
|
/** One terrain type per hex; the desert produces nothing. */
|
||||||
|
@Serializable
|
||||||
|
enum class Terrain(val produces: Resource?) {
|
||||||
|
FOREST(Resource.LUMBER),
|
||||||
|
PASTURE(Resource.WOOL),
|
||||||
|
FIELDS(Resource.GRAIN),
|
||||||
|
HILLS(Resource.BRICK),
|
||||||
|
MOUNTAINS(Resource.ORE),
|
||||||
|
DESERT(null),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||||
|
@Serializable
|
||||||
|
data class HexCoord(val q: Int, val r: Int)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class BuildingType { SETTLEMENT, CITY }
|
||||||
|
|
||||||
|
/** What stands on one vertex: a settlement or city owned by one seat. */
|
||||||
|
@Serializable
|
||||||
|
data class CatanBuilding(val type: BuildingType, val owner: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One harbour. A port sits on a coastal edge: a player who owns a
|
||||||
|
* settlement or city on *either* endpoint vertex of [edge] may trade at
|
||||||
|
* [ratio] (2 or 3) instead of the bank's 4:1. A resource port trades at 2:1
|
||||||
|
* for its own resource only; [resource] is null for a generic 3:1 harbour.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Port(val edge: Int, val resource: Resource?, val ratio: Int)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTORY_POINT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One development card. [id] is stable for the life of the game (the deck
|
||||||
|
* is built once from the seed), so moves can name a card by id even after
|
||||||
|
* it moves from the deck into a hand. [boughtTurn] records when it was
|
||||||
|
* drawn: a card cannot be played on the turn it was bought.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A domestic trade offer on the table. [by] is the offering seat and [to]
|
||||||
|
* the only seat that may respond — v1 has no counter-offers and no open
|
||||||
|
* auctions, so an offer is always addressed to one player.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TradeOffer(val by: Int, val to: Int, val give: List<Resource>, val get: List<Resource>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the game stands. The phases that involve more than the player
|
||||||
|
* whose turn it is carry their own bookkeeping: [discardSeat] during
|
||||||
|
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class CatanPhase {
|
||||||
|
/** Snake order forward: settlement + road each, seats 0..n-1. */
|
||||||
|
SETUP_FIRST,
|
||||||
|
|
||||||
|
/** Snake order back: settlement + road each, seats n-1..0, plus income. */
|
||||||
|
SETUP_SECOND,
|
||||||
|
|
||||||
|
/** The player whose turn it is must roll the dice. */
|
||||||
|
AWAIT_ROLL,
|
||||||
|
|
||||||
|
/** Players holding more than 7 resource cards discard half. */
|
||||||
|
DISCARDING,
|
||||||
|
|
||||||
|
/** The roller must move the robber and steal. */
|
||||||
|
ROBBER,
|
||||||
|
|
||||||
|
/** Build, trade, buy and play development cards, or end the turn. */
|
||||||
|
PLAYING,
|
||||||
|
|
||||||
|
/** A played Road Building card: up to two roads remain to be placed. */
|
||||||
|
FREE_ROADS,
|
||||||
|
|
||||||
|
/** The offered seat must accept or decline. */
|
||||||
|
TRADE_PENDING,
|
||||||
|
|
||||||
|
GAME_OVER,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CatanOptions(
|
||||||
|
val playerCount: Int = 3,
|
||||||
|
val pointsToWin: Int = 10,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||||
|
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole game as one immutable value.
|
||||||
|
*
|
||||||
|
* Public information (terrain, numbers, ports, buildings, roads, resource
|
||||||
|
* counts, dev-card counts, knights played) is stored directly. Hidden
|
||||||
|
* information — the deck's order and every hand's development cards — is
|
||||||
|
* here in full because the host owns the truth; [CatanRules.viewFor] is
|
||||||
|
* what keeps it off the wire.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CatanState(
|
||||||
|
val options: CatanOptions,
|
||||||
|
val seed: Long,
|
||||||
|
val phase: CatanPhase,
|
||||||
|
/**
|
||||||
|
* The seat whose turn it is — not always the seat expected to move:
|
||||||
|
* during [CatanPhase.DISCARDING] and [CatanPhase.TRADE_PENDING] another
|
||||||
|
* seat acts on the turn owner's behalf.
|
||||||
|
*/
|
||||||
|
val turn: Int,
|
||||||
|
/** How many setup placements are done, 0 until 2 * playerCount. */
|
||||||
|
val setupIndex: Int,
|
||||||
|
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
|
||||||
|
val turnNumber: Int,
|
||||||
|
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
|
||||||
|
val terrains: List<Terrain>,
|
||||||
|
/** Number token per hex, 0 where there is none (the desert). */
|
||||||
|
val numbers: List<Int>,
|
||||||
|
/** Hex the robber currently occupies. */
|
||||||
|
val robber: Int,
|
||||||
|
val ports: List<Port>,
|
||||||
|
/** What stands on each vertex, or null. */
|
||||||
|
val buildings: List<CatanBuilding?>,
|
||||||
|
/** Owning seat per edge, or [NO_PLAYER]. */
|
||||||
|
val roads: List<Int>,
|
||||||
|
/** Resource cards per seat. Card counts are public in Catan. */
|
||||||
|
val resources: List<Map<Resource, Int>>,
|
||||||
|
/** The bank's development cards, top first. Order is hidden. */
|
||||||
|
val devDeck: List<DevCard>,
|
||||||
|
/** Development cards per seat. Hidden from everyone but the holder. */
|
||||||
|
val devHands: List<List<DevCard>>,
|
||||||
|
/** Public mirror of the dev-hand sizes, for views and scoreboards. */
|
||||||
|
val devHandCounts: List<Int>,
|
||||||
|
/** Knights played (not merely held) per seat. */
|
||||||
|
val knightsPlayed: List<Int>,
|
||||||
|
/** Whether the turn owner has already played a development card. */
|
||||||
|
val devCardPlayedThisTurn: Boolean,
|
||||||
|
val pendingTrade: TradeOffer?,
|
||||||
|
/** Roads still owed on a played Road Building card. */
|
||||||
|
val pendingFreeRoads: Int,
|
||||||
|
/** Seat due to discard during [CatanPhase.DISCARDING]. */
|
||||||
|
val discardSeat: Int?,
|
||||||
|
val longestRoadOwner: Int?,
|
||||||
|
val largestArmyOwner: Int?,
|
||||||
|
/** The dice as last rolled, for the table display. */
|
||||||
|
val lastDice: List<Int>,
|
||||||
|
/**
|
||||||
|
* How many dice rolls and steals have happened so far. Random events are
|
||||||
|
* derived from the seed and this counter, so the same game replays
|
||||||
|
* identically without storing a random generator in the state.
|
||||||
|
*/
|
||||||
|
val rngStep: Int,
|
||||||
|
val winner: Int?,
|
||||||
|
val log: List<String>,
|
||||||
|
) {
|
||||||
|
val playerCount: Int get() = options.playerCount
|
||||||
|
|
||||||
|
/** Total resource cards held by [seat]. */
|
||||||
|
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
|
||||||
|
|
||||||
|
/** [seat]'s resource cards as a flat list, one entry per card. */
|
||||||
|
fun handOf(seat: Int): List<Resource> =
|
||||||
|
Resource.entries.flatMap { res -> List(resources[seat][res] ?: 0) { res } }
|
||||||
|
|
||||||
|
/** Whether [seat] holds every card in [cards], counting duplicates. */
|
||||||
|
fun holdsAll(seat: Int, cards: List<Resource>): Boolean {
|
||||||
|
val need = cards.groupingBy { it }.eachCount()
|
||||||
|
return need.all { (res, n) -> (resources[seat][res] ?: 0) >= n }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface CatanMove
|
||||||
|
|
||||||
|
/** Setup placement: a settlement on [vertex] plus a road on [edge]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setup")
|
||||||
|
data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll the dice.
|
||||||
|
*
|
||||||
|
* With [forced] null the roll is derived from the game seed, which is what
|
||||||
|
* the AI, the UI and the network always send. [forced] exists so tests and
|
||||||
|
* offline reviews can stage a specific number; it is not part of the
|
||||||
|
* enumerated move list, so the network driver's "is this move in the legal
|
||||||
|
* list" check rejects any forced roll a client tries to sneak through —
|
||||||
|
* only code holding the state directly (the host's own tests) can use it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("roll")
|
||||||
|
data class RollDice(val forced: List<Int>? = null) : CatanMove
|
||||||
|
|
||||||
|
/** Discard exactly these cards. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("discard")
|
||||||
|
data class Discard(val cards: List<Resource>) : CatanMove
|
||||||
|
|
||||||
|
/** Move the robber to [hex] and steal from [victim], if anyone can be robbed. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("robber")
|
||||||
|
data class MoveRobber(val hex: HexCoord, val victim: Int? = null) : CatanMove
|
||||||
|
|
||||||
|
/** Build a road on [edge] — paid normally, or free during FREE_ROADS. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("road")
|
||||||
|
data class BuildRoad(val edge: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("settlement")
|
||||||
|
data class BuildSettlement(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("city")
|
||||||
|
data class BuildCity(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("buyCard")
|
||||||
|
data object BuyDevCard : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a knight. The card is named by [card] id; the robber move follows as
|
||||||
|
* its own move once the phase has advanced.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("knight")
|
||||||
|
data class PlayKnight(val card: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("roadBuilding")
|
||||||
|
data class PlayRoadBuilding(val card: Int) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("monopoly")
|
||||||
|
data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
|
||||||
|
|
||||||
|
/** Take [take] (up to two cards) from the bank. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("plenty")
|
||||||
|
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer a domestic trade to [to]: [give] of ours for [get] of theirs.
|
||||||
|
*
|
||||||
|
* Like a forced dice roll this is a free-form intent rather than an
|
||||||
|
* enumerated move — the space of two card multisets is combinatorial — so
|
||||||
|
* it is validated structurally by [CatanRules.isLegalMove] instead of
|
||||||
|
* appearing in [CatanRules.legalMoves].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("offer")
|
||||||
|
data class OfferTrade(val to: Int, val give: List<Resource>, val get: List<Resource>) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("accept")
|
||||||
|
data object AcceptTrade : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("decline")
|
||||||
|
data object DeclineTrade : CatanMove
|
||||||
|
|
||||||
|
/** Bank trade: [give] at the best ratio this seat can trade at for [get]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("bank")
|
||||||
|
data class MaritimeTrade(val give: Resource, val get: Resource) : CatanMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("endTurn")
|
||||||
|
data object EndTurn : CatanMove
|
||||||
|
|
||||||
|
/** Forfeit any remaining free roads from a played Road Building card. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("endFreeRoads")
|
||||||
|
data object EndFreeRoads : CatanMove
|
||||||
@@ -0,0 +1,905 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catan as a pure state machine. See RULES-catan.md for the ruleset and the
|
||||||
|
* choices made where the published rules leave room.
|
||||||
|
*
|
||||||
|
* The rules the engine enforces come from the base game as published for
|
||||||
|
* three to four players: setup in snake order, one roll per turn,
|
||||||
|
* production weighted by number tokens, the robber on a seven, domestic
|
||||||
|
* and bank trade, building, development cards, Longest Road, Largest Army
|
||||||
|
* and victory at ten points.
|
||||||
|
*/
|
||||||
|
object CatanRules : GameRules<CatanState, CatanMove> {
|
||||||
|
|
||||||
|
override val gameId: String = GameCatalog.CATAN
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public constants the AI and the UI plan with
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
val ROAD_COST: Map<Resource, Int> = mapOf(Resource.LUMBER to 1, Resource.BRICK to 1)
|
||||||
|
val SETTLEMENT_COST: Map<Resource, Int> = mapOf(
|
||||||
|
Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1,
|
||||||
|
)
|
||||||
|
val CITY_COST: Map<Resource, Int> = mapOf(Resource.GRAIN to 2, Resource.ORE to 3)
|
||||||
|
val DEV_COST: Map<Resource, Int> = mapOf(Resource.WOOL to 1, Resource.GRAIN to 1, Resource.ORE to 1)
|
||||||
|
|
||||||
|
/** The 25 development cards in the bank's deck: 14 knights, two of each progress card, five victory points. */
|
||||||
|
val DEV_DECK: List<DevCardType> = List(14) { DevCardType.KNIGHT } +
|
||||||
|
List(2) { DevCardType.ROAD_BUILDING } +
|
||||||
|
List(2) { DevCardType.MONOPOLY } +
|
||||||
|
List(2) { DevCardType.YEAR_OF_PLENTY } +
|
||||||
|
List(5) { DevCardType.VICTORY_POINT }
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Life cycle
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun initialState(config: TableConfig): CatanState {
|
||||||
|
val options = json.decodeFromString<CatanOptions>(config.optionsJson)
|
||||||
|
require(config.seats.size == options.playerCount) {
|
||||||
|
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||||
|
}
|
||||||
|
|
||||||
|
val board = CatanBoard.generate(config.seed)
|
||||||
|
// A different random stream than the island, so the same seed still
|
||||||
|
// produces both a fixed island and a fixed deck.
|
||||||
|
val devDeck = DEV_DECK
|
||||||
|
.mapIndexed { id, type -> DevCard(id, type) }
|
||||||
|
.shuffled(Random(config.seed + 1))
|
||||||
|
|
||||||
|
return CatanState(
|
||||||
|
options = options,
|
||||||
|
seed = config.seed,
|
||||||
|
phase = CatanPhase.SETUP_FIRST,
|
||||||
|
turn = 0,
|
||||||
|
setupIndex = 0,
|
||||||
|
turnNumber = 0,
|
||||||
|
terrains = board.terrains,
|
||||||
|
numbers = board.numbers,
|
||||||
|
robber = board.robber,
|
||||||
|
ports = board.ports,
|
||||||
|
buildings = List(CatanBoard.VERTEX_COUNT) { null },
|
||||||
|
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER },
|
||||||
|
resources = List(options.playerCount) {
|
||||||
|
Resource.entries.associateWith { 0 }
|
||||||
|
},
|
||||||
|
devDeck = devDeck,
|
||||||
|
devHands = List(options.playerCount) { emptyList() },
|
||||||
|
devHandCounts = List(options.playerCount) { 0 },
|
||||||
|
knightsPlayed = List(options.playerCount) { 0 },
|
||||||
|
devCardPlayedThisTurn = false,
|
||||||
|
pendingTrade = null,
|
||||||
|
pendingFreeRoads = 0,
|
||||||
|
discardSeat = null,
|
||||||
|
longestRoadOwner = null,
|
||||||
|
largestArmyOwner = null,
|
||||||
|
lastDice = emptyList(),
|
||||||
|
rngStep = 0,
|
||||||
|
winner = null,
|
||||||
|
log = listOf(
|
||||||
|
"Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentSeat(state: CatanState): Int? = when (state.phase) {
|
||||||
|
CatanPhase.GAME_OVER -> null
|
||||||
|
CatanPhase.SETUP_FIRST -> state.setupIndex
|
||||||
|
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
|
||||||
|
CatanPhase.DISCARDING -> state.discardSeat
|
||||||
|
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to
|
||||||
|
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
||||||
|
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
||||||
|
-> state.turn
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Legality
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural legality, the form the network driver and the UI should
|
||||||
|
* gate through.
|
||||||
|
*
|
||||||
|
* Two moves are free-form intents rather than members of the enumerated
|
||||||
|
* move list, because enumerating them would explode combinatorially:
|
||||||
|
* a dice roll may carry forced values (tests and offline reviews only —
|
||||||
|
* over the network the driver's membership check rejects it), and a
|
||||||
|
* domestic trade offer names any two multisets of resource cards.
|
||||||
|
*/
|
||||||
|
override fun isLegalMove(state: CatanState, seat: Int, move: CatanMove): Boolean {
|
||||||
|
if (currentSeat(state) != seat) return false
|
||||||
|
return when (move) {
|
||||||
|
is RollDice -> state.phase == CatanPhase.AWAIT_ROLL &&
|
||||||
|
(move.forced == null ||
|
||||||
|
(move.forced.size == 2 && move.forced.all { it in 1..6 }))
|
||||||
|
|
||||||
|
is OfferTrade -> offerLegal(state, seat, move)
|
||||||
|
|
||||||
|
else -> move in legalMoves(state, seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
|
||||||
|
if (currentSeat(state) != seat) return emptyList()
|
||||||
|
return when (state.phase) {
|
||||||
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
|
||||||
|
|
||||||
|
CatanPhase.AWAIT_ROLL -> listOf(RollDice())
|
||||||
|
|
||||||
|
CatanPhase.DISCARDING -> discardMoves(state, seat)
|
||||||
|
|
||||||
|
CatanPhase.ROBBER -> robberMoves(state, seat)
|
||||||
|
|
||||||
|
CatanPhase.PLAYING -> playingMoves(state, seat)
|
||||||
|
|
||||||
|
CatanPhase.FREE_ROADS ->
|
||||||
|
roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
|
||||||
|
|
||||||
|
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
||||||
|
|
||||||
|
CatanPhase.GAME_OVER -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupMoves(state: CatanState): List<SetupPlace> {
|
||||||
|
val moves = mutableListOf<SetupPlace>()
|
||||||
|
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
|
if (!vertexOpenForSettlement(state, v)) continue
|
||||||
|
for (e in CatanBoard.VERTEX_EDGES[v]) {
|
||||||
|
if (state.roads[e] != NO_PLAYER) continue
|
||||||
|
moves += SetupPlace(v, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun discardMoves(state: CatanState, seat: Int): List<Discard> {
|
||||||
|
val total = state.resourceCount(seat)
|
||||||
|
if (total <= 7) return emptyList()
|
||||||
|
val half = total / 2
|
||||||
|
val held = state.resources[seat]
|
||||||
|
val moves = mutableListOf<Discard>()
|
||||||
|
|
||||||
|
fun walk(resourceIndex: Int, remaining: Int, acc: MutableList<Resource>) {
|
||||||
|
if (resourceIndex == Resource.entries.size) {
|
||||||
|
if (remaining == 0) moves += Discard(acc.toList())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val resource = Resource.entries[resourceIndex]
|
||||||
|
val have = held[resource] ?: 0
|
||||||
|
for (take in 0..minOf(have, remaining)) {
|
||||||
|
repeat(take) { acc += resource }
|
||||||
|
walk(resourceIndex + 1, remaining - take, acc)
|
||||||
|
repeat(take) { acc.removeAt(acc.size - 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(0, half, mutableListOf())
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
|
||||||
|
val moves = mutableListOf<MoveRobber>()
|
||||||
|
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
if (h == state.robber) continue
|
||||||
|
val victims = CatanBoard.HEX_CORNERS[h]
|
||||||
|
.mapNotNull { state.buildings[it] }
|
||||||
|
.map { it.owner }
|
||||||
|
.filter { it != seat }
|
||||||
|
.distinct()
|
||||||
|
.filter { state.resourceCount(it) > 0 }
|
||||||
|
if (victims.isEmpty()) {
|
||||||
|
moves += MoveRobber(CatanBoard.HEXES[h])
|
||||||
|
} else {
|
||||||
|
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playingMoves(state: CatanState, seat: Int): List<CatanMove> {
|
||||||
|
val moves = mutableListOf<CatanMove>()
|
||||||
|
|
||||||
|
if (affordable(state, seat, ROAD_COST)) moves += roadMoves(state, seat)
|
||||||
|
if (affordable(state, seat, SETTLEMENT_COST)) {
|
||||||
|
moves += settlementVertices(state, seat).map { BuildSettlement(it) }
|
||||||
|
}
|
||||||
|
if (affordable(state, seat, CITY_COST)) {
|
||||||
|
moves += cityVertices(state, seat).map { BuildCity(it) }
|
||||||
|
}
|
||||||
|
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
|
||||||
|
moves += BuyDevCard
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.devCardPlayedThisTurn) {
|
||||||
|
for (card in state.devHands[seat]) {
|
||||||
|
if (card.boughtTurn == state.turnNumber) continue
|
||||||
|
when (card.type) {
|
||||||
|
DevCardType.KNIGHT -> moves += PlayKnight(card.id)
|
||||||
|
DevCardType.ROAD_BUILDING -> moves += PlayRoadBuilding(card.id)
|
||||||
|
DevCardType.MONOPOLY -> Resource.entries.forEach {
|
||||||
|
moves += PlayMonopoly(card.id, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
DevCardType.YEAR_OF_PLENTY -> moves += plentyTakes(state).map {
|
||||||
|
PlayYearOfPlenty(card.id, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
DevCardType.VICTORY_POINT -> Unit // revealed by winning, never played
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (give in Resource.entries) {
|
||||||
|
if ((state.resources[seat][give] ?: 0) < bestPortRatio(state, seat, give)) continue
|
||||||
|
for (get in Resource.entries) {
|
||||||
|
if (get == give || bankSupply(state, get) < 1) continue
|
||||||
|
moves += MaritimeTrade(give, get)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moves += EndTurn
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every take a Year of Plenty card could name: one card, or two. */
|
||||||
|
private fun plentyTakes(state: CatanState): List<List<Resource>> {
|
||||||
|
val takes = mutableListOf<List<Resource>>()
|
||||||
|
for (a in Resource.entries) {
|
||||||
|
if (bankSupply(state, a) >= 1) takes += listOf(a)
|
||||||
|
for (b in Resource.entries) {
|
||||||
|
// Two of a kind needs a bank that can pay both, not just one.
|
||||||
|
val needOfB = if (a == b) 2 else 1
|
||||||
|
if (bankSupply(state, a) >= 1 && bankSupply(state, b) >= needOfB) {
|
||||||
|
takes += listOf(a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return takes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Road placements that connect to [seat]'s network, cost aside. */
|
||||||
|
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
|
||||||
|
(0 until CatanBoard.EDGE_COUNT)
|
||||||
|
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
|
||||||
|
.map { BuildRoad(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settlement placements: an open vertex at least two edges from every
|
||||||
|
* building, connected to [seat]'s road network. (During setup the
|
||||||
|
* connection requirement does not apply — that is why setup uses its
|
||||||
|
* own move.)
|
||||||
|
*/
|
||||||
|
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
|
||||||
|
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||||
|
vertexOpenForSettlement(state, v) &&
|
||||||
|
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
|
||||||
|
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||||
|
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
|
||||||
|
val offer = state.pendingTrade ?: return emptyList()
|
||||||
|
val responses = mutableListOf<CatanMove>()
|
||||||
|
if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) {
|
||||||
|
responses += AcceptTrade
|
||||||
|
}
|
||||||
|
responses += DeclineTrade
|
||||||
|
return responses
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty vertex with no building on it or one edge away. */
|
||||||
|
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||||
|
state.buildings[v] == null &&
|
||||||
|
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||||
|
|
||||||
|
/** A road on [edge] would join [seat]'s network of roads and buildings. */
|
||||||
|
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
|
||||||
|
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||||
|
return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } ||
|
||||||
|
CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } ||
|
||||||
|
state.buildings[a]?.owner == seat ||
|
||||||
|
state.buildings[b]?.owner == seat
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
|
||||||
|
if (state.phase != CatanPhase.PLAYING || state.turn != seat) return false
|
||||||
|
if (move.to !in 0 until state.playerCount || move.to == seat) return false
|
||||||
|
if (move.give.isEmpty() || move.get.isEmpty()) return false
|
||||||
|
// Like-for-like is not a trade; the published game rejects it too.
|
||||||
|
if (countOf(move.give) == countOf(move.get)) return false
|
||||||
|
return state.holdsAll(seat, move.give)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Move application
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun applyMove(state: CatanState, seat: Int, move: CatanMove): CatanState {
|
||||||
|
require(currentSeat(state) == seat) {
|
||||||
|
"Not seat $seat's move in phase ${state.phase}"
|
||||||
|
}
|
||||||
|
require(isLegalMove(state, seat, move)) { "Illegal move $move for seat $seat" }
|
||||||
|
return when (move) {
|
||||||
|
is SetupPlace -> applySetup(state, seat, move)
|
||||||
|
is RollDice -> applyRoll(state, seat, move)
|
||||||
|
is Discard -> applyDiscard(state, seat, move)
|
||||||
|
is MoveRobber -> applyRobber(state, seat, move)
|
||||||
|
is BuildRoad -> applyBuildRoad(state, seat, move)
|
||||||
|
is BuildSettlement -> applyBuildSettlement(state, seat, move)
|
||||||
|
is BuildCity -> applyBuildCity(state, seat, move)
|
||||||
|
BuyDevCard -> applyBuyDevCard(state, seat)
|
||||||
|
is PlayKnight -> applyPlayKnight(state, seat, move)
|
||||||
|
is PlayRoadBuilding -> applyPlayRoadBuilding(state, seat, move)
|
||||||
|
is PlayMonopoly -> applyPlayMonopoly(state, seat, move)
|
||||||
|
is PlayYearOfPlenty -> applyPlayYearOfPlenty(state, seat, move)
|
||||||
|
is OfferTrade -> applyOfferTrade(state, seat, move)
|
||||||
|
AcceptTrade -> applyAcceptTrade(state, seat)
|
||||||
|
DeclineTrade -> applyDeclineTrade(state, seat)
|
||||||
|
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
|
||||||
|
EndTurn -> applyEndTurn(state, seat)
|
||||||
|
EndFreeRoads -> applyEndFreeRoads(state, seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
|
||||||
|
val n = state.playerCount
|
||||||
|
val second = state.phase == CatanPhase.SETUP_SECOND
|
||||||
|
var s = state.copy(
|
||||||
|
buildings = state.buildings.toMutableList().also {
|
||||||
|
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||||
|
},
|
||||||
|
roads = state.roads.toMutableList().also { it[move.edge] = seat },
|
||||||
|
log = state.log + "Seat $seat settles at ${move.vertex} and lays a road on ${move.edge}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (second) {
|
||||||
|
// The second settlement earns one card from each adjacent
|
||||||
|
// producing hex, supply permitting.
|
||||||
|
val adjacent = CatanBoard.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
|
||||||
|
val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) ->
|
||||||
|
need <= bankSupply(state, res)
|
||||||
|
}
|
||||||
|
if (granted.isNotEmpty()) {
|
||||||
|
s = s.copy(
|
||||||
|
resources = withCounts(s.resources, seat, granted, 1),
|
||||||
|
log = s.log + "Seat $seat banks ${describe(granted)} from the new lands.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newIndex = state.setupIndex + 1
|
||||||
|
s = s.copy(setupIndex = newIndex)
|
||||||
|
return when {
|
||||||
|
newIndex >= 2 * n -> s.copy(
|
||||||
|
phase = CatanPhase.AWAIT_ROLL,
|
||||||
|
turn = 0,
|
||||||
|
turnNumber = 1,
|
||||||
|
log = s.log + "Setup done — seat 0 leads.",
|
||||||
|
)
|
||||||
|
|
||||||
|
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
|
||||||
|
else -> s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
|
||||||
|
val dice = move.forced ?: derivedDice(state)
|
||||||
|
val total = dice[0] + dice[1]
|
||||||
|
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
|
||||||
|
return if (total == 7) {
|
||||||
|
val firstDiscarder = (0 until s.playerCount)
|
||||||
|
.firstOrNull { s.resourceCount(it) > 7 }
|
||||||
|
val robbed = if (firstDiscarder != null) {
|
||||||
|
s.copy(phase = CatanPhase.DISCARDING, discardSeat = firstDiscarder)
|
||||||
|
} else {
|
||||||
|
s.copy(phase = CatanPhase.ROBBER)
|
||||||
|
}
|
||||||
|
robbed.copy(log = robbed.log + "Seat $seat rolls 7 — the robber rides.")
|
||||||
|
} else {
|
||||||
|
applyProduction(s, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dice derived from the seed and the count of random events so far. */
|
||||||
|
private fun derivedDice(state: CatanState): List<Int> {
|
||||||
|
val rng = Random(state.seed * 1_000_003L + state.rngStep)
|
||||||
|
return listOf(rng.nextInt(6) + 1, rng.nextInt(6) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyProduction(state: CatanState, number: Int): CatanState {
|
||||||
|
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
|
||||||
|
for (resource in Resource.entries) {
|
||||||
|
var demand = 0
|
||||||
|
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
if (state.numbers[h] != number) continue
|
||||||
|
if (h == state.robber) continue
|
||||||
|
if (state.terrains[h].produces != resource) continue
|
||||||
|
for (v in CatanBoard.HEX_CORNERS[h]) {
|
||||||
|
val building = state.buildings[v] ?: continue
|
||||||
|
val amount = if (building.type == BuildingType.CITY) 2 else 1
|
||||||
|
gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount
|
||||||
|
demand += amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Official ruling: when the bank cannot meet the demand, nobody
|
||||||
|
// receives that resource this roll.
|
||||||
|
if (demand > bankSupply(state, resource)) {
|
||||||
|
for (perSeat in gains) perSeat.remove(resource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var s = state.copy(phase = CatanPhase.PLAYING)
|
||||||
|
val whoGot = mutableListOf<String>()
|
||||||
|
for (seat in 0 until s.playerCount) {
|
||||||
|
val got = gains[seat].filterValues { it > 0 }
|
||||||
|
if (got.isEmpty()) continue
|
||||||
|
s = s.copy(resources = withCounts(s.resources, seat, got, 1))
|
||||||
|
whoGot += "seat $seat takes ${describe(got)}"
|
||||||
|
}
|
||||||
|
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ")
|
||||||
|
return s.copy(
|
||||||
|
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState {
|
||||||
|
var s = state.copy(resources = withCards(state.resources, seat, move.cards, -1))
|
||||||
|
val next = nextDiscarder(s, seat)
|
||||||
|
s = if (next != null) {
|
||||||
|
s.copy(discardSeat = next)
|
||||||
|
} else {
|
||||||
|
s.copy(phase = CatanPhase.ROBBER, discardSeat = null)
|
||||||
|
}
|
||||||
|
return s.copy(log = s.log + "Seat $seat discards ${describe(countOf(move.cards))}.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextDiscarder(state: CatanState, after: Int): Int? =
|
||||||
|
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
|
||||||
|
|
||||||
|
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
|
||||||
|
val hex = CatanBoard.hexIndex[move.hex]
|
||||||
|
?: throw IllegalArgumentException("No hex at ${move.hex}")
|
||||||
|
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
|
||||||
|
var stolen: String? = null
|
||||||
|
if (move.victim != null) {
|
||||||
|
// One card at random from the victim's hand, derived like the
|
||||||
|
// dice: same seed, next random event.
|
||||||
|
val rng = Random(state.seed * 1_000_003L + state.rngStep)
|
||||||
|
val victimHand = state.handOf(move.victim)
|
||||||
|
val card = victimHand[rng.nextInt(victimHand.size)]
|
||||||
|
val less = withCards(s.resources, move.victim, listOf(card), -1)
|
||||||
|
s = s.copy(resources = withCards(less, seat, listOf(card), 1))
|
||||||
|
stolen = " and steals a ${card.name.lowercase()} from seat ${move.victim}"
|
||||||
|
}
|
||||||
|
return s.copy(
|
||||||
|
phase = CatanPhase.PLAYING,
|
||||||
|
log = s.log + "Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
|
||||||
|
" (${move.hex.q},${move.hex.r})$stolen.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBuildRoad(state: CatanState, seat: Int, move: BuildRoad): CatanState {
|
||||||
|
val free = state.phase == CatanPhase.FREE_ROADS
|
||||||
|
var s = state.copy(roads = state.roads.toMutableList().also { it[move.edge] = seat })
|
||||||
|
if (!free) s = payCost(s, seat, ROAD_COST)
|
||||||
|
s = refreshLongestRoad(s)
|
||||||
|
if (free) {
|
||||||
|
val left = s.pendingFreeRoads - 1
|
||||||
|
s = s.copy(pendingFreeRoads = left)
|
||||||
|
if (left == 0) s = s.copy(phase = CatanPhase.PLAYING)
|
||||||
|
}
|
||||||
|
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState {
|
||||||
|
var s = state.copy(
|
||||||
|
buildings = state.buildings.toMutableList().also {
|
||||||
|
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
s = payCost(s, seat, SETTLEMENT_COST)
|
||||||
|
// A settlement can break another player's road, so the award is
|
||||||
|
// recomputed for everyone.
|
||||||
|
s = refreshLongestRoad(s)
|
||||||
|
return checkWin(s.copy(log = s.log + "Seat $seat builds a settlement at ${move.vertex}."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBuildCity(state: CatanState, seat: Int, move: BuildCity): CatanState {
|
||||||
|
var s = state.copy(
|
||||||
|
buildings = state.buildings.toMutableList().also {
|
||||||
|
it[move.vertex] = CatanBuilding(BuildingType.CITY, seat)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
s = payCost(s, seat, CITY_COST)
|
||||||
|
return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
|
||||||
|
val card = state.devDeck.first().copy(boughtTurn = state.turnNumber)
|
||||||
|
val hands = state.devHands.toMutableList().also { it[seat] = it[seat] + card }
|
||||||
|
return payCost(state, seat, DEV_COST).copy(
|
||||||
|
devDeck = state.devDeck.drop(1),
|
||||||
|
devHands = hands,
|
||||||
|
devHandCounts = hands.map { it.size },
|
||||||
|
// What was bought is not announced — only that something was.
|
||||||
|
log = state.log + "Seat $seat buys a development card.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlayKnight(state: CatanState, seat: Int, move: PlayKnight): CatanState {
|
||||||
|
val hands = removeCard(state, seat, move.card)
|
||||||
|
val played = state.knightsPlayed.toMutableList().also { it[seat] = it[seat] + 1 }
|
||||||
|
var s = state.copy(
|
||||||
|
devHands = hands,
|
||||||
|
devHandCounts = hands.map { it.size },
|
||||||
|
knightsPlayed = played,
|
||||||
|
devCardPlayedThisTurn = true,
|
||||||
|
phase = CatanPhase.ROBBER,
|
||||||
|
)
|
||||||
|
s = refreshLargestArmy(s, seat)
|
||||||
|
return s.copy(log = s.log + "Seat $seat plays a knight.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlayRoadBuilding(state: CatanState, seat: Int, move: PlayRoadBuilding): CatanState {
|
||||||
|
val hands = removeCard(state, seat, move.card)
|
||||||
|
return state.copy(
|
||||||
|
devHands = hands,
|
||||||
|
devHandCounts = hands.map { it.size },
|
||||||
|
devCardPlayedThisTurn = true,
|
||||||
|
phase = CatanPhase.FREE_ROADS,
|
||||||
|
pendingFreeRoads = 2,
|
||||||
|
log = state.log + "Seat $seat plays Road Building.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlayMonopoly(state: CatanState, seat: Int, move: PlayMonopoly): CatanState {
|
||||||
|
val resource = move.resource
|
||||||
|
var resources = state.resources
|
||||||
|
var seized = 0
|
||||||
|
for (other in 0 until state.playerCount) {
|
||||||
|
if (other == seat) continue
|
||||||
|
val held = state.resources[other][resource] ?: 0
|
||||||
|
if (held == 0) continue
|
||||||
|
resources = withCounts(withCards(resources, other, List(held) { resource }, -1), seat, mapOf(resource to held), 1)
|
||||||
|
seized += held
|
||||||
|
}
|
||||||
|
val hands = removeCard(state, seat, move.card)
|
||||||
|
return state.copy(
|
||||||
|
resources = resources,
|
||||||
|
devHands = hands,
|
||||||
|
devHandCounts = hands.map { it.size },
|
||||||
|
devCardPlayedThisTurn = true,
|
||||||
|
log = state.log + "Seat $seat plays Monopoly — seizes $seized ${resource.name.lowercase()}.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlayYearOfPlenty(state: CatanState, seat: Int, move: PlayYearOfPlenty): CatanState {
|
||||||
|
val hands = removeCard(state, seat, move.card)
|
||||||
|
return state.copy(
|
||||||
|
resources = withCards(state.resources, seat, move.take, 1),
|
||||||
|
devHands = hands,
|
||||||
|
devHandCounts = hands.map { it.size },
|
||||||
|
devCardPlayedThisTurn = true,
|
||||||
|
log = state.log + "Seat $seat plays Year of Plenty — takes ${describe(countOf(move.take))}.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState =
|
||||||
|
state.copy(
|
||||||
|
phase = CatanPhase.TRADE_PENDING,
|
||||||
|
pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get),
|
||||||
|
log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " +
|
||||||
|
"${describe(countOf(move.get))} to seat ${move.to}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
|
||||||
|
val offer = state.pendingTrade!!
|
||||||
|
var resources = withCards(state.resources, offer.to, offer.get, -1)
|
||||||
|
resources = withCards(resources, offer.by, offer.give, -1)
|
||||||
|
resources = withCards(resources, offer.by, offer.get, 1)
|
||||||
|
resources = withCards(resources, offer.to, offer.give, 1)
|
||||||
|
return state.copy(
|
||||||
|
phase = CatanPhase.PLAYING,
|
||||||
|
pendingTrade = null,
|
||||||
|
resources = resources,
|
||||||
|
log = state.log + "Seat $seat accepts — the cards cross the table.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
|
||||||
|
state.copy(
|
||||||
|
phase = CatanPhase.PLAYING,
|
||||||
|
pendingTrade = null,
|
||||||
|
log = state.log + "Seat $seat declines the offer.",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState {
|
||||||
|
val ratio = bestPortRatio(state, seat, move.give)
|
||||||
|
val paid = withCards(state.resources, seat, List(ratio) { move.give }, -1)
|
||||||
|
val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1))
|
||||||
|
val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour"
|
||||||
|
return s.copy(
|
||||||
|
log = s.log + "Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
|
||||||
|
"${move.get.name.lowercase()} via $via.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyEndTurn(state: CatanState, seat: Int): CatanState {
|
||||||
|
// Defensive: a player already at the target wins rather than passes.
|
||||||
|
val settled = checkWin(state)
|
||||||
|
if (settled.phase == CatanPhase.GAME_OVER) return settled
|
||||||
|
|
||||||
|
val next = (state.turn + 1) % state.playerCount
|
||||||
|
val s = state.copy(
|
||||||
|
turn = next,
|
||||||
|
turnNumber = state.turnNumber + 1,
|
||||||
|
phase = CatanPhase.AWAIT_ROLL,
|
||||||
|
devCardPlayedThisTurn = false,
|
||||||
|
pendingTrade = null,
|
||||||
|
)
|
||||||
|
// Someone can cross the target on another player's turn (a broken
|
||||||
|
// Longest Road re-awarded to a third player); they win when their
|
||||||
|
// own turn arrives.
|
||||||
|
return checkWin(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
|
||||||
|
state.copy(
|
||||||
|
phase = CatanPhase.PLAYING,
|
||||||
|
pendingFreeRoads = 0,
|
||||||
|
log = state.log + "Seat $seat forfeits the remaining free road.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Scoring
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Victory points everyone at the table can count: buildings, the two
|
||||||
|
* awards, and nothing hidden. Hidden victory-point cards are added on
|
||||||
|
* top by [victoryPoints], which is only meaningful for the holder's own
|
||||||
|
* view or the host's copy.
|
||||||
|
*/
|
||||||
|
fun publicVictoryPoints(state: CatanState): List<Int> {
|
||||||
|
val points = IntArray(state.playerCount)
|
||||||
|
state.buildings.forEach { building ->
|
||||||
|
if (building != null) {
|
||||||
|
points[building.owner] += if (building.type == BuildingType.CITY) 2 else 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.longestRoadOwner?.let { points[it] += 5 }
|
||||||
|
state.largestArmyOwner?.let { points[it] += 5 }
|
||||||
|
return points.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun victoryPoints(state: CatanState, seat: Int): Int =
|
||||||
|
publicVictoryPoints(state)[seat] +
|
||||||
|
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
||||||
|
|
||||||
|
private fun checkWin(state: CatanState): CatanState {
|
||||||
|
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||||
|
val seat = state.turn
|
||||||
|
val points = victoryPoints(state, seat)
|
||||||
|
if (points < state.options.pointsToWin) return state
|
||||||
|
return state.copy(
|
||||||
|
phase = CatanPhase.GAME_OVER,
|
||||||
|
winner = seat,
|
||||||
|
log = state.log + "Seat $seat reveals $points victory points and wins.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes everyone's road lengths and re-awards Longest Road.
|
||||||
|
*
|
||||||
|
* The award needs a strictly longer road than the holder's; equal length
|
||||||
|
* leaves it where it is, exactly as the published rule keeps the first
|
||||||
|
* builder ahead of a builder who merely matches them. If the holder's
|
||||||
|
* road falls below five segments the award reopens to the longest road
|
||||||
|
* of five or more, lowest seat first on a tie.
|
||||||
|
*/
|
||||||
|
private fun refreshLongestRoad(state: CatanState): CatanState {
|
||||||
|
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
|
||||||
|
val holder = state.longestRoadOwner
|
||||||
|
val best = lengths
|
||||||
|
.withIndex()
|
||||||
|
.filter { it.value >= LONGEST_ROAD_MIN }
|
||||||
|
.maxByOrNull { it.value } // first of the maximum: lowest seat on a tie
|
||||||
|
|
||||||
|
val newOwner = when {
|
||||||
|
holder != null && lengths[holder] >= LONGEST_ROAD_MIN &&
|
||||||
|
(best == null || best.value <= lengths[holder]) -> holder
|
||||||
|
|
||||||
|
else -> best?.index
|
||||||
|
}
|
||||||
|
if (newOwner == holder) return state
|
||||||
|
val line = if (newOwner == null) {
|
||||||
|
"Longest Road falls vacant."
|
||||||
|
} else {
|
||||||
|
"Seat $newOwner takes Longest Road (${lengths[newOwner]} segments)."
|
||||||
|
}
|
||||||
|
return state.copy(longestRoadOwner = newOwner, log = state.log + line)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seat's longest single road: a simple path over its own edges that
|
||||||
|
* never passes through an opponent's building and never revisits a
|
||||||
|
* vertex (so a figure of two loops sharing an intersection counts as
|
||||||
|
* one road, not two — the intersection is a branch).
|
||||||
|
*/
|
||||||
|
fun longestRoadFor(state: CatanState, seat: Int): Int {
|
||||||
|
var best = 0
|
||||||
|
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT)
|
||||||
|
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
|
val building = state.buildings[v]
|
||||||
|
if (building != null && building.owner != seat) blocked[v] = true
|
||||||
|
}
|
||||||
|
for (start in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
|
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
|
||||||
|
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
|
||||||
|
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roadDfs(
|
||||||
|
state: CatanState,
|
||||||
|
seat: Int,
|
||||||
|
v: Int,
|
||||||
|
blocked: BooleanArray,
|
||||||
|
visited: BooleanArray,
|
||||||
|
): Int {
|
||||||
|
visited[v] = true
|
||||||
|
var best = 0
|
||||||
|
for (e in CatanBoard.VERTEX_EDGES[v]) {
|
||||||
|
if (state.roads[e] != seat) continue
|
||||||
|
val w = CatanBoard.otherEnd(e, v)
|
||||||
|
if (visited[w]) continue
|
||||||
|
if (blocked[w]) {
|
||||||
|
// The segment may run up to an opponent's building, but not
|
||||||
|
// through it.
|
||||||
|
best = maxOf(best, 1)
|
||||||
|
} else {
|
||||||
|
best = maxOf(best, 1 + roadDfs(state, seat, w, blocked, visited))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visited[v] = false
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes Largest Army: first to three knights takes it, and it is
|
||||||
|
* stolen only by a strictly larger army.
|
||||||
|
*/
|
||||||
|
private fun refreshLargestArmy(state: CatanState, seat: Int): CatanState {
|
||||||
|
val holder = state.largestArmyOwner
|
||||||
|
val newOwner = when {
|
||||||
|
holder == null && state.knightsPlayed[seat] >= LARGEST_ARMY_MIN -> seat
|
||||||
|
holder != null && holder != seat &&
|
||||||
|
state.knightsPlayed[seat] > state.knightsPlayed[holder] -> seat
|
||||||
|
|
||||||
|
else -> holder
|
||||||
|
}
|
||||||
|
if (newOwner == holder) return state
|
||||||
|
return state.copy(
|
||||||
|
largestArmyOwner = newOwner,
|
||||||
|
log = state.log + "Seat $newOwner takes Largest Army (${state.knightsPlayed[seat]} knights).",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Resources
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The bank's remaining cards of [resource]: what is not in players' hands. */
|
||||||
|
fun bankSupply(state: CatanState, resource: Resource): Int =
|
||||||
|
BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 }
|
||||||
|
|
||||||
|
private fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
|
||||||
|
cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need }
|
||||||
|
|
||||||
|
private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState =
|
||||||
|
state.copy(resources = withCounts(state.resources, seat, cost, -1))
|
||||||
|
|
||||||
|
private fun countOf(cards: List<Resource>): Map<Resource, Int> =
|
||||||
|
cards.groupingBy { it }.eachCount()
|
||||||
|
|
||||||
|
/** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */
|
||||||
|
private fun withCounts(
|
||||||
|
resources: List<Map<Resource, Int>>,
|
||||||
|
seat: Int,
|
||||||
|
delta: Map<Resource, Int>,
|
||||||
|
sign: Int,
|
||||||
|
): List<Map<Resource, Int>> {
|
||||||
|
val hand = resources[seat].toMutableMap()
|
||||||
|
for ((resource, n) in delta) hand[resource] = (hand[resource] ?: 0) + sign * n
|
||||||
|
return resources.toMutableList().also { it[seat] = hand }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands with each card of [cards] added to (or removed from) [seat]'s. */
|
||||||
|
private fun withCards(
|
||||||
|
resources: List<Map<Resource, Int>>,
|
||||||
|
seat: Int,
|
||||||
|
cards: List<Resource>,
|
||||||
|
sign: Int,
|
||||||
|
): List<Map<Resource, Int>> = withCounts(resources, seat, countOf(cards), sign)
|
||||||
|
|
||||||
|
/** The card with id [card], removed from [seat]'s hand. */
|
||||||
|
private fun removeCard(state: CatanState, seat: Int, card: Int): List<List<DevCard>> =
|
||||||
|
state.devHands.toMutableList().also { hands ->
|
||||||
|
hands[seat] = hands[seat].filterNot { it.id == card }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The best ratio [seat] can trade [give] at: 2:1 through a matching
|
||||||
|
* resource harbour, 3:1 through a generic one, otherwise the bank's 4:1.
|
||||||
|
*/
|
||||||
|
fun bestPortRatio(state: CatanState, seat: Int, give: Resource): Int {
|
||||||
|
var best = 4
|
||||||
|
for (port in state.ports) {
|
||||||
|
if (port.resource != null && port.resource != give) continue
|
||||||
|
if (port.ratio >= best) continue
|
||||||
|
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
|
||||||
|
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
|
||||||
|
if (mine) best = port.ratio
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Results, redaction and wire format
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun isFinished(state: CatanState): Boolean = state.phase == CatanPhase.GAME_OVER
|
||||||
|
|
||||||
|
override fun summary(state: CatanState): String = when (val winner = state.winner) {
|
||||||
|
null -> publicVictoryPoints(state)
|
||||||
|
.mapIndexed { seat, points -> "S$seat $points" }
|
||||||
|
.joinToString(" ")
|
||||||
|
|
||||||
|
else -> "Seat $winner wins"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun viewFor(state: CatanState, seat: Int): CatanState = state.copy(
|
||||||
|
// Hidden information stays with the holder and the host.
|
||||||
|
devDeck = emptyList(),
|
||||||
|
devHands = state.devHands.mapIndexed { index, hand ->
|
||||||
|
if (index == seat) hand else emptyList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun encodeState(state: CatanState): String = json.encodeToString(state)
|
||||||
|
|
||||||
|
override fun decodeState(json: String): CatanState = this.json.decodeFromString(json)
|
||||||
|
|
||||||
|
override fun encodeMove(move: CatanMove): String = json.encodeToString(move)
|
||||||
|
|
||||||
|
override fun decodeMove(json: String): CatanMove = this.json.decodeFromString(json)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Wording
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun describe(counts: Map<Resource, Int>): String =
|
||||||
|
counts.entries.joinToString(" + ") { (resource, n) -> "$n ${resource.name.lowercase()}" }
|
||||||
|
|
||||||
|
private fun terrainName(terrain: Terrain): String =
|
||||||
|
terrain.name.lowercase().replace('_', ' ')
|
||||||
|
}
|
||||||
@@ -64,6 +64,21 @@ interface GameRules<S : Any, M : Any> {
|
|||||||
|
|
||||||
fun legalMoves(state: S, seat: Int): List<M>
|
fun legalMoves(state: S, seat: Int): List<M>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [move] is currently legal for [seat].
|
||||||
|
*
|
||||||
|
* Defaults to membership in [legalMoves], which is everything a game
|
||||||
|
* with a small, enumerable move set needs. A game whose move space is
|
||||||
|
* combinatorial — Catan's trade offers name any two multisets of cards —
|
||||||
|
* overrides this and validates structurally instead, because enumerating
|
||||||
|
* every intent would explode the list the UI and the network driver work
|
||||||
|
* from. Gates that check a specific move should call this method rather
|
||||||
|
* than testing list membership directly, so both kinds of game behave the
|
||||||
|
* same way.
|
||||||
|
*/
|
||||||
|
fun isLegalMove(state: S, seat: Int, move: M): Boolean =
|
||||||
|
move in legalMoves(state, seat)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies [move] on behalf of [seat].
|
* Applies [move] on behalf of [seat].
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 = "ProLibertateGames:lan"
|
const val WIFI_LOCK_TAG = "PilgrimsOfKatan:lan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
|
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (move !in rules.legalMoves(current, seat)) {
|
if (!rules.isLegalMove(current, seat, move)) {
|
||||||
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
|
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -424,7 +424,7 @@ class MatchController<S : Any, M : Any>(
|
|||||||
// throws on a move that is no longer legal, and there is nothing
|
// throws on a move that is no longer legal, and there is nothing
|
||||||
// above this to catch it.
|
// above this to catch it.
|
||||||
if (rules.currentSeat(live) != seat) return@withLock false
|
if (rules.currentSeat(live) != seat) return@withLock false
|
||||||
if (move !in rules.legalMoves(live, seat)) return@withLock false
|
if (!rules.isLegalMove(live, seat, move)) return@withLock false
|
||||||
authoritative = rules.applyMove(live, seat, move)
|
authoritative = rules.applyMove(live, seat, move)
|
||||||
publish()
|
publish()
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -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 = "ProLibertateGames"
|
const val SERVICE_NAME = "PilgrimsOfKatan"
|
||||||
|
|
||||||
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
|
||||||
// tartan before the app itself has.
|
// splash before the app itself has.
|
||||||
val pending = release
|
val pending = release
|
||||||
if (pending == null || dismissed || !visible) return
|
if (pending == null || dismissed || !visible) return
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ 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
|
||||||
@@ -112,6 +116,7 @@ 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
|
||||||
@@ -668,6 +673,23 @@ 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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
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,17 +1,19 @@
|
|||||||
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 run number, a production
|
* sequences — a dev build's versionCode is its CI build number, a production
|
||||||
* build's is its release run number, and the two are unrelated. There is no
|
* build's is its release build 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
|
||||||
* `…games.dev` — so both can sit on one device, each updating itself down its
|
* `…pilgrimsofkatan.dev` — so both can sit on one device, each updating
|
||||||
* own line and leaving the other alone.
|
* itself down its own line and leaving the other alone.
|
||||||
*/
|
*/
|
||||||
enum class UpdateChannel(val label: String, val blurb: String) {
|
enum class UpdateChannel(val label: String, val blurb: String) {
|
||||||
PRODUCTION("Production", "Stable builds released from main"),
|
PRODUCTION("Production", "Stable builds released from main"),
|
||||||
@@ -21,7 +23,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 channel's `latest.json`. */
|
/** One published build, as read off a repository release. */
|
||||||
data class Release(
|
data class Release(
|
||||||
val tag: String,
|
val tag: String,
|
||||||
val versionCode: Int,
|
val versionCode: Int,
|
||||||
@@ -49,14 +51,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 GitHub's own prerelease flag. A release would have to
|
* puts on the tag, and the release's own prerelease flag. A release would have
|
||||||
* lose both to be mistaken for the other channel.
|
* to 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 run number out of a `v<series>.<n>` tag, which is what an APK's
|
* The build 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
|
||||||
@@ -65,15 +67,37 @@ 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 update server at all
|
* question, so a check that finds nothing — or that cannot reach the release
|
||||||
* — stays quiet; a phone on a train would otherwise be told off for it every
|
* server at all — stays quiet; a phone on a train would otherwise be told off
|
||||||
* single launch. The button in Settings *is* asking a question, and reports
|
* for it every single launch. The button in Settings *is* asking a question, and
|
||||||
* every answer including those two.
|
* reports 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,17 +8,36 @@ 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 plg.wallacenet.ca (Cloudflare Pages).
|
* Over-the-air updates from the repository's releases.
|
||||||
*
|
*
|
||||||
* Release tags are v<series>.<run-number> and the APK's versionCode is that same run
|
* The channel is the Gitea instance this app ships from
|
||||||
* number, so comparing the two is enough to tell whether a newer build exists.
|
* (`git.wallacenet.ca`), not GitHub. Gitea's release API answers with the
|
||||||
* Everything there is public — no token is embedded in the app.
|
* same shape the updater reads — `tag_name`, `prerelease`, `draft`, and
|
||||||
|
* 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
|
||||||
@@ -33,6 +52,11 @@ 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(
|
||||||
@@ -46,37 +70,70 @@ class Updater(private val activity: Activity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each channel publishes a `latest.json` next to its APK on Cloudflare
|
* Production reads the "latest release", which by definition skips
|
||||||
* Pages: `/prod/latest.json` and `/dev/latest.json`. The two live in one
|
* prereleases — Gitea implements it as the most recent non-prerelease,
|
||||||
* site but separate paths, so a build can only ever look down its own line.
|
* non-draft release, so the rule holds on this instance too.
|
||||||
|
*
|
||||||
|
* 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? {
|
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) {
|
||||||
val path = if (channel == UpdateChannel.DEV) "dev" else "prod"
|
UpdateChannel.PRODUCTION ->
|
||||||
return getJsonObject("$BASE_URL/$path/latest.json")?.let { toRelease(it) }
|
getJsonObject("$API/releases/latest")
|
||||||
|
?.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").ifBlank { return null }
|
val tag = json.optString("tag_name").ifBlank { return null }
|
||||||
val versionCode = versionCodeOfTag(tag) ?: return null
|
val versionCode = versionCodeOfTag(tag) ?: return null
|
||||||
val apkUrl = json.optString("apkUrl").ifBlank { return null }
|
val channel = channelOfTag(tag, json.optBoolean("prerelease"))
|
||||||
|
|
||||||
|
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 = apkUrl,
|
apkUrl = onReportedHost(chosen.getString("browser_download_url"), SERVER_HOST),
|
||||||
apkName = json.optString("apkName"),
|
apkName = chosen.optString("name"),
|
||||||
channel = channelOfTag(tag, prerelease = false),
|
channel = channel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getJsonObject(url: String): JSONObject? {
|
private fun openConnection(url: String): HttpURLConnection =
|
||||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = "GET"
|
requestMethod = "GET"
|
||||||
setRequestProperty("User-Agent", "ProLibertateGames-Updater")
|
setRequestProperty("Accept", "application/json")
|
||||||
|
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 {
|
||||||
@@ -84,6 +141,16 @@ 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())
|
||||||
|
|
||||||
@@ -135,6 +202,13 @@ 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 {
|
||||||
const val BASE_URL = "https://plg.wallacenet.ca"
|
/** The Gitea instance that publishes this app's releases. */
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,17 +106,19 @@ class CatalogTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `every game in the catalogue can be played`() {
|
fun `every reference game in the catalogue can be played`() {
|
||||||
// There is nothing left marked "coming soon". The machinery that hides
|
// Every game the app ships is playable. The one exception is Katan,
|
||||||
// unfinished games from a production build is still there and still
|
// listed so the slot exists and marked *Coming soon* until its engine
|
||||||
// tested above — it simply has nothing to hide at the moment, and this
|
// and screen land — `available = false` is what keeps an unfinished
|
||||||
// is what will fail the day something is added to the list before its
|
// game out of a production build while leaving it visible on a dev one.
|
||||||
// engine is written.
|
|
||||||
assertTrue(GameCatalog.all.all { it.available })
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
|
listOf(GameCatalog.CATAN),
|
||||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
|
GameCatalog.all.filterNot { it.available }.map { it.id },
|
||||||
)
|
)
|
||||||
|
// It is also the only difference the two channels see in a menu.
|
||||||
|
val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
|
||||||
|
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
|
||||||
|
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
class CatanAiTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||||
|
gameId = GameCatalog.CATAN,
|
||||||
|
seats = (0 until options.playerCount).map {
|
||||||
|
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||||
|
},
|
||||||
|
optionsJson = json.encodeToString(options),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Plays one table of engines out to a result, refusing every illegal move. */
|
||||||
|
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 4_000): CatanState {
|
||||||
|
val ai = CatanAi()
|
||||||
|
var state = CatanRules.initialState(config(options, seed))
|
||||||
|
var moves = 0
|
||||||
|
while (!CatanRules.isFinished(state)) {
|
||||||
|
assertTrue("seed $seed ran past $cap moves", moves++ < cap)
|
||||||
|
val seat = CatanRules.currentSeat(state) ?: break
|
||||||
|
val move = ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat))
|
||||||
|
assertTrue(
|
||||||
|
"seed $seed: seat $seat chose the illegal move $move",
|
||||||
|
CatanRules.isLegalMove(state, seat, move),
|
||||||
|
)
|
||||||
|
state = CatanRules.applyMove(state, seat, move)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three engines finish a game at ten points`() {
|
||||||
|
listOf(11L, 22L, 33L).forEach { seed ->
|
||||||
|
val state = playOut(CatanOptions(), seed)
|
||||||
|
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||||
|
val winner = state.winner
|
||||||
|
assertTrue("seed $seed finished without a winner", winner != null)
|
||||||
|
assertTrue(
|
||||||
|
"seed $seed: the winner fell short of the target",
|
||||||
|
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `four engines finish a game at ten points`() {
|
||||||
|
listOf(44L, 55L).forEach { seed ->
|
||||||
|
val state = playOut(CatanOptions(playerCount = 4), seed)
|
||||||
|
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||||
|
val winner = state.winner
|
||||||
|
assertTrue("seed $seed finished without a winner", winner != null)
|
||||||
|
assertTrue(
|
||||||
|
"seed $seed: the winner fell short of the target",
|
||||||
|
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mid-game state on seat 0's turn, one card short of a settlement. */
|
||||||
|
private fun offeringState(): CatanState =
|
||||||
|
CatanRules.initialState(config(CatanOptions(), seed = 7L))
|
||||||
|
.copy(
|
||||||
|
phase = CatanPhase.PLAYING,
|
||||||
|
turn = 0,
|
||||||
|
turnNumber = 1,
|
||||||
|
// Lumber to spare, brick missing, and a neighbour visibly
|
||||||
|
// holding brick. No roads or buildings, so no build is legal
|
||||||
|
// and no port is open — the offer is the only move forward.
|
||||||
|
resources = listOf(
|
||||||
|
hand(Resource.LUMBER to 4, Resource.WOOL to 1, Resource.GRAIN to 1),
|
||||||
|
hand(Resource.BRICK to 2),
|
||||||
|
hand(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||||
|
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the AI offers the trade that closes its build`() {
|
||||||
|
val state = offeringState()
|
||||||
|
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
|
||||||
|
assertEquals(
|
||||||
|
OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)),
|
||||||
|
move,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the AI offers at most one trade a turn`() {
|
||||||
|
// The same table, but the offer was already made and refused once.
|
||||||
|
val state = offeringState().copy(
|
||||||
|
log = listOf(
|
||||||
|
"Seat 0 rolls 3 + 2.",
|
||||||
|
"Seat 0 offers 2 lumber for a brick to seat 1.",
|
||||||
|
"Seat 1 declines the offer.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
|
||||||
|
assertTrue("the AI re-offered within one turn: $move", move !is OfferTrade)
|
||||||
|
assertEquals(EndTurn, move)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the AI answers a pending offer with accept or decline`() {
|
||||||
|
val state = CatanRules.initialState(config(CatanOptions(), seed = 7L))
|
||||||
|
.copy(
|
||||||
|
phase = CatanPhase.TRADE_PENDING,
|
||||||
|
turn = 0,
|
||||||
|
turnNumber = 1,
|
||||||
|
pendingTrade = TradeOffer(
|
||||||
|
by = 0, to = 1,
|
||||||
|
give = listOf(Resource.LUMBER, Resource.LUMBER),
|
||||||
|
get = listOf(Resource.BRICK),
|
||||||
|
),
|
||||||
|
resources = listOf(
|
||||||
|
hand(Resource.LUMBER to 3),
|
||||||
|
hand(Resource.BRICK to 2, Resource.WOOL to 1),
|
||||||
|
hand(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val legal = CatanRules.legalMoves(state, 1)
|
||||||
|
val move = CatanAi().chooseMove(state, 1, legal)
|
||||||
|
assertTrue("neither accept nor decline: $move", move == AcceptTrade || move == DeclineTrade)
|
||||||
|
assertTrue(move in legal)
|
||||||
|
|
||||||
|
val after = CatanRules.applyMove(state, 1, move)
|
||||||
|
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||||
|
assertNull(after.pendingTrade)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CatanBoardTest {
|
||||||
|
|
||||||
|
// -- Geometry -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the island has 19 hexes, 54 vertices, 72 edges and 30 coastal edges`() {
|
||||||
|
assertEquals(19, CatanBoard.HEX_COUNT)
|
||||||
|
assertEquals(54, CatanBoard.VERTEX_COUNT)
|
||||||
|
assertEquals(72, CatanBoard.EDGE_COUNT)
|
||||||
|
assertEquals("the coast is one ring of 30 edges", 30, CatanBoard.COASTAL.size)
|
||||||
|
assertEquals("nine harbour positions", 9, CatanBoard.PORT_POSITIONS.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every hex has six distinct corners and every edge two distinct ends`() {
|
||||||
|
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
assertEquals("hex $h", 6, CatanBoard.HEX_CORNERS[h].size)
|
||||||
|
assertEquals("hex $h repeats a corner", 6, CatanBoard.HEX_CORNERS[h].distinct().size)
|
||||||
|
}
|
||||||
|
CatanBoard.EDGE_VERTICES.forEach { (a, b) -> assertTrue("edge loops on itself", a != b) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two vertices never share a lattice position`() {
|
||||||
|
assertEquals(CatanBoard.VERTEX_COUNT, CatanBoard.VERTEX_POS.distinct().size)
|
||||||
|
assertEquals(CatanBoard.HEX_COUNT, (0 until CatanBoard.HEX_COUNT)
|
||||||
|
.map { CatanBoard.hexCenter(it) }
|
||||||
|
.distinct().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adjacent hexes share exactly two vertices and one edge`() {
|
||||||
|
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
for (n in CatanBoard.HEX_NEIGHBORS[h]) {
|
||||||
|
val shared = CatanBoard.HEX_CORNERS[h].toSet()
|
||||||
|
.intersect(CatanBoard.HEX_CORNERS[n].toSet())
|
||||||
|
assertEquals("hexes $h and $n", 2, shared.size)
|
||||||
|
val sharedEdge = CatanBoard.EDGE_HEXES.indices.count { e ->
|
||||||
|
CatanBoard.EDGE_HEXES[e].containsAll(listOf(h, n))
|
||||||
|
}
|
||||||
|
assertEquals("hexes $h and $n", 1, sharedEdge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vertices have two or three edges, each to a distinct neighbour`() {
|
||||||
|
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
|
assertTrue("vertex $v", CatanBoard.VERTEX_EDGES[v].size in 2..3)
|
||||||
|
assertEquals("vertex $v", CatanBoard.VERTEX_EDGES[v].size, CatanBoard.VERTEX_NEIGHBORS[v].size)
|
||||||
|
CatanBoard.VERTEX_NEIGHBORS[v].forEach { n ->
|
||||||
|
assertTrue(
|
||||||
|
"vertices $v and $n are not joined by an edge",
|
||||||
|
CatanBoard.VERTEX_EDGES[v].any { CatanBoard.otherEnd(it, v) == n },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interior edges touch two hexes and coastal edges one`() {
|
||||||
|
CatanBoard.EDGE_HEXES.forEach { hexes -> assertTrue(hexes.size in 1..2) }
|
||||||
|
// Nineteen hexes times six edges each, counted per side: 114.
|
||||||
|
assertEquals(114, CatanBoard.EDGE_HEXES.sumOf { it.size })
|
||||||
|
val coastal = CatanBoard.EDGE_HEXES.indices.filter { CatanBoard.EDGE_HEXES[it].size == 1 }
|
||||||
|
assertEquals(CatanBoard.COASTAL.toSet(), coastal.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the coast is one closed ring and the harbours are spread along it`() {
|
||||||
|
CatanBoard.COASTAL.forEachIndexed { i, e ->
|
||||||
|
val next = CatanBoard.COASTAL[(i + 1) % CatanBoard.COASTAL.size]
|
||||||
|
val shared = ends(e).intersect(ends(next))
|
||||||
|
assertTrue("coastal edges $e and $next are not consecutive", shared.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(9, CatanBoard.PORT_POSITIONS.distinct().size)
|
||||||
|
assertTrue(CatanBoard.PORT_POSITIONS.all { it in CatanBoard.COASTAL })
|
||||||
|
val ringSpots = CatanBoard.PORT_POSITIONS.map { CatanBoard.COASTAL.indexOf(it) }.sorted()
|
||||||
|
val gaps = ringSpots.zipWithNext { a, b -> b - a } +
|
||||||
|
(CatanBoard.COASTAL.size - ringSpots.last() + ringSpots.first())
|
||||||
|
assertTrue("harbours sit closer than three edges apart: $gaps", gaps.all { it >= 3 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `token odds follow the dice`() {
|
||||||
|
assertEquals(5, CatanBoard.numberOdds(6))
|
||||||
|
assertEquals(5, CatanBoard.numberOdds(8))
|
||||||
|
assertEquals(1, CatanBoard.numberOdds(2))
|
||||||
|
assertEquals(1, CatanBoard.numberOdds(12))
|
||||||
|
assertEquals(36, (2..12).sumOf { CatanBoard.numberOdds(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Island generation --------------------------------------------------
|
||||||
|
|
||||||
|
private val terrainCounts = mapOf(
|
||||||
|
Terrain.FOREST to 4,
|
||||||
|
Terrain.PASTURE to 4,
|
||||||
|
Terrain.FIELDS to 4,
|
||||||
|
Terrain.HILLS to 3,
|
||||||
|
Terrain.MOUNTAINS to 3,
|
||||||
|
Terrain.DESERT to 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The 18 number tokens dealt to the land, the seven withheld. */
|
||||||
|
private val tokens = listOf(6, 6, 8, 8, 2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every seed deals the fixed terrain mix`() {
|
||||||
|
for (seed in 1L..500L) {
|
||||||
|
val island = CatanBoard.generate(seed)
|
||||||
|
assertEquals("seed $seed", 19, island.terrains.size)
|
||||||
|
terrainCounts.forEach { (terrain, n) ->
|
||||||
|
assertEquals("seed $seed $terrain", n, island.terrains.count { it == terrain })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every seed deals the full token set and the desert stays bare`() {
|
||||||
|
for (seed in 1L..500L) {
|
||||||
|
val island = CatanBoard.generate(seed)
|
||||||
|
val dealt = island.numbers.filter { it > 0 }
|
||||||
|
assertEquals("seed $seed", 18, dealt.size)
|
||||||
|
assertEquals("seed $seed", tokens.sorted(), dealt.sorted())
|
||||||
|
assertEquals("seed $seed: the desert bears no token", 0, island.numbers[island.robber])
|
||||||
|
assertEquals(
|
||||||
|
"seed $seed: the robber does not start on the desert",
|
||||||
|
island.terrains.indexOf(Terrain.DESERT),
|
||||||
|
island.robber,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no seed puts two red tokens on adjacent hexes`() {
|
||||||
|
for (seed in 1L..500L) {
|
||||||
|
val island = CatanBoard.generate(seed)
|
||||||
|
val red = island.numbers.withIndex()
|
||||||
|
.filter { it.value == 6 || it.value == 8 }
|
||||||
|
.map { it.index }
|
||||||
|
assertEquals("seed $seed", 4, red.size)
|
||||||
|
for (i in red.indices) {
|
||||||
|
for (j in i + 1 until red.size) {
|
||||||
|
assertTrue(
|
||||||
|
"seed $seed: red hexes ${red[i]} and ${red[j]} touch",
|
||||||
|
!CatanBoard.hexesAdjacent(red[i], red[j]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every seed spreads nine harbours, five special and four generic`() {
|
||||||
|
for (seed in 1L..500L) {
|
||||||
|
val island = CatanBoard.generate(seed)
|
||||||
|
assertEquals("seed $seed", 9, island.ports.size)
|
||||||
|
assertEquals("seed $seed", 5, island.ports.count { it.resource != null })
|
||||||
|
assertEquals("seed $seed", 4, island.ports.count { it.resource == null })
|
||||||
|
assertTrue("seed $seed", island.ports.all { port ->
|
||||||
|
port.ratio == if (port.resource == null) 3 else 2
|
||||||
|
})
|
||||||
|
assertEquals(
|
||||||
|
"seed $seed: every resource has its own harbour",
|
||||||
|
Resource.entries,
|
||||||
|
island.ports.mapNotNull { it.resource }.sorted(),
|
||||||
|
)
|
||||||
|
assertEquals("seed $seed", 9, island.ports.map { it.edge }.distinct().size)
|
||||||
|
assertTrue("seed $seed", island.ports.all { it.edge in CatanBoard.COASTAL })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same seed builds the same island`() {
|
||||||
|
for (seed in listOf(0L, 7L, 42L, 99L, -1L)) {
|
||||||
|
assertEquals("seed $seed", CatanBoard.generate(seed), CatanBoard.generate(seed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two vertices of [edge], as a set. */
|
||||||
|
private fun ends(edge: Int): Set<Int> {
|
||||||
|
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||||
|
return setOf(a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,861 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
class CatanRulesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(
|
||||||
|
options: CatanOptions = CatanOptions(),
|
||||||
|
seed: Long = 7L,
|
||||||
|
) = TableConfig(
|
||||||
|
gameId = GameCatalog.CATAN,
|
||||||
|
seats = (0 until options.playerCount).map {
|
||||||
|
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||||
|
},
|
||||||
|
optionsJson = json.encodeToString(options),
|
||||||
|
seed = seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mid-game state on some seat's turn, built from a real island so the
|
||||||
|
* terrain, tokens, robber and harbours are a genuine layout. What a test
|
||||||
|
* bends with [adjust] is its own business; the moves it then asks about
|
||||||
|
* are checked against the rules like any other.
|
||||||
|
*/
|
||||||
|
private fun craft(
|
||||||
|
options: CatanOptions = CatanOptions(),
|
||||||
|
seed: Long = 7L,
|
||||||
|
turn: Int = 0,
|
||||||
|
adjust: CatanState.() -> CatanState = { this },
|
||||||
|
): CatanState = CatanRules.initialState(config(options, seed))
|
||||||
|
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
|
||||||
|
.adjust()
|
||||||
|
|
||||||
|
private fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||||
|
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||||
|
|
||||||
|
private fun hands(vararg perSeat: Map<Resource, Int>, players: Int = 3): List<Map<Resource, Int>> =
|
||||||
|
(0 until players).map { perSeat.getOrNull(it) ?: hand() }
|
||||||
|
|
||||||
|
private fun CatanState.withBuilding(
|
||||||
|
vertex: Int,
|
||||||
|
type: BuildingType,
|
||||||
|
owner: Int,
|
||||||
|
): CatanState = copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
|
||||||
|
|
||||||
|
private fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
|
||||||
|
copy(roads = roads.toMutableList().also { it[edge] = owner })
|
||||||
|
|
||||||
|
/** A roll whose dice add up to [total]. */
|
||||||
|
private fun rollFor(total: Int): RollDice {
|
||||||
|
val first = (total - 1).coerceAtMost(6)
|
||||||
|
return RollDice(forced = listOf(first, total - first))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A producing hex corner whose other hexes bear a different number. */
|
||||||
|
private data class Spot(val resource: Resource, val number: Int, val vertex: Int)
|
||||||
|
|
||||||
|
private fun CatanState.pickSpot(): Spot {
|
||||||
|
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||||
|
val resource = terrains[h].produces ?: continue
|
||||||
|
val number = numbers[h]
|
||||||
|
val vertex = CatanBoard.HEX_CORNERS[h].firstOrNull { corner ->
|
||||||
|
CatanBoard.VERTEX_HEXES[corner].all { it == h || numbers[it] != number }
|
||||||
|
}
|
||||||
|
if (vertex != null) return Spot(resource, number, vertex)
|
||||||
|
}
|
||||||
|
error("no clean producing corner on this island")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The six edges of hex [h], in walking order around it. */
|
||||||
|
private fun hexRing(h: Int): List<Int> {
|
||||||
|
val corners = CatanBoard.HEX_CORNERS[h]
|
||||||
|
return (0..5).map { k ->
|
||||||
|
val a = corners[k]
|
||||||
|
val b = corners[(k + 1) % 6]
|
||||||
|
CatanBoard.EDGE_VERTICES.indices.first { e ->
|
||||||
|
val (x, y) = CatanBoard.EDGE_VERTICES[e]
|
||||||
|
(x == a && y == b) || (x == b && y == a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sharedVertex(a: Int, b: Int): Int =
|
||||||
|
ends(a).intersect(ends(b)).single()
|
||||||
|
|
||||||
|
/** The two vertices of [edge], as a set. */
|
||||||
|
private fun ends(edge: Int): Set<Int> {
|
||||||
|
val (x, y) = CatanBoard.EDGE_VERTICES[edge]
|
||||||
|
return setOf(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [count] vertices at least two edges apart, for standing buildings on. */
|
||||||
|
private fun scattered(count: Int): List<Int> {
|
||||||
|
val picked = mutableListOf<Int>()
|
||||||
|
var v = 0
|
||||||
|
while (picked.size < count) {
|
||||||
|
if (CatanBoard.VERTEX_NEIGHBORS[v].none { it in picked }) picked += v
|
||||||
|
v++
|
||||||
|
}
|
||||||
|
return picked
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Setup ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setup runs in snake order and then seat zero leads`() {
|
||||||
|
var state = CatanRules.initialState(config())
|
||||||
|
val order = mutableListOf<Int>()
|
||||||
|
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||||
|
val seat = CatanRules.currentSeat(state)!!
|
||||||
|
order += seat
|
||||||
|
state = CatanRules.applyMove(
|
||||||
|
state, seat,
|
||||||
|
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(listOf(0, 1, 2, 2, 1, 0), order)
|
||||||
|
assertEquals(CatanPhase.AWAIT_ROLL, state.phase)
|
||||||
|
assertEquals(0, state.turn)
|
||||||
|
assertEquals(1, state.turnNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the second settlement earns one card from each adjacent land`() {
|
||||||
|
var state = CatanRules.initialState(config())
|
||||||
|
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||||
|
val seat = CatanRules.currentSeat(state)!!
|
||||||
|
val move = CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first()
|
||||||
|
val second = state.phase == CatanPhase.SETUP_SECOND
|
||||||
|
val before = state.resources[seat]
|
||||||
|
state = CatanRules.applyMove(state, seat, move)
|
||||||
|
if (second) {
|
||||||
|
val expected = CatanBoard.VERTEX_HEXES[move.vertex]
|
||||||
|
.mapNotNull { state.terrains[it].produces }
|
||||||
|
.groupingBy { it }.eachCount()
|
||||||
|
.let { earned ->
|
||||||
|
before.toMutableMap().also { hand ->
|
||||||
|
earned.forEach { (res, n) -> hand[res] = (hand[res] ?: 0) + n }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("seat $seat's second income", expected, state.resources[seat])
|
||||||
|
} else {
|
||||||
|
assertEquals("no income during the first pass", before, state.resources[seat])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setup leaves every settlement two edges from its neighbour`() {
|
||||||
|
var state = CatanRules.initialState(config())
|
||||||
|
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||||
|
val seat = CatanRules.currentSeat(state)!!
|
||||||
|
state = CatanRules.applyMove(
|
||||||
|
state, seat,
|
||||||
|
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val placed = state.buildings.withIndex().filter { it.value != null }.map { it.index }
|
||||||
|
assertEquals(6, placed.size)
|
||||||
|
placed.forEach { v ->
|
||||||
|
assertTrue(
|
||||||
|
"a settlement stands beside $v",
|
||||||
|
CatanBoard.VERTEX_NEIGHBORS[v].none { state.buildings[it] != null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Every road laid in setup touches the settlement it was laid with.
|
||||||
|
state.roads.withIndex().filter { it.value != NO_PLAYER }.forEach { (edge, _) ->
|
||||||
|
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||||
|
assertTrue(
|
||||||
|
"road $edge touches no settlement",
|
||||||
|
state.buildings[a] != null || state.buildings[b] != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the island seats three or four`() {
|
||||||
|
assertThrows { CatanOptions(playerCount = 2) }
|
||||||
|
assertThrows { CatanOptions(playerCount = 5) }
|
||||||
|
val fourSeats = config(CatanOptions(playerCount = 3))
|
||||||
|
.let { it.copy(seats = it.seats.drop(1)) }
|
||||||
|
assertThrows { CatanRules.initialState(fourSeats) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Production ----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a roll pays settlements singly and cities double`() {
|
||||||
|
val base = craft()
|
||||||
|
val spot = base.pickSpot()
|
||||||
|
|
||||||
|
val settled = base.withBuilding(spot.vertex, BuildingType.SETTLEMENT, 0)
|
||||||
|
.copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand()))
|
||||||
|
val afterSettlement = CatanRules.applyMove(settled, 0, rollFor(spot.number))
|
||||||
|
assertEquals(1, afterSettlement.resources[0][spot.resource])
|
||||||
|
assertEquals(CatanPhase.PLAYING, afterSettlement.phase)
|
||||||
|
|
||||||
|
val raised = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
|
||||||
|
.copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand()))
|
||||||
|
val afterCity = CatanRules.applyMove(raised, 0, rollFor(spot.number))
|
||||||
|
assertEquals(2, afterCity.resources[0][spot.resource])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the robber's hex produces nothing`() {
|
||||||
|
val base = craft()
|
||||||
|
val spot = base.pickSpot()
|
||||||
|
val hex = CatanBoard.VERTEX_HEXES[spot.vertex].first { base.numbers[it] == spot.number }
|
||||||
|
val state = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
|
||||||
|
.copy(phase = CatanPhase.AWAIT_ROLL, robber = hex, resources = hands(hand()))
|
||||||
|
val after = CatanRules.applyMove(state, 0, rollFor(spot.number))
|
||||||
|
assertEquals("the blocked city takes nothing", 0, after.resources[0][spot.resource])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bank-short resource pays nobody`() {
|
||||||
|
val base = craft()
|
||||||
|
val spot = base.pickSpot()
|
||||||
|
// A city wants two; the bank holds one, because seat 1 hoards the rest.
|
||||||
|
val state = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
|
||||||
|
.copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand(), hand(spot.resource to 18)))
|
||||||
|
val after = CatanRules.applyMove(state, 0, rollFor(spot.number))
|
||||||
|
assertEquals("the city takes nothing while the bank is short", 0, after.resources[0][spot.resource])
|
||||||
|
assertEquals(18, after.resources[1][spot.resource])
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- The robber ----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seven with no overfull hand rides the robber directly`() {
|
||||||
|
val state = craft().copy(
|
||||||
|
phase = CatanPhase.AWAIT_ROLL,
|
||||||
|
resources = hands(hand(), hand(Resource.LUMBER to 7), hand(Resource.BRICK to 7)),
|
||||||
|
)
|
||||||
|
val after = CatanRules.applyMove(state, 0, RollDice(forced = listOf(6, 1)))
|
||||||
|
assertEquals(CatanPhase.ROBBER, after.phase)
|
||||||
|
assertNull(after.discardSeat)
|
||||||
|
assertTrue(after.log.any { it.startsWith("Seat 0 rolls 7") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seven makes overfull hands discard half in seat order`() {
|
||||||
|
var state = craft().copy(
|
||||||
|
phase = CatanPhase.AWAIT_ROLL,
|
||||||
|
resources = hands(
|
||||||
|
hand(),
|
||||||
|
hand(Resource.LUMBER to 2, Resource.BRICK to 3, Resource.WOOL to 4), // 9 cards
|
||||||
|
hand(Resource.GRAIN to 8), // 8 cards
|
||||||
|
),
|
||||||
|
)
|
||||||
|
state = CatanRules.applyMove(state, 0, RollDice(forced = listOf(3, 4)))
|
||||||
|
assertEquals(CatanPhase.DISCARDING, state.phase)
|
||||||
|
assertEquals(1, state.discardSeat)
|
||||||
|
assertEquals(1, CatanRules.currentSeat(state))
|
||||||
|
|
||||||
|
// Nine cards means four gone, exactly, and no other number of them.
|
||||||
|
assertTrue(CatanRules.isLegalMove(state, 1, Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.BRICK))))
|
||||||
|
assertFalse(CatanRules.isLegalMove(state, 1, Discard(listOf(Resource.LUMBER))))
|
||||||
|
assertFalse(CatanRules.isLegalMove(state, 1, Discard(List(5) { Resource.WOOL })))
|
||||||
|
state = CatanRules.applyMove(
|
||||||
|
state, 1,
|
||||||
|
Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.BRICK)),
|
||||||
|
)
|
||||||
|
assertEquals("seat 2 still owes the robber", CatanPhase.DISCARDING, state.phase)
|
||||||
|
assertEquals(2, state.discardSeat)
|
||||||
|
|
||||||
|
state = CatanRules.applyMove(state, 2, Discard(List(4) { Resource.GRAIN }))
|
||||||
|
assertEquals(CatanPhase.ROBBER, state.phase)
|
||||||
|
assertEquals("the roller moves the robber", 0, CatanRules.currentSeat(state))
|
||||||
|
assertEquals(5, state.resourceCount(1))
|
||||||
|
assertEquals(4, state.resourceCount(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the robber must move and steals one card from a built-up hex`() {
|
||||||
|
val base = craft()
|
||||||
|
val spot = base.pickSpot()
|
||||||
|
val hex = CatanBoard.VERTEX_HEXES[spot.vertex].first { base.numbers[it] == spot.number }
|
||||||
|
val elsewhere = (0 until CatanBoard.HEX_COUNT)
|
||||||
|
.first { it != base.robber && it != hex }
|
||||||
|
val state = base.withBuilding(spot.vertex, BuildingType.SETTLEMENT, 1)
|
||||||
|
.copy(
|
||||||
|
phase = CatanPhase.ROBBER,
|
||||||
|
robber = elsewhere,
|
||||||
|
resources = hands(hand(), hand(Resource.WOOL to 3)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val moves = CatanRules.legalMoves(state, 0).filterIsInstance<MoveRobber>()
|
||||||
|
assertTrue(
|
||||||
|
"the robber may not stay put",
|
||||||
|
moves.all { it.hex != CatanBoard.HEXES[state.robber] },
|
||||||
|
)
|
||||||
|
assertEquals("every other hex is reachable", 18, moves.map { it.hex }.distinct().size)
|
||||||
|
|
||||||
|
val steal = moves.first { it.hex == CatanBoard.HEXES[hex] && it.victim == 1 }
|
||||||
|
val after = CatanRules.applyMove(state, 0, steal)
|
||||||
|
assertEquals(hex, after.robber)
|
||||||
|
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||||
|
assertEquals(2, after.resources[1][Resource.WOOL])
|
||||||
|
assertEquals(1, after.resourceCount(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a forced roll must be two honest dice, and only tests may send it`() {
|
||||||
|
val state = craft().copy(phase = CatanPhase.AWAIT_ROLL)
|
||||||
|
assertTrue(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(3, 4))))
|
||||||
|
assertFalse(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(7, 0))))
|
||||||
|
assertFalse(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(3))))
|
||||||
|
// The derived roll is the enumerated move; a forced one is structural only.
|
||||||
|
assertTrue(RollDice() in CatanRules.legalMoves(state, 0))
|
||||||
|
assertFalse(RollDice(forced = listOf(3, 4)) in CatanRules.legalMoves(state, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Building ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a road must attach to the builder's own network`() {
|
||||||
|
val state = craft().copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
|
||||||
|
assertTrue("nothing to attach to", CatanRules.legalMoves(state, 0).none { it is BuildRoad })
|
||||||
|
|
||||||
|
val e0 = CatanBoard.VERTEX_EDGES[0][0]
|
||||||
|
val attached = state.withRoad(e0, 0)
|
||||||
|
val roads = CatanRules.legalMoves(attached, 0).filterIsInstance<BuildRoad>()
|
||||||
|
assertEquals(
|
||||||
|
"roads may only fan out from the network",
|
||||||
|
CatanBoard.EDGE_NEIGHBORS[e0].map { BuildRoad(it) }.toSet(),
|
||||||
|
roads.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a settlement needs an open vertex on the network, a city one of yours`() {
|
||||||
|
val base = craft()
|
||||||
|
val homes = scattered(3)
|
||||||
|
val state = base.withBuilding(homes[0], BuildingType.SETTLEMENT, 0)
|
||||||
|
.withRoad(CatanBoard.VERTEX_EDGES[homes[1]][0], 0)
|
||||||
|
.copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 2, Resource.ORE to 3)))
|
||||||
|
|
||||||
|
// Reachable: open, two edges from anything standing, on seat 0's road.
|
||||||
|
assertTrue(BuildSettlement(homes[1]) in CatanRules.legalMoves(state, 0))
|
||||||
|
// One edge from a standing settlement is too close, network or not.
|
||||||
|
val tooClose = CatanBoard.VERTEX_NEIGHBORS[homes[0]].first()
|
||||||
|
assertTrue(BuildSettlement(tooClose) !in CatanRules.legalMoves(state, 0))
|
||||||
|
// Open but off the network is out of reach.
|
||||||
|
assertTrue(BuildSettlement(homes[2]) !in CatanRules.legalMoves(state, 0))
|
||||||
|
|
||||||
|
// Raising costs two grain and three ore.
|
||||||
|
val raised = CatanRules.applyMove(state, 0, BuildCity(homes[0]))
|
||||||
|
assertEquals(BuildingType.CITY, raised.buildings[homes[0]]?.type)
|
||||||
|
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 0, Resource.ORE to 0), raised.resources[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Trade ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a domestic offer names any two multisets and waits on the table`() {
|
||||||
|
val state = craft().copy(
|
||||||
|
resources = hands(
|
||||||
|
hand(Resource.LUMBER to 3),
|
||||||
|
hand(Resource.BRICK to 2),
|
||||||
|
hand(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val offer = OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK))
|
||||||
|
assertTrue(CatanRules.isLegalMove(state, 0, offer))
|
||||||
|
assertFalse("not the addressee's turn", CatanRules.isLegalMove(state, 1, offer))
|
||||||
|
assertFalse("never to oneself", CatanRules.isLegalMove(state, 0, offer.copy(to = 0)))
|
||||||
|
assertFalse(
|
||||||
|
"like for like is not a trade",
|
||||||
|
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.LUMBER, Resource.BRICK), listOf(Resource.BRICK, Resource.LUMBER))),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
"an empty leg is not a trade",
|
||||||
|
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.LUMBER), emptyList())),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
"cards the offerer does not hold cannot be promised",
|
||||||
|
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.ORE), listOf(Resource.BRICK))),
|
||||||
|
)
|
||||||
|
|
||||||
|
val table = CatanRules.applyMove(state, 0, offer)
|
||||||
|
assertEquals(CatanPhase.TRADE_PENDING, table.phase)
|
||||||
|
assertEquals(1, CatanRules.currentSeat(table))
|
||||||
|
assertEquals(TradeOffer(by = 0, to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)), table.pendingTrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an accepted offer crosses the cards and a declined one clears the table`() {
|
||||||
|
val offered = craft().copy(
|
||||||
|
phase = CatanPhase.TRADE_PENDING,
|
||||||
|
pendingTrade = TradeOffer(
|
||||||
|
by = 0, to = 1,
|
||||||
|
give = listOf(Resource.LUMBER, Resource.LUMBER),
|
||||||
|
get = listOf(Resource.BRICK),
|
||||||
|
),
|
||||||
|
resources = hands(
|
||||||
|
hand(Resource.LUMBER to 3),
|
||||||
|
hand(Resource.BRICK to 2),
|
||||||
|
hand(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val accepted = CatanRules.applyMove(offered, 1, AcceptTrade)
|
||||||
|
assertEquals(CatanPhase.PLAYING, accepted.phase)
|
||||||
|
assertNull(accepted.pendingTrade)
|
||||||
|
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), accepted.resources[0])
|
||||||
|
assertEquals(hand(Resource.LUMBER to 2, Resource.BRICK to 1), accepted.resources[1])
|
||||||
|
|
||||||
|
val declined = CatanRules.applyMove(offered, 1, DeclineTrade)
|
||||||
|
assertEquals(CatanPhase.PLAYING, declined.phase)
|
||||||
|
assertNull(declined.pendingTrade)
|
||||||
|
assertEquals("a refusal leaves the cards where they were", offered.resources, declined.resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an offer the addressee cannot honour can only be declined`() {
|
||||||
|
val offered = craft().copy(
|
||||||
|
phase = CatanPhase.TRADE_PENDING,
|
||||||
|
pendingTrade = TradeOffer(
|
||||||
|
by = 0, to = 1,
|
||||||
|
give = listOf(Resource.LUMBER, Resource.LUMBER),
|
||||||
|
get = listOf(Resource.BRICK),
|
||||||
|
),
|
||||||
|
resources = hands(hand(Resource.LUMBER to 3), hand(), hand()),
|
||||||
|
)
|
||||||
|
assertEquals(listOf<CatanMove>(DeclineTrade), CatanRules.legalMoves(offered, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bank trades four for one and harbours do better`() {
|
||||||
|
val base = craft()
|
||||||
|
val lumberPort = base.ports.first { it.resource == Resource.LUMBER }
|
||||||
|
val genericPort = base.ports.first { it.resource == null }
|
||||||
|
val portSide = { port: Port -> CatanBoard.EDGE_VERTICES[port.edge].first }
|
||||||
|
|
||||||
|
val plain = base.copy(resources = hands(hand(Resource.LUMBER to 4)))
|
||||||
|
assertEquals(4, CatanRules.bestPortRatio(plain, 0, Resource.LUMBER))
|
||||||
|
val viaBank = CatanRules.applyMove(plain, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
|
||||||
|
assertEquals(hand(Resource.BRICK to 1), viaBank.resources[0])
|
||||||
|
|
||||||
|
val special = base.withBuilding(portSide(lumberPort), BuildingType.SETTLEMENT, 0)
|
||||||
|
.copy(resources = hands(hand(Resource.LUMBER to 2)))
|
||||||
|
assertEquals(2, CatanRules.bestPortRatio(special, 0, Resource.LUMBER))
|
||||||
|
assertEquals(4, CatanRules.bestPortRatio(special, 0, Resource.BRICK))
|
||||||
|
val viaPort = CatanRules.applyMove(special, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
|
||||||
|
assertEquals(hand(Resource.BRICK to 1), viaPort.resources[0])
|
||||||
|
|
||||||
|
val generic = base.withBuilding(portSide(genericPort), BuildingType.SETTLEMENT, 0)
|
||||||
|
.copy(resources = hands(hand(Resource.LUMBER to 3)))
|
||||||
|
assertEquals(3, CatanRules.bestPortRatio(generic, 0, Resource.LUMBER))
|
||||||
|
val viaGeneric = CatanRules.applyMove(generic, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
|
||||||
|
assertEquals(hand(Resource.BRICK to 1), viaGeneric.resources[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bank trade needs the cards and a bank with something to give`() {
|
||||||
|
val base = craft()
|
||||||
|
assertFalse(
|
||||||
|
"three is one short of the bank's four",
|
||||||
|
CatanRules.isLegalMove(
|
||||||
|
base.copy(resources = hands(hand(Resource.LUMBER to 3))),
|
||||||
|
0,
|
||||||
|
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
"the bank holds no brick to give",
|
||||||
|
CatanRules.isLegalMove(
|
||||||
|
base.copy(resources = hands(hand(Resource.LUMBER to 4), hand(Resource.BRICK to 19))),
|
||||||
|
0,
|
||||||
|
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
"no resource trades for itself",
|
||||||
|
CatanRules.isLegalMove(
|
||||||
|
base.copy(resources = hands(hand(Resource.LUMBER to 4))),
|
||||||
|
0,
|
||||||
|
MaritimeTrade(Resource.LUMBER, Resource.LUMBER),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
CatanRules.isLegalMove(
|
||||||
|
base.copy(resources = hands(hand(Resource.LUMBER to 4))),
|
||||||
|
0,
|
||||||
|
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Development cards ---------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buying a card costs wool, grain and ore and hides it until next turn`() {
|
||||||
|
val base = craft().copy(resources = hands(hand(Resource.WOOL to 1, Resource.GRAIN to 1, Resource.ORE to 1)))
|
||||||
|
val top = base.devDeck.first()
|
||||||
|
val state = CatanRules.applyMove(base, 0, BuyDevCard)
|
||||||
|
assertEquals(hand(), state.resources[0])
|
||||||
|
assertEquals(listOf(top.copy(boughtTurn = 1)), state.devHands[0])
|
||||||
|
assertEquals(listOf(1, 0, 0), state.devHandCounts)
|
||||||
|
assertEquals(base.devDeck.drop(1), state.devDeck)
|
||||||
|
assertTrue("bought this turn, playable next", CatanRules.legalMoves(state, 0).none { it is PlayKnight })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a knight may not be played before the roll`() {
|
||||||
|
val state = craft().copy(
|
||||||
|
phase = CatanPhase.AWAIT_ROLL,
|
||||||
|
devHands = listOf(listOf(DevCard(90, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(1, 0, 0),
|
||||||
|
)
|
||||||
|
assertEquals(listOf<CatanMove>(RollDice()), CatanRules.legalMoves(state, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a card bought on an earlier turn is playable, one per turn`() {
|
||||||
|
val knight = DevCard(90, DevCardType.KNIGHT, 0)
|
||||||
|
val roadBuilding = DevCard(91, DevCardType.ROAD_BUILDING, 0)
|
||||||
|
val state = craft().copy(
|
||||||
|
devHands = listOf(listOf(knight, roadBuilding), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(2, 0, 0),
|
||||||
|
)
|
||||||
|
val moves = CatanRules.legalMoves(state, 0)
|
||||||
|
assertTrue(PlayKnight(90) in moves)
|
||||||
|
assertTrue(PlayRoadBuilding(91) in moves)
|
||||||
|
|
||||||
|
val after = CatanRules.applyMove(state, 0, PlayKnight(90))
|
||||||
|
assertEquals(CatanPhase.ROBBER, after.phase)
|
||||||
|
assertEquals(1, after.knightsPlayed[0])
|
||||||
|
assertEquals(listOf(roadBuilding), after.devHands[0])
|
||||||
|
assertTrue(after.devCardPlayedThisTurn)
|
||||||
|
|
||||||
|
// The robber rides first, then the turn owner may build and trade —
|
||||||
|
// but not play a second card.
|
||||||
|
val robberMove = CatanRules.legalMoves(after, 0).filterIsInstance<MoveRobber>().first()
|
||||||
|
val playing = CatanRules.applyMove(after, 0, robberMove)
|
||||||
|
assertEquals(CatanPhase.PLAYING, playing.phase)
|
||||||
|
assertTrue(
|
||||||
|
"one development card a turn",
|
||||||
|
CatanRules.legalMoves(playing, 0).none { it is PlayRoadBuilding },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three knights take Largest Army, and only a strictly larger army steals it`() {
|
||||||
|
val firstToThree = craft().copy(
|
||||||
|
devHands = listOf(listOf(DevCard(90, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(1, 0, 0),
|
||||||
|
knightsPlayed = listOf(2, 0, 0),
|
||||||
|
)
|
||||||
|
val took = CatanRules.applyMove(firstToThree, 0, PlayKnight(90))
|
||||||
|
assertEquals(0, took.largestArmyOwner)
|
||||||
|
assertEquals(5, CatanRules.publicVictoryPoints(took)[0])
|
||||||
|
|
||||||
|
// A tie does not steal it.
|
||||||
|
val tied = craft().copy(
|
||||||
|
devHands = listOf(listOf(DevCard(91, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(1, 0, 0),
|
||||||
|
knightsPlayed = listOf(2, 3, 0),
|
||||||
|
largestArmyOwner = 1,
|
||||||
|
)
|
||||||
|
val kept = CatanRules.applyMove(tied, 0, PlayKnight(91))
|
||||||
|
assertEquals("the incumbent keeps a tie", 1, kept.largestArmyOwner)
|
||||||
|
|
||||||
|
// A fourth knight does.
|
||||||
|
val stronger = craft().copy(
|
||||||
|
devHands = listOf(listOf(DevCard(92, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(1, 0, 0),
|
||||||
|
knightsPlayed = listOf(3, 3, 0),
|
||||||
|
largestArmyOwner = 1,
|
||||||
|
)
|
||||||
|
val stolen = CatanRules.applyMove(stronger, 0, PlayKnight(92))
|
||||||
|
assertEquals(0, stolen.largestArmyOwner)
|
||||||
|
assertEquals(5, CatanRules.publicVictoryPoints(stolen)[0])
|
||||||
|
assertEquals(0, CatanRules.publicVictoryPoints(stolen)[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Road Building lays two free segments and may be forfeited`() {
|
||||||
|
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
|
||||||
|
val ring = hexRing(centre)
|
||||||
|
val base = craft()
|
||||||
|
.copy(
|
||||||
|
resources = hands(hand(Resource.LUMBER to 9, Resource.BRICK to 9)),
|
||||||
|
devHands = listOf(listOf(DevCard(93, DevCardType.ROAD_BUILDING, 0)), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(1, 0, 0),
|
||||||
|
)
|
||||||
|
.withRoad(ring[0], 0)
|
||||||
|
|
||||||
|
val opened = CatanRules.applyMove(base, 0, PlayRoadBuilding(93))
|
||||||
|
assertEquals(CatanPhase.FREE_ROADS, opened.phase)
|
||||||
|
assertEquals(2, opened.pendingFreeRoads)
|
||||||
|
assertTrue(EndFreeRoads in CatanRules.legalMoves(opened, 0))
|
||||||
|
|
||||||
|
val first = CatanRules.applyMove(opened, 0, BuildRoad(ring[1]))
|
||||||
|
assertEquals(CatanPhase.FREE_ROADS, first.phase)
|
||||||
|
assertEquals(1, first.pendingFreeRoads)
|
||||||
|
assertEquals("free roads cost nothing", base.resources[0], first.resources[0])
|
||||||
|
|
||||||
|
val forfeited = CatanRules.applyMove(first, 0, EndFreeRoads)
|
||||||
|
assertEquals(CatanPhase.PLAYING, forfeited.phase)
|
||||||
|
assertEquals(0, forfeited.pendingFreeRoads)
|
||||||
|
assertEquals(base.resources[0], forfeited.resources[0])
|
||||||
|
assertEquals(2, forfeited.roads.count { it == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Monopoly seizes every card of the named resource`() {
|
||||||
|
val state = craft().copy(
|
||||||
|
resources = hands(
|
||||||
|
hand(Resource.WOOL to 2, Resource.GRAIN to 1),
|
||||||
|
hand(Resource.WOOL to 3, Resource.BRICK to 1),
|
||||||
|
hand(Resource.WOOL to 1),
|
||||||
|
),
|
||||||
|
devHands = listOf(listOf(DevCard(94, DevCardType.MONOPOLY, 0)), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(1, 0, 0),
|
||||||
|
)
|
||||||
|
val after = CatanRules.applyMove(state, 0, PlayMonopoly(94, Resource.WOOL))
|
||||||
|
assertEquals(hand(Resource.WOOL to 6, Resource.GRAIN to 1), after.resources[0])
|
||||||
|
assertEquals(hand(Resource.BRICK to 1), after.resources[1])
|
||||||
|
assertEquals(hand(), after.resources[2])
|
||||||
|
assertTrue(after.devHands[0].isEmpty())
|
||||||
|
assertTrue(after.devCardPlayedThisTurn)
|
||||||
|
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Year of Plenty takes two cards the bank can pay`() {
|
||||||
|
val base = craft().copy(
|
||||||
|
devHands = listOf(listOf(DevCard(95, DevCardType.YEAR_OF_PLENTY, 0)), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(1, 0, 0),
|
||||||
|
)
|
||||||
|
val took = CatanRules.applyMove(base, 0, PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.ORE)))
|
||||||
|
assertEquals(hand(Resource.GRAIN to 1, Resource.ORE to 1), took.resources[0])
|
||||||
|
|
||||||
|
// The bank holds a single grain, seat 1 keeping the other eighteen:
|
||||||
|
// two grain cannot be taken.
|
||||||
|
val shortBank = base.copy(resources = hands(hand(), hand(Resource.GRAIN to 18), hand()))
|
||||||
|
assertTrue(
|
||||||
|
"two of a kind needs a bank that can pay both",
|
||||||
|
PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.GRAIN)) !in CatanRules.legalMoves(shortBank, 0),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.ORE)) in CatanRules.legalMoves(shortBank, 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Longest Road --------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a closed ring counts as a simple path, not as two loops`() {
|
||||||
|
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
|
||||||
|
val ring = hexRing(centre)
|
||||||
|
var state = craft()
|
||||||
|
ring.forEach { state = state.withRoad(it, 0) }
|
||||||
|
assertEquals("six segments around one hex count five", 5, CatanRules.longestRoadFor(state, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `five segments take Longest Road, a tie keeps it, a settlement breaks it`() {
|
||||||
|
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
|
||||||
|
val ring = hexRing(centre)
|
||||||
|
// A challenger's path on a hex that touches neither the centre nor its ring.
|
||||||
|
val far = (0 until CatanBoard.HEX_COUNT).first { h ->
|
||||||
|
h != centre && CatanBoard.HEX_NEIGHBORS[h].none { it == centre }
|
||||||
|
}.let { hexRing(it) }
|
||||||
|
|
||||||
|
var state = craft().copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
|
||||||
|
ring.take(4).forEach { state = state.withRoad(it, 0) }
|
||||||
|
state = CatanRules.applyMove(state, 0, BuildRoad(ring[4]))
|
||||||
|
assertEquals("the fifth segment takes the award", 0, state.longestRoadOwner)
|
||||||
|
assertEquals(5, CatanRules.longestRoadFor(state, 0))
|
||||||
|
|
||||||
|
// An equal five does not steal it.
|
||||||
|
var challenger = state.copy(turn = 1, resources = hands(hand(), hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
|
||||||
|
far.take(4).forEach { challenger = challenger.withRoad(it, 1) }
|
||||||
|
challenger = CatanRules.applyMove(challenger, 1, BuildRoad(far[4]))
|
||||||
|
assertEquals("the incumbent keeps a tie", 0, challenger.longestRoadOwner)
|
||||||
|
assertEquals(5, CatanRules.longestRoadFor(challenger, 1))
|
||||||
|
|
||||||
|
// An opponent's settlement in the middle breaks the holder's road in
|
||||||
|
// two, and the award falls to the challenger.
|
||||||
|
val breakAt = sharedVertex(ring[2], ring[3])
|
||||||
|
val spoke = CatanBoard.VERTEX_EDGES[breakAt].first { it != ring[2] && it != ring[3] }
|
||||||
|
val breaker = challenger
|
||||||
|
.copy(
|
||||||
|
resources = hands(
|
||||||
|
hand(),
|
||||||
|
hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.withRoad(spoke, 1)
|
||||||
|
val broken = CatanRules.applyMove(breaker, 1, BuildSettlement(breakAt))
|
||||||
|
assertEquals("the broken road loses the award", 1, broken.longestRoadOwner)
|
||||||
|
assertEquals(3, CatanRules.longestRoadFor(broken, 0))
|
||||||
|
assertEquals(5, CatanRules.longestRoadFor(broken, 1))
|
||||||
|
assertEquals(6, CatanRules.publicVictoryPoints(broken)[1])
|
||||||
|
assertEquals(0, CatanRules.publicVictoryPoints(broken)[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Winning -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tenth point on the builder's own move wins`() {
|
||||||
|
val homes = scattered(7)
|
||||||
|
var state = craft()
|
||||||
|
.withBuilding(homes[0], BuildingType.CITY, 0)
|
||||||
|
.withBuilding(homes[1], BuildingType.CITY, 0)
|
||||||
|
homes.drop(2).forEach { state = state.withBuilding(it, BuildingType.SETTLEMENT, 0) }
|
||||||
|
state = state.copy(resources = hands(hand(Resource.GRAIN to 2, Resource.ORE to 3)))
|
||||||
|
|
||||||
|
val won = CatanRules.applyMove(state, 0, BuildCity(homes[2]))
|
||||||
|
assertEquals(CatanPhase.GAME_OVER, won.phase)
|
||||||
|
assertEquals(0, won.winner)
|
||||||
|
assertTrue(won.log.any { it.contains("Seat 0 reveals 10 victory points and wins") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hidden victory-point cards count and are revealed by winning`() {
|
||||||
|
val homes = scattered(7)
|
||||||
|
var state = craft()
|
||||||
|
.withBuilding(homes[0], BuildingType.CITY, 0)
|
||||||
|
.withBuilding(homes[1], BuildingType.CITY, 0)
|
||||||
|
homes.drop(2).forEach { state = state.withBuilding(it, BuildingType.SETTLEMENT, 0) }
|
||||||
|
state = state.copy(
|
||||||
|
devHands = listOf(listOf(DevCard(96, DevCardType.VICTORY_POINT, 0)), emptyList(), emptyList()),
|
||||||
|
devHandCounts = listOf(1, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nine points stand in public, the tenth hides in hand: ending the
|
||||||
|
// turn wins before the turn passes.
|
||||||
|
val won = CatanRules.applyMove(state, 0, EndTurn)
|
||||||
|
assertEquals(CatanPhase.GAME_OVER, won.phase)
|
||||||
|
assertEquals(0, won.winner)
|
||||||
|
assertTrue(won.log.any { it.contains("Seat 0 reveals 10 victory points and wins") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seat already at the target wins when its own turn arrives`() {
|
||||||
|
val homes = scattered(5)
|
||||||
|
// Seat 2 ends its turn, so seat 0 is next in the three-seat rotation.
|
||||||
|
var state = craft(turn = 2)
|
||||||
|
homes.forEach { state = state.withBuilding(it, BuildingType.CITY, 0) }
|
||||||
|
|
||||||
|
val won = CatanRules.applyMove(state, 2, EndTurn)
|
||||||
|
assertEquals(CatanPhase.GAME_OVER, won.phase)
|
||||||
|
assertEquals(0, won.winner)
|
||||||
|
assertTrue("seat 0 never had to roll", won.log.none { it.startsWith("Seat 0 rolls") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Redaction and wire format -------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seat view hides the deck and other hands but not the counts`() {
|
||||||
|
val state = craft().copy(
|
||||||
|
devDeck = listOf(
|
||||||
|
DevCard(1, DevCardType.KNIGHT),
|
||||||
|
DevCard(2, DevCardType.VICTORY_POINT),
|
||||||
|
),
|
||||||
|
devHands = listOf(
|
||||||
|
listOf(DevCard(3, DevCardType.KNIGHT, 1)),
|
||||||
|
listOf(DevCard(4, DevCardType.MONOPOLY, 1)),
|
||||||
|
listOf(DevCard(5, DevCardType.YEAR_OF_PLENTY, 1)),
|
||||||
|
),
|
||||||
|
devHandCounts = listOf(1, 1, 1),
|
||||||
|
)
|
||||||
|
val view = CatanRules.viewFor(state, seat = 1)
|
||||||
|
assertTrue("the deck order is hidden", view.devDeck.isEmpty())
|
||||||
|
assertEquals(state.devHands[1], view.devHands[1])
|
||||||
|
assertTrue(view.devHands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() })
|
||||||
|
assertEquals(state.devHandCounts, view.devHandCounts)
|
||||||
|
// Public information travels whole.
|
||||||
|
assertEquals(state.resources, view.resources)
|
||||||
|
assertEquals(state.buildings, view.buildings)
|
||||||
|
assertEquals(state.log, view.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `state and every move survive a wire round trip`() {
|
||||||
|
val homes = scattered(3)
|
||||||
|
val state = craft(
|
||||||
|
options = CatanOptions(playerCount = 4),
|
||||||
|
seed = 99L,
|
||||||
|
)
|
||||||
|
.withBuilding(homes[0], BuildingType.CITY, 2)
|
||||||
|
.withRoad(CatanBoard.VERTEX_EDGES[homes[1]][0], 3)
|
||||||
|
.copy(
|
||||||
|
resources = hands(
|
||||||
|
hand(Resource.LUMBER to 2),
|
||||||
|
hand(Resource.BRICK to 1),
|
||||||
|
hand(Resource.WOOL to 1, Resource.GRAIN to 1),
|
||||||
|
hand(Resource.ORE to 4),
|
||||||
|
players = 4,
|
||||||
|
),
|
||||||
|
pendingTrade = TradeOffer(
|
||||||
|
by = 0, to = 1,
|
||||||
|
give = listOf(Resource.LUMBER),
|
||||||
|
get = listOf(Resource.BRICK, Resource.WOOL),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||||
|
|
||||||
|
val moves = listOf<CatanMove>(
|
||||||
|
SetupPlace(3, 5),
|
||||||
|
RollDice(),
|
||||||
|
RollDice(listOf(3, 4)),
|
||||||
|
Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK)),
|
||||||
|
MoveRobber(HexCoord(1, -1), 2),
|
||||||
|
MoveRobber(HexCoord(-2, 0)),
|
||||||
|
BuildRoad(11),
|
||||||
|
BuildSettlement(13),
|
||||||
|
BuildCity(13),
|
||||||
|
BuyDevCard,
|
||||||
|
PlayKnight(21),
|
||||||
|
PlayRoadBuilding(22),
|
||||||
|
PlayMonopoly(23, Resource.ORE),
|
||||||
|
PlayYearOfPlenty(24, listOf(Resource.GRAIN, Resource.GRAIN)),
|
||||||
|
OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)),
|
||||||
|
AcceptTrade,
|
||||||
|
DeclineTrade,
|
||||||
|
MaritimeTrade(Resource.WOOL, Resource.GRAIN),
|
||||||
|
EndTurn,
|
||||||
|
EndFreeRoads,
|
||||||
|
)
|
||||||
|
moves.forEach { assertEquals(it, CatanRules.decodeMove(CatanRules.encodeMove(it))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `summary reports the standing points until somebody wins`() {
|
||||||
|
val state = craft()
|
||||||
|
assertTrue(CatanRules.summary(state).contains("S0 0"))
|
||||||
|
val homes = scattered(5)
|
||||||
|
// Seat 2 ends, seat 0 is next and wins on arrival.
|
||||||
|
var won = craft(turn = 2)
|
||||||
|
homes.forEach { won = won.withBuilding(it, BuildingType.CITY, 0) }
|
||||||
|
won = CatanRules.applyMove(won, 2, EndTurn)
|
||||||
|
assertEquals("Seat 0 wins", CatanRules.summary(won))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun assertThrows(block: () -> Unit) {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
throw AssertionError("expected the rules to reject that")
|
||||||
|
} catch (expected: IllegalArgumentException) {
|
||||||
|
// Rejected, which is the point.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,9 +164,6 @@ 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 = "ProLibertateGames-$tag.apk",
|
apkName = "PilgrimsOfKatan-$tag.apk",
|
||||||
channel = channel,
|
channel = channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +53,28 @@ 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
|
||||||
@@ -129,7 +151,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 api.github.com")),
|
launchOffer(UpdateVerdict.Refused("Unable to resolve host git.wallacenet.ca")),
|
||||||
)
|
)
|
||||||
// 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
@@ -0,0 +1,141 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# docs/
|
||||||
|
|
||||||
|
Conventions for this repo's documentation. Seeded at bootstrap (WALA-118).
|
||||||
|
|
||||||
|
## Per-game rules: `RULES-<game>.md`
|
||||||
|
|
||||||
|
Every game in `GameCatalog` gets one file describing how *this app* implements
|
||||||
|
it — not a generic rule dump. The rules code
|
||||||
|
(`app/src/main/java/org/prolibertate/games/game/<game>/`) is the source of
|
||||||
|
truth; the doc exists so a human or an agent can check intent against
|
||||||
|
behaviour without reading Kotlin first.
|
||||||
|
|
||||||
|
Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
|
||||||
|
|
||||||
|
| Game (catalogue id) | Rules doc |
|
||||||
|
| --- | --- |
|
||||||
|
| Backgammon | [RULES-backgammon.md](RULES-backgammon.md) |
|
||||||
|
| Checkers | [RULES-checkers.md](RULES-checkers.md) |
|
||||||
|
| Chess | [RULES-chess.md](RULES-chess.md) |
|
||||||
|
| Crazy 8s | [RULES-crazy8s.md](RULES-crazy8s.md) |
|
||||||
|
| Cribbage | [RULES-cribbage.md](RULES-cribbage.md) |
|
||||||
|
| Euchre | [RULES-euchre.md](RULES-euchre.md) |
|
||||||
|
| FreeCell | — (TODO) |
|
||||||
|
| Golf | [RULES-golf.md](RULES-golf.md) |
|
||||||
|
| Hearts | — (TODO) |
|
||||||
|
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
|
||||||
|
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) |
|
||||||
|
| Klondike | — (TODO) |
|
||||||
|
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
|
||||||
|
| Morris | [RULES-morris.md](RULES-morris.md) |
|
||||||
|
| Peg solitaire | — (TODO) |
|
||||||
|
| Pirates | [RULES-pirates.md](RULES-pirates.md) |
|
||||||
|
| President | [RULES-president.md](RULES-president.md) |
|
||||||
|
| Pyramid | — (TODO) |
|
||||||
|
| Sequence | [RULES-sequence.md](RULES-sequence.md) |
|
||||||
|
| Spider | — (TODO) |
|
||||||
|
| Ta Yü | [RULES-tayu.md](RULES-tayu.md) |
|
||||||
|
| Wizard | [RULES-wizard.md](RULES-wizard.md) |
|
||||||
|
| Yahtzee | — (TODO) |
|
||||||
|
|
||||||
|
## Other docs
|
||||||
|
|
||||||
|
- [CI.md](CI.md) — build, CI, releases, OTA decisions.
|
||||||
|
- `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`).
|
||||||
|
- Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root.
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
# Katan — implemented rules
|
||||||
|
|
||||||
|
This is the ruleset the engine enforces. Anything marked **toggle** is exposed
|
||||||
|
in the game setup screen.
|
||||||
|
|
||||||
|
Katan is this app's name for the island game the collection is built around.
|
||||||
|
The catalogue id is `catan` (so this file is `RULES-catan.md` and the code lives
|
||||||
|
in `game/catan/`), but the printed name is trademarked, so the game is listed
|
||||||
|
and titled **Katan**. The rules themselves are the base game for three to four
|
||||||
|
players, as published.
|
||||||
|
|
||||||
|
The engine is a pure state machine (`CatanRules`) with no Android types, and its
|
||||||
|
behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`.
|
||||||
|
|
||||||
|
## Table
|
||||||
|
|
||||||
|
- **Players — *toggle*.** Three or four. `CatanOptions.playerCount`; the engine
|
||||||
|
refuses anything else.
|
||||||
|
- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at
|
||||||
|
least 2. A lower target makes a test game short.
|
||||||
|
|
||||||
|
The setup screen that exposes these lands with the screen task; the engine reads
|
||||||
|
them from the table configuration today.
|
||||||
|
|
||||||
|
## Board
|
||||||
|
|
||||||
|
The island is the standard layout, derived once from axial hex coordinates in
|
||||||
|
`CatanBoard` and checked at class-init:
|
||||||
|
|
||||||
|
- **19 hexes**, the hexes within distance 2 of the centre.
|
||||||
|
- **54 vertices** and **72 edges**, shared between neighbouring hexes by exact
|
||||||
|
integer position, not floating point. `HEX_CORNERS`, `EDGE_VERTICES`,
|
||||||
|
`VERTEX_EDGES`, `VERTEX_HEXES` and `EDGE_HEXES` are the shared graph.
|
||||||
|
- **30 coastal edges**, one closed ring.
|
||||||
|
- **9 harbours**, on fixed positions around the coast.
|
||||||
|
|
||||||
|
Terrain is dealt from the physical mix and shuffled: **4 forest, 4 pasture,
|
||||||
|
4 fields, 3 hills, 3 mountains, 1 desert**.
|
||||||
|
|
||||||
|
Number tokens are the 18 the game ships, shuffled over the land hexes: four red
|
||||||
|
tokens (**6, 6, 8, 8**) and fourteen others
|
||||||
|
(**2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12**). Seven never appears. The
|
||||||
|
desert bears no token and the robber starts on it.
|
||||||
|
|
||||||
|
### Keeping 6s and 8s apart
|
||||||
|
|
||||||
|
The published game asks that the two red numbers not sit on adjacent hexes, and
|
||||||
|
`generate` guarantees it rather than hoping for it:
|
||||||
|
|
||||||
|
1. Shuffle the land and take four hexes; accept the draw if none of the four
|
||||||
|
touch each other. Retry up to 60 times.
|
||||||
|
2. If no draw separates them, fall back to four of the six board-corner hexes,
|
||||||
|
which are pairwise non-adjacent by construction and at most one of which can
|
||||||
|
be the desert.
|
||||||
|
|
||||||
|
`no seed puts two red tokens on adjacent hexes` runs the whole thing over seeds
|
||||||
|
1..500.
|
||||||
|
|
||||||
|
Harbours: the nine **positions** are fixed (the physical frame is not shuffled),
|
||||||
|
but the nine **types** are shuffled onto them — **five 2:1 resource harbours**
|
||||||
|
(one per resource) and **four generic 3:1 harbours**. The frame positions are
|
||||||
|
chosen so no two harbours sit closer than three coastal edges apart.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Placement runs in snake order: seats `0, 1, …, n-1` then `n-1, …, 1, 0`, each
|
||||||
|
placing one settlement and one road. During setup the distance rule still holds
|
||||||
|
(a settlement may not sit one edge from any other building) but the road need
|
||||||
|
not connect to anything already placed.
|
||||||
|
|
||||||
|
A player's **second** settlement earns one resource card for each adjacent
|
||||||
|
producing hex, supply permitting — duplicates counted, so a second settlement on
|
||||||
|
two forests earns two lumber. The first pass earns nothing.
|
||||||
|
|
||||||
|
After the last placement, seat 0 leads and the first real turn begins.
|
||||||
|
|
||||||
|
## Turn
|
||||||
|
|
||||||
|
A turn runs through the phases in `CatanPhase`:
|
||||||
|
|
||||||
|
1. **AWAIT_ROLL** — the player on the clock rolls.
|
||||||
|
2. A seven goes to **DISCARDING** and then **ROBBER**; anything else produces and
|
||||||
|
goes straight to **PLAYING**.
|
||||||
|
3. **PLAYING** — build, trade, buy and play development cards, then end the turn.
|
||||||
|
4. **FREE_ROADS** is a detour inside PLAYING while a Road Building card is being
|
||||||
|
spent.
|
||||||
|
|
||||||
|
### Rolling
|
||||||
|
|
||||||
|
The dice are derived from the game seed and a monotonic counter (`rngStep`), not
|
||||||
|
from a random generator stored in the state: the same seed and the same moves
|
||||||
|
replay identically. `RollDice(forced = …)` exists so tests can stage a number;
|
||||||
|
it is deliberately **not** a member of the enumerated move list, so a client
|
||||||
|
cannot send one over the network — only code holding the state directly can use
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
For a non-seven total, each hex whose number matches pays its producing terrain:
|
||||||
|
a settlement takes **1**, a city takes **2**. The robber's hex pays nothing.
|
||||||
|
|
||||||
|
**When the bank cannot meet the demand for a resource, nobody receives that
|
||||||
|
resource this roll.** This is the official ruling, implemented per-resource:
|
||||||
|
demand is totalled first and, if it exceeds the bank's supply, every gain of
|
||||||
|
that resource is dropped.
|
||||||
|
|
||||||
|
## The robber
|
||||||
|
|
||||||
|
A **seven** sends the robber: first every player holding **more than seven**
|
||||||
|
resource cards discards half their hand (rounded down, so nine cards means four
|
||||||
|
gone), in seat order starting from the lowest overfull seat; then the roller
|
||||||
|
moves the robber and may steal.
|
||||||
|
|
||||||
|
The robber **must move to a different hex**. Moving onto a hex lets the roller
|
||||||
|
steal one card from one opponent who has a building on that hex and at least one
|
||||||
|
card in hand. The card stolen is chosen at random, derived like the dice. A
|
||||||
|
hex with no opponent holding cards offers only the move, no steal.
|
||||||
|
|
||||||
|
A played Knight enters the same ROBBER phase, so the robber move after a Knight
|
||||||
|
is the same code path.
|
||||||
|
|
||||||
|
## Trading
|
||||||
|
|
||||||
|
### Domestic
|
||||||
|
|
||||||
|
An offer names **one** addressee and two card multisets: what the offerer gives,
|
||||||
|
what they want. There are no counter-offers and no open auctions in v1.
|
||||||
|
|
||||||
|
An offer is legal only when the offerer's turn is in PLAYING, the addressee is
|
||||||
|
another seat, both legs are non-empty, the two legs are not the same multiset
|
||||||
|
(`countOf(give) != countOf(get)` — like-for-like is not a trade), and the
|
||||||
|
offerer actually holds the cards being offered.
|
||||||
|
|
||||||
|
The addressee may **accept** only if they hold what was asked and the offerer
|
||||||
|
still holds what was promised; otherwise only **decline** is offered. A decline
|
||||||
|
clears the table and leaves every card where it was.
|
||||||
|
|
||||||
|
Offers are free-form intents rather than enumerated moves (the space of card
|
||||||
|
multisets is combinatorial), so they are validated structurally by
|
||||||
|
`CatanRules.isLegalMove` and checked by the network driver's gate like any other
|
||||||
|
move.
|
||||||
|
|
||||||
|
### Maritime
|
||||||
|
|
||||||
|
Bank trade gives `ratio` of one resource for one of another, where the ratio is
|
||||||
|
the best the seat can get: **4:1** through the bank, **3:1** through a generic
|
||||||
|
harbour, **2:1** through a harbour matching the resource being given. A harbour
|
||||||
|
counts when the seat owns a settlement or city on **either** endpoint vertex of
|
||||||
|
its edge.
|
||||||
|
|
||||||
|
## Building and development cards
|
||||||
|
|
||||||
|
| Build | Cost |
|
||||||
|
| --- | --- |
|
||||||
|
| Road | 1 lumber + 1 brick |
|
||||||
|
| Settlement | 1 lumber + 1 brick + 1 wool + 1 grain |
|
||||||
|
| City | 2 grain + 3 ore |
|
||||||
|
| Development card | 1 wool + 1 grain + 1 ore |
|
||||||
|
|
||||||
|
A settlement goes on an **empty vertex** at least two edges from every standing
|
||||||
|
building and connected to the seat's own road network. A city upgrades one of
|
||||||
|
the seat's own settlements. A road goes on an empty edge that connects to the
|
||||||
|
seat's network (a road or a building at one of its ends).
|
||||||
|
|
||||||
|
The development deck is the published 25 cards: **14 Knights, 2 Road Building,
|
||||||
|
2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
|
||||||
|
top of the deck.
|
||||||
|
|
||||||
|
Timing rules the engine enforces:
|
||||||
|
|
||||||
|
- **One development card per turn.** `devCardPlayedThisTurn` gates every play.
|
||||||
|
- **A card cannot be played the turn it was bought.** Each card records
|
||||||
|
`boughtTurn` and is skipped while that equals the current turn.
|
||||||
|
- **Victory Point cards are never played.** They are counted straight from the
|
||||||
|
hand by `victoryPoints`, and are only revealed by winning.
|
||||||
|
- **Knight** → the robber phase, and counts toward Largest Army.
|
||||||
|
- **Road Building** → two free road placements (FREE_ROADS); the player may
|
||||||
|
forfeit any that remain.
|
||||||
|
- **Monopoly** → seizes every card of the named resource from the other seats.
|
||||||
|
- **Year of Plenty** → takes up to two cards from the bank; taking two of a kind
|
||||||
|
requires the bank to hold both.
|
||||||
|
|
||||||
|
## Scoring and winning
|
||||||
|
|
||||||
|
Victory points are:
|
||||||
|
|
||||||
|
- **Settlement 1, city 2.**
|
||||||
|
- **Longest Road 5** — the longest single road of **five or more** segments.
|
||||||
|
- **Largest Army 5** — first to **three** played knights.
|
||||||
|
- **Victory Point cards**, one each.
|
||||||
|
|
||||||
|
A seat wins on reaching **points to win** (default 10). The win is checked after
|
||||||
|
every build, when a turn ends, and when a seat's turn arrives — the last covers
|
||||||
|
points that changed on someone else's turn, such as a broken Longest Road being
|
||||||
|
re-awarded. A seat already at the target therefore wins at the start of its own
|
||||||
|
turn rather than having to act.
|
||||||
|
|
||||||
|
### Longest Road
|
||||||
|
|
||||||
|
The award goes to the longest **simple path** over a seat's own roads. The path
|
||||||
|
never passes *through* an opponent's building (it may run up to one) and never
|
||||||
|
revisits a vertex — so a figure of two loops sharing an intersection counts as
|
||||||
|
one road of five, not two roads, because the intersection is a branch.
|
||||||
|
|
||||||
|
Once held, the award is kept until another seat builds a **strictly longer**
|
||||||
|
road; an equal length leaves it where it is. If the holder's road falls below
|
||||||
|
five segments the award reopens to the longest road of five or more, lowest seat
|
||||||
|
first on a tie. A settlement placed across an opponent's road breaks it, and the
|
||||||
|
award is recomputed for everyone after any settlement.
|
||||||
|
|
||||||
|
### Largest Army
|
||||||
|
|
||||||
|
First to three knights takes it. It is stolen only by a **strictly larger** army:
|
||||||
|
a tie leaves the incumbent ahead, exactly as Longest Road does.
|
||||||
|
|
||||||
|
## The computer opponent
|
||||||
|
|
||||||
|
`CatanAi` plays a legal, mildly strategic game so a solo player can host against
|
||||||
|
computers, with the lobby filling empty seats.
|
||||||
|
|
||||||
|
It reads **only public information** — terrain, numbers, buildings, roads,
|
||||||
|
resource counts, development-card counts and knights played — plus its own hand.
|
||||||
|
It never looks into the deck, another seat's development cards, or the seed
|
||||||
|
beyond the roll the rules derive from it. That is a design constraint, not an
|
||||||
|
accident: it runs on the host, where the authoritative state is fully visible.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
- **Setup.** Rates a settlement by the production its three hexes give (weighted
|
||||||
|
by the token odds and by how much it wants each resource), plus a bonus for
|
||||||
|
resource variety and for sitting on a harbour, plus a small term for where the
|
||||||
|
accompanying road points.
|
||||||
|
- **Main phase**, in priority order: raise a city, build a settlement, play a
|
||||||
|
knight, play Year of Plenty or Monopoly when it closes a gap, play Road
|
||||||
|
Building when two segments are useful, buy a development card, build a road
|
||||||
|
that reaches a spot worth settling, trade at the bank only when the trade
|
||||||
|
completes a build this turn, make one domestic offer that closes a build, else
|
||||||
|
end the turn.
|
||||||
|
- **The robber.** Moves onto the hex where it blocks the most opposing
|
||||||
|
production (weighted by token odds), preferring to rob whoever is holding the
|
||||||
|
most cards and the most points.
|
||||||
|
- **Trades.** Accepts an offer when what arrives is worth more than what leaves
|
||||||
|
by its own resource valuations; a mild table, not a shark.
|
||||||
|
- **Discards.** Throws away what it values least (wool, then brick, then lumber,
|
||||||
|
before grain and ore).
|
||||||
|
|
||||||
|
It makes **at most one domestic offer a turn**. A declined offer changes nothing,
|
||||||
|
so re-offering the same thing would only bounce between two seats forever; the
|
||||||
|
engine allows more offers, the AI chooses not to.
|
||||||
|
|
||||||
|
`CatanAiTest` plays three- and four-seat tables of engines out to a result from
|
||||||
|
several seeds, refusing any illegal move the AI chooses, and checks the winner
|
||||||
|
reached the target.
|
||||||
|
|
||||||
|
## Provenance and rule choices
|
||||||
|
|
||||||
|
The rules are the published base game. Where the published rules leave room, the
|
||||||
|
engine picks one reading and records it here.
|
||||||
|
|
||||||
|
**Followed from the published rules:** the board and its component mix; the
|
||||||
|
number tokens; 6s and 8s not adjacent; snake-order setup and second-settlement
|
||||||
|
income; production weighting; the robber on a seven; discard half over seven;
|
||||||
|
domestic and maritime trade; build costs; the 25-card development deck and its
|
||||||
|
timing rules; Longest Road and Largest Army worth five; victory at ten.
|
||||||
|
|
||||||
|
**Choices made here, and why:**
|
||||||
|
|
||||||
|
- **A bank short of a resource pays nobody that roll.** This is the official
|
||||||
|
ruling, and it is implemented per-resource rather than per-player.
|
||||||
|
- **Longest Road and Largest Army are kept on a tie**, and stolen only by a
|
||||||
|
strictly longer road or a strictly larger army. The alternative reading lets a
|
||||||
|
tie steal the award; "strictly better" is the common tournament reading and
|
||||||
|
keeps the award from flickering.
|
||||||
|
- **A ring road counts as a simple path.** No vertex is revisited, so six
|
||||||
|
segments around one hex are worth five, not six.
|
||||||
|
- **The win is checked when a seat's turn arrives**, not only on that seat's own
|
||||||
|
action, so points that move on another player's turn still win.
|
||||||
|
- **A bought Victory Point card does not win immediately.** It is counted when
|
||||||
|
the win is next checked (a build or the end of the turn), which is when the
|
||||||
|
holder would reveal it.
|
||||||
|
- **Year of Plenty two-of-a-kind needs the bank to hold both cards**, not one.
|
||||||
|
- **The robber must move.** Some printings allow staying put; this engine always
|
||||||
|
requires a different hex.
|
||||||
|
- **Offers are one-to-one, no auctions or counter-offers** in v1.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **No domestic auction or counter-offer.** An offer is addressed to a single
|
||||||
|
seat. Open trading to the whole table, and counter-offers, are the obvious next
|
||||||
|
step and are not implemented.
|
||||||
|
- **No player-to-player trade over the wire beyond accept/decline.** The engine
|
||||||
|
supports it; whether the UI surfaces it is the screen task's concern.
|
||||||
|
- **No trading away a development card.** Not part of the base game.
|
||||||
|
- **The setup screen is not part of this task.** `CatanOptions` (players, target
|
||||||
|
points) is read from the table configuration; the screen that sets it, and the
|
||||||
|
catalogue descriptor's `available` flag, land with the screen and verification
|
||||||
|
tasks.
|
||||||
|
- **The AI does not look ahead.** It scores one move deep with a fixed resource
|
||||||
|
valuation. It plays legally and plausibly; it is not a strong opponent, and no
|
||||||
|
strength claim is made.
|
||||||
|
- **The board is the standard published layout, generated.** Unlike the Sequence
|
||||||
|
board there is no unverified substitute here — the geometry is the real one —
|
||||||
|
but the *island* (terrain and token placement) is freshly generated each game,
|
||||||
|
so it will not match any particular physical set-up. That is the intent.
|
||||||
+6
-6
@@ -24,10 +24,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, versionName
|
# The permanent floor under every stamped build number: versionCode (and the
|
||||||
# and the release tag are all `run number + this`. Run numbers restart at 1 on a
|
# release tag, and versionName) are `run number + this`. Gitea Actions starts
|
||||||
# new CI host (Gitea Actions), and Android refuses any versionCode lower than the
|
# run numbers at 1 for each workflow, so without it the first Gitea-era release
|
||||||
# one installed, so without a floor the first build after a move would look like
|
# would be build 1 — the same number an unstamped local build already carries —
|
||||||
# a downgrade. 1000 sits above every number GitHub reached (prod 30, dev ~172).
|
# and any later reset would make Android reject the next build as a downgrade.
|
||||||
# Set once and never changed.
|
# Set once, at bootstrap, and never changed again: see docs/CI.md.
|
||||||
plgVersionRunOffset=1000
|
plgVersionRunOffset=1000
|
||||||
|
|||||||
Executable
+126
@@ -0,0 +1,126 @@
|
|||||||
|
#!/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
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "ProLibertateGames"
|
rootProject.name = "PilgrimsOfKatan"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/*/latest.json
|
|
||||||
Cache-Control: no-cache
|
|
||||||
Access-Control-Allow-Origin: *
|
|
||||||
|
|
||||||
/*.apk
|
|
||||||
Content-Type: application/vnd.android.package-archive
|
|
||||||
Cache-Control: public, max-age=300
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<!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>
|
|
||||||
Reference in New Issue
Block a user