Compare commits
40
Commits
3a930f5423
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
518f80b998 | ||
|
|
59a6ffc940 | ||
|
|
98506c05b7 | ||
|
|
f9b793d56e | ||
|
|
c8368416c4 | ||
|
|
e344d1d817 | ||
|
|
d88083679d | ||
|
|
5fdb70cae0 | ||
|
|
2d85df0de0 | ||
|
|
d7d3c8a3ed | ||
|
|
12fec14034 | ||
|
|
5e84119670 | ||
|
|
b90058afda | ||
|
|
ef69704e00 | ||
|
|
3b0b307997 | ||
|
|
87b00ad84f | ||
|
|
30dcf026bd | ||
|
|
a778e4732f | ||
|
|
fb59372802 | ||
|
|
653e7a17a3 | ||
|
|
33a253d80c | ||
|
|
4ffe817d5f | ||
|
|
fd352b4d14 | ||
|
|
7b5b041c42 | ||
|
|
e63c2cf7ac | ||
|
|
2314f5f842 | ||
|
|
2a193f8d5b | ||
|
|
ad30ab9dea | ||
|
|
4c603f6d3d | ||
|
|
0927660c15 | ||
|
|
5d0f95361f | ||
|
|
6fbe63f787 | ||
|
|
12d20565bd | ||
|
|
2df140a3c1 | ||
|
|
0b74b1b2b1 | ||
|
|
4369d15cec | ||
|
|
1e9908569d | ||
|
|
c0fc4fe7d6 | ||
|
|
4d7cd6182f | ||
|
|
b58b4251dc |
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assembles the Cloudflare Pages site for plg.wallacenet.ca into ./pages-out.
|
||||
#
|
||||
# Usage: stage-pages-site.sh <prod|dev> <tag> <apk-path>
|
||||
#
|
||||
# A Pages deploy replaces the whole site, and both channels live in one site,
|
||||
# so this stages the channel being published and then re-fetches the *other*
|
||||
# channel's current files from the live site so the deploy does not erase them.
|
||||
set -euo pipefail
|
||||
|
||||
channel="$1"; tag="$2"; apk="$3"
|
||||
base="${PAGES_BASE_URL:-https://plg.wallacenet.ca}"
|
||||
out="pages-out"
|
||||
limit=$((25 * 1024 * 1024)) # Cloudflare Pages: 25 MiB per file
|
||||
|
||||
case "$channel" in prod) other=dev ;; dev) other=prod ;; *) echo "bad channel: $channel" >&2; exit 2 ;; esac
|
||||
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out/$channel" "$out/$other"
|
||||
cp site/index.html site/404.html site/_headers "$out/"
|
||||
|
||||
name="$(basename "$apk")"
|
||||
cp "$apk" "$out/$channel/$name"
|
||||
cp "$apk" "$out/$channel/ProLibertateGames.apk"
|
||||
# node rather than jq: the Gitea runner's image has node but not jq.
|
||||
node -e 'console.log(JSON.stringify({tag: process.argv[1], apkName: process.argv[2], apkUrl: process.argv[3], channel: process.argv[4]}, null, 2))' \
|
||||
"$tag" "$name" "$base/$channel/$name" "$channel" > "$out/$channel/latest.json"
|
||||
|
||||
# PLG_SKIP_OTHER=1 is for the very first deploy, when the site does not exist
|
||||
# yet and the domain answers 403 (Cloudflare error 1014) instead of 404.
|
||||
if [ -n "${PLG_SKIP_OTHER:-}" ]; then
|
||||
echo "PLG_SKIP_OTHER set; not carrying over $other."
|
||||
rm -rf "$out/$other"
|
||||
find "$out" -type f | sort
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Carry the other channel over. 404 means it was never published; any other
|
||||
# failure must stop the deploy rather than silently dropping that channel.
|
||||
code="$(curl -sS -o "$out/$other/latest.json" -w '%{http_code}' "$base/$other/latest.json" || true)"
|
||||
: "${code:=000}"
|
||||
# Cloudflare error 1014 (403 + "error code: 1014") means the domain's CNAME
|
||||
# points at a Pages project that does not exist yet, so there is nothing to
|
||||
# preserve — the same as a 404, and what a push-triggered first deploy sees.
|
||||
if [ "$code" = 403 ] && grep -q 'error code: 1014' "$out/$other/latest.json" 2>/dev/null; then
|
||||
code=404
|
||||
fi
|
||||
if [ "$code" = 404 ]; then
|
||||
echo "No $other channel published yet; skipping."
|
||||
rm -rf "$out/$other"
|
||||
elif [ "$code" = 200 ] && ! other_name="$(node -p 'const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if (!j.apkName) process.exit(1); j.apkName' "$out/$other/latest.json" 2>/dev/null)"; then
|
||||
# A 200 that is not our JSON: Pages without a 404.html answers unknown paths
|
||||
# with index.html. Nothing is published on that channel.
|
||||
echo "No $other channel published yet (200 but no latest.json); skipping."
|
||||
rm -rf "$out/$other"
|
||||
elif [ "$code" = 200 ]; then
|
||||
curl -fsS -o "$out/$other/$other_name" "$base/$other/$other_name"
|
||||
cp "$out/$other/$other_name" "$out/$other/ProLibertateGames.apk"
|
||||
else
|
||||
echo "Fetching $other/latest.json returned HTTP $code; refusing to deploy." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
find "$out" -name '*.apk' -size +"${limit}c" -print | grep . >&2 && {
|
||||
echo "APK exceeds Cloudflare Pages' 25 MiB per-file limit." >&2; exit 1; } || true
|
||||
find "$out" -type f | sort
|
||||
+91
-37
@@ -1,27 +1,28 @@
|
||||
name: CI
|
||||
|
||||
# Pre-merge checks. Runs on the dev branch and on every pull request aimed at
|
||||
# dev or main, so breakage is caught before it reaches main.
|
||||
# dev or main, so breakage is caught before it reaches main and triggers a
|
||||
# release.
|
||||
#
|
||||
# On a push to dev it also publishes the build as a dev-channel prerelease on
|
||||
# this Gitea instance, so the in-app updater can offer it to anyone on the dev
|
||||
# channel. Prereleases are used deliberately: the "latest release" endpoint
|
||||
# skips them, so a production-channel user never sees a dev build. Pull
|
||||
# requests only build and check — they never publish, which keeps a PR from a
|
||||
# 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 a push to dev it also publishes the build as a dev-channel prerelease, so
|
||||
# the in-app updater can offer it to anyone who has switched to that channel.
|
||||
# Prereleases are used deliberately: GitHub's "latest release" endpoint skips
|
||||
# them, so a production-channel user never sees a dev build. Pull requests only
|
||||
# build and check — they never publish, which keeps a PR from a fork from
|
||||
# shipping anything.
|
||||
on:
|
||||
push:
|
||||
branches: [ dev ]
|
||||
pull_request:
|
||||
branches: [ main, dev ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
first_deploy:
|
||||
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Needed by the job token to create the release and attach the APK.
|
||||
# Needed to publish the dev-channel prerelease below.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -34,6 +35,8 @@ jobs:
|
||||
build:
|
||||
name: Build and check
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.stage.outputs.version }}
|
||||
env:
|
||||
# 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
|
||||
@@ -41,7 +44,10 @@ jobs:
|
||||
# <series>.0-dev: they are never published and must not claim a channel
|
||||
# version.
|
||||
#
|
||||
# This condition has to stay in step with the publish steps' own.
|
||||
# This condition has to stay in step with the publish steps' own. Stamping
|
||||
# a build the publish steps would ship, or shipping one this left
|
||||
# unstamped, both end the same way — an APK claiming versionCode 1, which
|
||||
# the updater can never see as an upgrade over anything.
|
||||
PLG_CHANNEL: ${{ github.ref == 'refs/heads/dev' && 'dev' || '' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -69,17 +75,30 @@ jobs:
|
||||
- name: Upload debug APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: PilgrimsOfKatan-debug-apk
|
||||
name: ProLibertateGames-debug-apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload JVM crash logs
|
||||
# A JVM crash writes hs_err_pid*.log into the workspace, which dies
|
||||
# with the job container; keep it so the failing frame can be read.
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ProLibertateGames-jvm-crash
|
||||
path: |
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload lint and test reports
|
||||
# Reports are most useful precisely when a step above failed.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: PilgrimsOfKatan-reports
|
||||
name: ProLibertateGames-reports
|
||||
path: |
|
||||
app/build/reports/
|
||||
if-no-files-found: ignore
|
||||
@@ -89,39 +108,74 @@ jobs:
|
||||
# branch itself, after it has built, tested and linted clean above.
|
||||
#
|
||||
# Gated on the branch alone rather than on the event, which is how
|
||||
# release.yml gates main. A pull request is still excluded without
|
||||
# needing to name it: a PR build runs against refs/pull/N/merge, never
|
||||
# refs/heads/dev, so a fork still cannot ship anything.
|
||||
# release.yml has always gated main. A pull request is still excluded
|
||||
# without needing to name it: a PR build runs against refs/pull/N/merge,
|
||||
# never refs/heads/dev, so a fork still cannot ship anything.
|
||||
- name: Stage dev APK
|
||||
id: stage
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The series and the offset come from gradle.properties, which is
|
||||
# the same file the build read when it stamped the APK — so the tag
|
||||
# and what is inside it cannot name different versions. The number
|
||||
# is run number + offset, exactly the arithmetic build.gradle.kts
|
||||
# did for versionCode.
|
||||
# The series comes from gradle.properties, which is the same file the
|
||||
# build read when it stamped the APK — so the tag and what is inside
|
||||
# it cannot name different versions.
|
||||
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
|
||||
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
||||
test -n "$series"
|
||||
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
||||
test -n "$offset"
|
||||
stamp="$(( ${{ github.run_number }} + offset ))"
|
||||
version="v${series}.${stamp}-dev"
|
||||
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
|
||||
version="v${series}.$(( ${{ github.run_number }} + offset ))-dev"
|
||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||
dest="PilgrimsOfKatan-${version}.apk"
|
||||
dest="ProLibertateGames-${version}.apk"
|
||||
cp "$src" "$dest"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "apk=$dest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish dev channel prerelease
|
||||
- name: Upload staged dev APK for the Pages deploy
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
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:
|
||||
# The job's built-in token: contents: write is declared above.
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.stage.outputs.version }}
|
||||
RELEASE_APK: ${{ steps.stage.outputs.apk }}
|
||||
RELEASE_KIND: prerelease
|
||||
RELEASE_TARGET: ${{ github.sha }}
|
||||
# One script, also runnable by hand — see docs/CI.md.
|
||||
run: ./scripts/publish-release.sh
|
||||
PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
|
||||
run: .github/scripts/stage-pages-site.sh dev "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Creates the project on first run; fails harmlessly if it exists.
|
||||
npx --yes wrangler@3 pages project create plg --production-branch main || true
|
||||
# --branch main makes this the production deployment, which is what
|
||||
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
|
||||
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
name: Build and Release APK
|
||||
|
||||
# Production-channel build. Runs on a push to main, or by hand
|
||||
# (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.
|
||||
# Build an APK and publish it as a GitHub Release whenever commits land on main
|
||||
# (for example when a pull request is merged). Can also be run manually.
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
first_deploy:
|
||||
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Needed by the job token to create the release and attach the APK.
|
||||
# Allow the built-in token to create releases and upload the APK.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -23,13 +24,16 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
name: Build APK and publish release
|
||||
# workflow_dispatch can be aimed at any branch from the Actions UI. The
|
||||
# production stamp belongs to main alone — anything else is a no-op rather
|
||||
# than shipping unreviewed code to everybody.
|
||||
# workflow_dispatch can be aimed at any branch from the Actions UI. Releases
|
||||
# are what the in-app updater installs, so only main may produce one —
|
||||
# anything else is a no-op rather than shipping unreviewed code to everyone.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.stage.outputs.version }}
|
||||
env:
|
||||
# Stamps the APK as a production-channel build.
|
||||
# Stamps the APK as a production-channel build, versioned with the run
|
||||
# number that matches the tag published below.
|
||||
PLG_CHANNEL: production
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -45,44 +49,76 @@ jobs:
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
|
||||
- name: Build production APK
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
||||
|
||||
- name: Stage APK
|
||||
- name: Stage APK for release
|
||||
id: stage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The series and the offset come from gradle.properties, which is
|
||||
# the same file the build read when it stamped the APK — so the tag
|
||||
# and what is inside it cannot name different versions. The number
|
||||
# is run number + offset, exactly the arithmetic build.gradle.kts
|
||||
# did for versionCode.
|
||||
# The series comes from gradle.properties, which is the same file the
|
||||
# build read when it stamped the APK — so the tag and what is inside
|
||||
# it cannot name different versions.
|
||||
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
|
||||
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
||||
test -n "$series"
|
||||
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
|
||||
test -n "$offset"
|
||||
stamp="$(( ${{ github.run_number }} + offset ))"
|
||||
version="v${series}.${stamp}"
|
||||
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
|
||||
version="v${series}.$(( ${{ github.run_number }} + offset ))"
|
||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||
dest="PilgrimsOfKatan-${version}.apk"
|
||||
dest="ProLibertateGames-${version}.apk"
|
||||
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 "apk=$dest" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: PilgrimsOfKatan-apk
|
||||
name: plg-prod-staged
|
||||
path: ${{ steps.stage.outputs.apk }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish production release
|
||||
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
|
||||
# the only release channel: the in-app updater reads latest.json from there.
|
||||
#
|
||||
# Both workflows deploy into one Pages site, and a deploy replaces the whole
|
||||
# site, so they share a concurrency group and never run at the same time.
|
||||
deploy-pages:
|
||||
name: Deploy to Cloudflare Pages
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: plg-pages-deploy
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download staged APK
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: plg-prod-staged
|
||||
path: staged
|
||||
|
||||
- name: Assemble site
|
||||
env:
|
||||
# The job's built-in token: contents: write is declared above.
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.stage.outputs.version }}
|
||||
RELEASE_APK: ${{ steps.stage.outputs.apk }}
|
||||
RELEASE_KIND: release
|
||||
RELEASE_TARGET: ${{ github.sha }}
|
||||
# One script, also runnable by hand — see docs/CI.md.
|
||||
run: ./scripts/publish-release.sh
|
||||
PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
|
||||
run: .github/scripts/stage-pages-site.sh prod "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Creates the project on first run; fails harmlessly if it exists.
|
||||
npx --yes wrangler@3 pages project create plg --production-branch main || true
|
||||
# --branch main makes this the production deployment, which is what
|
||||
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
|
||||
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
|
||||
|
||||
@@ -1,95 +1,103 @@
|
||||
# Working on this repo (agent notes)
|
||||
# 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>.
|
||||
CI runs on **Gitea Actions** at `git.wallacenet.ca/devadmin/ProLibertateGames`
|
||||
(workflows in `.github/workflows/`), publishing to Cloudflare Pages
|
||||
(`plg.wallacenet.ca`). The workflow below was measured on the dev machine
|
||||
(`~/ProLibertateGames`); it replaces the earlier GitHub-sandbox notes, which no
|
||||
longer apply.
|
||||
|
||||
## Ground rules
|
||||
## Build, test and lint locally
|
||||
|
||||
- **Licences travel.** `LICENSE` is GPL-3.0 and must stay. The Uncial Antiqua
|
||||
font ships with its OFL licence at `docs/licenses/UncialAntiqua-OFL.txt`;
|
||||
never delete or edit either. Attribution to the upstream lives in the README.
|
||||
- **No Catan trademarks or brand assets** — the one hard line. The app's own
|
||||
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.
|
||||
Gradle works on the dev machine. Two environment variables are required: the
|
||||
default JDK is 25, which Gradle 8.14 cannot use, and the Android SDK lives in the
|
||||
home directory.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=... # JDK 17
|
||||
export ANDROID_HOME=... # SDK with platform 34, build-tools 34
|
||||
./gradlew assembleDebug # dev-channel APK (default)
|
||||
PLG_CHANNEL=production ./gradlew assembleDebug # production APK
|
||||
./gradlew testDebugUnitTest # JVM unit tests (rules/AI/net/score)
|
||||
./gradlew lintDebug # Android lint (CI runs it)
|
||||
```
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
export ANDROID_HOME=$HOME/android-sdk # platform 34, build-tools 34.0.0
|
||||
./gradlew testDebugUnitTest lintDebug assembleDebug
|
||||
```
|
||||
|
||||
On the Wallacenet project workspace a ready toolchain sits at
|
||||
`../.toolchain/` (`jdk17/`, `android-sdk/`) — export `JAVA_HOME` and
|
||||
`ANDROID_HOME` to it rather than installing again. Both variants must build:
|
||||
they install side by side (different applicationId, launcher name, colour).
|
||||
The Katan tests run in seconds; a clean build takes a few minutes. Run the full
|
||||
suite, lint and `assembleDebug` before pushing: they are what CI runs. There is
|
||||
no KVM, so no emulator; the engine and the UI logic are covered by unit tests
|
||||
(see `docs/UI-catan.md`).
|
||||
|
||||
## Channels and OTA
|
||||
## Shipping: branch, PR, CI, merge
|
||||
|
||||
One flag — `isDevBuild` in `app/build.gradle.kts` — drives applicationId,
|
||||
launcher name and brand colour together. Never set them individually.
|
||||
1. Branch from `dev`; before pushing, merge `origin/dev` in (it moves between
|
||||
sessions).
|
||||
2. Plain `git push` has no credentials. Use the Gitea token as a one-off header:
|
||||
`git -c "http.extraheader=Authorization: token $(cat ~/.config/gitea/token)" push -u origin <branch>`.
|
||||
3. Open the PR with `POST /api/v1/repos/devadmin/ProLibertateGames/pulls`
|
||||
(same token, `Authorization: token ...`). CI (`ci.yml`) runs on the PR: build,
|
||||
unit tests and Android lint; it publishes nothing. Poll
|
||||
`/actions/runs` until it completes (about 7 minutes).
|
||||
4. Merge with `POST .../pulls/N/merge {"Do":"merge"}`. The push to `dev` runs
|
||||
`ci.yml` again and publishes the build (about 9 minutes).
|
||||
|
||||
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.
|
||||
## Merging is a shipping action
|
||||
|
||||
## CI
|
||||
Both long-lived branches publish, so a merge is a release, not just an
|
||||
integration:
|
||||
|
||||
`.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.
|
||||
- **`dev`** publishes a `v<series>.N-dev` build to
|
||||
`https://plg.wallacenet.ca/dev/latest.json`, offered by the in-app updater to
|
||||
anyone on the Dev channel.
|
||||
- **`main`** runs `release.yml` and publishes a **production** release for
|
||||
everyone on the Production channel. Confirm before merging to `main`.
|
||||
`release.yml` runs `assembleDebug` only (no tests, no lint), so a `main`
|
||||
release is only as verified as the `dev` build it replays.
|
||||
|
||||
**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.
|
||||
## Katan
|
||||
|
||||
## Code map
|
||||
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
|
||||
sets chosen at setup: the base game (`docs/RULES-catan.md`) and Cities & Knights
|
||||
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
|
||||
`CatanState.ck`, which is null in the base game). The screen is layered; see
|
||||
`docs/UI-catan.md` before changing it.
|
||||
|
||||
- `game/` — rules engines and AI, **pure Kotlin, no Android types**, unit
|
||||
tested. `Catalog.kt` is the registry (id, title, blurb, options, `available`).
|
||||
- `net/` — wire protocol, LAN transport (mDNS + TCP), lobby, host-authoritative
|
||||
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.
|
||||
## Bash output is filtered before it reaches the model
|
||||
|
||||
Host is authoritative over the wire; clients send intents, host re-validates
|
||||
against the rules. Keep `game/` free of Android imports — the JVM tests depend
|
||||
on it.
|
||||
`.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.
|
||||
|
||||
## Git
|
||||
Measured on this repo rather than quoted from rtk's README:
|
||||
|
||||
- Branches: `main` (production), `dev` (integration). Feature branches → PR
|
||||
into `dev`.
|
||||
- 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.
|
||||
| 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% |
|
||||
|
||||
## Legacy files
|
||||
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.
|
||||
|
||||
`.claude/` and `.rtk/` are the upstream author's assistant tooling, harmless
|
||||
and left as inherited. This file supersedes the upstream's CLAUDE.md (which
|
||||
documented their GitHub sandbox; that history is still in git).
|
||||
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,32 +1,24 @@
|
||||
# ProLibertateGames
|
||||
# Pro Libertate Games
|
||||
|
||||
A collection of card and board games for Android, playable against the computer
|
||||
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
|
||||
|
||||
The app shell is complete and every game in the catalogue is playable end to end:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage, Hearts, Klondike, FreeCell, Spider, Pyramid |
|
||||
| **Table** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Peg Solitaire, Yahtzee, Pirates and Bulgars, Ta Yü, Katan *(coming soon)* |
|
||||
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage |
|
||||
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
|
||||
|
||||
One entry is marked *Coming soon*: Katan, the island game, whose engine and
|
||||
screen land with their own task. The machinery is still there and still tested:
|
||||
a game listed in `GameCatalog` with `available = false` appears in the menu
|
||||
marked *Coming soon* **on dev builds only**, so a production release lists just
|
||||
what can actually be played and nobody installs it and taps into a dead end. The
|
||||
menu decides this from the installed APK's own version name — the same source
|
||||
the updater uses to know which channel it is on.
|
||||
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
|
||||
machinery for it is still there and still tested: a game listed in `GameCatalog`
|
||||
with `available = false` appears in the menu marked *Coming soon* **on dev builds
|
||||
only**, so a production release lists just what can actually be played and nobody
|
||||
installs it and taps into a dead end. The menu decides this from the installed
|
||||
APK's own version name — the same source the updater uses to know which channel
|
||||
it is on.
|
||||
|
||||
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
||||
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
|
||||
@@ -119,53 +111,55 @@ it: every rule above is a pure function and unit-tested on the JVM.
|
||||
- **Sound** on/off.
|
||||
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
|
||||
the computer appears to think.
|
||||
- **Updates** — check on launch, or check on demand. The channel is this
|
||||
project's own Gitea releases ([docs/CI.md](docs/CI.md)), and **there is no
|
||||
channel to choose**: a build updates from its own channel and no other. The
|
||||
settings screen states which one is running rather than offering a switch.
|
||||
`BuildConfig.OTA_ENABLED` is the kill switch, not a preference.
|
||||
- **Updates** — check on launch, or check on demand. **There is no channel to
|
||||
choose**: a build updates from its own channel and no other. A production
|
||||
install is offered production builds from `main`; a dev install is offered dev
|
||||
builds from `dev`. The settings screen states which one is running rather than
|
||||
offering a switch.
|
||||
|
||||
The check on launch **only ever speaks when there is a build to install**. It
|
||||
runs behind the splash and waits for it to clear, and an app that is already
|
||||
current — or a phone that cannot reach the release server — says nothing,
|
||||
because opening the app was not a question. The button in Settings is a
|
||||
question, so it answers every time, including "you're on the latest" and
|
||||
whatever went wrong.
|
||||
current — or a phone that cannot reach GitHub — says nothing, because opening
|
||||
the app was not a question. The button in Settings is a question, so it
|
||||
answers every time, including "you're on the latest" and whatever went wrong.
|
||||
|
||||
Wanting both means installing both — they are separate apps and each updates
|
||||
itself, see [Both channels on one device](#both-channels-on-one-device).
|
||||
|
||||
## Branding
|
||||
|
||||
The app wears the upstream's own branding, restored (WALA-118 review): the
|
||||
Wallace tartan is the launcher icon and the splash, drawn as vector drawables and
|
||||
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.
|
||||
The launcher icon is a single square of the Wallace tartan set on the bias, and
|
||||
the app opens on the same cloth carrying the name in a Celtic uncial hand for two
|
||||
seconds before fading into the menu.
|
||||
|
||||
A dev build wears the **hunting green** sett where production wears the **clan
|
||||
red**, and installs as *Pro Libertate Games DEV*. That is what tells the two
|
||||
copies apart when both are on the phone; see
|
||||
[Both channels on one device](#both-channels-on-one-device). The tartan is drawn
|
||||
by `ui/theme/Tartan.kt` (`TartanBackground`, `threadWidthFor`) off
|
||||
`BuildConfig.DEV_BUILD`; the matching vector colours are generated per build by
|
||||
`resValue` in `app/build.gradle.kts` — the icon vectors reference
|
||||
`@color/tartan_field` rather than a literal, which is how one set of vectors
|
||||
serves both colourways.
|
||||
A dev build wears **Wallace Hunting** instead — the same threadcount with green
|
||||
where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is
|
||||
what tells the two copies apart when both are on the phone; see
|
||||
[Both channels on one device](#both-channels-on-one-device).
|
||||
|
||||
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
|
||||
warp and then as weft at half opacity, which is what produces the blended
|
||||
squares instead of a flat grid. The icon is the same construction as a vector
|
||||
drawable, so it stays sharp at every density and needs no PNGs.
|
||||
|
||||
Two honest caveats:
|
||||
|
||||
- **The sett is the published threadcount: `K/4 R32 K32 Y/4`.** The slashes are
|
||||
pivots, so `WallaceSett` holds the half-sett and reflects it out to
|
||||
`K4 R32 K32 Y4 K32 R32` — 136 threads, about half black, half red, with a
|
||||
yellow line worth 3%. Two details define the pattern and are easy to get
|
||||
backwards: the yellow overstripe runs down the *centre of a wide black band*,
|
||||
and the narrow black guard sits *between two red blocks*. The icon is
|
||||
generated from the same numbers, so the icon and the splash cannot drift.
|
||||
Weave scale is derived from the sett rather than fixed, so changing the
|
||||
threadcount rescales both instead of silently zooming in. The hunting
|
||||
colourway is the same threadcount passed a different field colour, not a
|
||||
second copy of the numbers, so it cannot fall out of step either.
|
||||
- **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
|
||||
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
|
||||
the SIL Open Font License 1.1. The full licence is at
|
||||
[docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and
|
||||
must stay with the font — the OFL requires it to be distributed alongside.
|
||||
- **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
|
||||
|
||||
@@ -189,70 +183,122 @@ smaller than a fingertip.
|
||||
|
||||
Requires JDK 17 and the Android SDK (compileSdk 34).
|
||||
|
||||
A local build is a dev build: it installs as
|
||||
`ca.wallacenet.pilgrimsofkatan.dev` under the name *Pro Libertate Games DEV* on
|
||||
the hunting-green sett, so it goes on beside a production copy rather than over
|
||||
it. Set `PLG_CHANNEL=production` to build the production package
|
||||
(`ca.wallacenet.pilgrimsofkatan`, clan red).
|
||||
A local build is a dev build: it installs as `org.prolibertate.games.dev` under
|
||||
the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an
|
||||
installed release rather than over it. Set `PLG_CHANNEL=production` to build the
|
||||
production package instead.
|
||||
|
||||
## CI
|
||||
|
||||
Full decision record: [docs/CI.md](docs/CI.md).
|
||||
|
||||
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
|
||||
requests into `dev` or `main`, and publishes a dev prerelease from `dev`.
|
||||
- `.github/workflows/release.yml` — builds and publishes the production release
|
||||
on push to `main`, or by hand (`workflow_dispatch`).
|
||||
- Both publish through [scripts/publish-release.sh](scripts/publish-release.sh)
|
||||
against Gitea's REST API, which is also runnable by hand. **No Actions runner
|
||||
is registered on the instance yet**, so until one is, the local build is the
|
||||
guaranteed path (`./gradlew assembleDebug && ./gradlew testDebugUnitTest`)
|
||||
and releases go out via the script.
|
||||
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
|
||||
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
|
||||
`gradle.properties`, currently **1.3**.
|
||||
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
|
||||
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
|
||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
||||
|
||||
### Hosting: plg.wallacenet.ca
|
||||
|
||||
Both channels are also deployed to one Cloudflare Pages project (`plg`), served at
|
||||
`https://plg.wallacenet.ca`:
|
||||
|
||||
```
|
||||
/ landing page (reads both latest.json files)
|
||||
/prod/latest.json {tag, apkName, apkUrl, channel} — what the updater reads
|
||||
/prod/ProLibertateGames.apk, /prod/ProLibertateGames-v<series>.<n>.apk
|
||||
/dev/... same, for the dev channel
|
||||
```
|
||||
|
||||
Each workflow's `deploy-pages` job runs `.github/scripts/stage-pages-site.sh`,
|
||||
which stages its own channel and re-fetches the other channel from the live site,
|
||||
because a Pages deploy replaces everything. The two share a concurrency group so
|
||||
they cannot race. Needs repo secrets `CLOUDFLARE_API_TOKEN` (Account → Cloudflare
|
||||
Pages → Edit) and `CLOUDFLARE_ACCOUNT_ID`. Pages caps files at 25 MiB.
|
||||
|
||||
**First deploy:** the site does not exist yet, so the domain answers 403 (Cloudflare error 1014) and the carry-over fetch would abort. Dispatch the workflow once with `first_deploy` ticked, then attach `plg.wallacenet.ca` as a custom domain on the new `plg` Pages project. Later runs need no input.
|
||||
|
||||
The in-app updater reads these `latest.json` files, 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
|
||||
|
||||
A version is **`<series>.<build number>`**. Both values are set by hand, once,
|
||||
in `gradle.properties`:
|
||||
A version is **`<series>.<run number>`**. The series is set by hand, once, in
|
||||
`gradle.properties`:
|
||||
|
||||
```properties
|
||||
plgVersionSeries=1.4
|
||||
plgVersionRunOffset=1000
|
||||
plgVersionSeries=1.3
|
||||
```
|
||||
|
||||
Both workflows read those same properties when they name a release tag, and the
|
||||
build reads them when it stamps the APK, so the tag and what is inside it cannot
|
||||
name different versions. Changing either is a one-line change to that file and
|
||||
nothing else — but they are not equivalent:
|
||||
Both workflows read that same property when they name a release tag, and the
|
||||
build reads it when it stamps the APK, so the tag and what is inside it cannot
|
||||
name different versions. Bumping a series is a one-line change to that file and
|
||||
nothing else.
|
||||
|
||||
- The **series** is a name for people. Bumping it changes nothing about
|
||||
ordering.
|
||||
- The **build number** is what the machines compare: `versionCode`, the tag's
|
||||
trailing number, and the number in `versionName`, all the same figure —
|
||||
`CI run number + plgVersionRunOffset`. Because the offset never changes and
|
||||
the run number only climbs, going from 1.4 to 1.5 never looks like a downgrade
|
||||
to Android and never interrupts an OTA update.
|
||||
|
||||
The offset exists because Gitea Actions restarts run numbers at 1 for each
|
||||
workflow: without a floor, the first release of a fresh instance would collide
|
||||
with the `versionCode 1` every unstamped local build carries, and a counter
|
||||
reset would make Android refuse the next build outright. Full reasoning in
|
||||
[docs/CI.md](docs/CI.md).
|
||||
The number after it is the CI run number and keeps climbing on its own, which is
|
||||
what makes a series bump safe: `versionCode` is the run number *alone*, so
|
||||
going from 1.0 to 1.1 never looks like a downgrade to Android and never
|
||||
interrupts an OTA update. The series is a name for people; the run number is
|
||||
what the machines compare.
|
||||
|
||||
### Update channels
|
||||
|
||||
`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:
|
||||
Both pipelines publish, to two separate channels:
|
||||
|
||||
- production comes only from `main` (`release.yml` refuses any other branch);
|
||||
- dev prereleases are marked so the "latest release" endpoint can never hand one
|
||||
to a production install;
|
||||
- pull requests build and check but never publish.
|
||||
| Channel | Published by | Tag | Marked |
|
||||
| --- | --- | --- | --- |
|
||||
| Production | `release.yml`, push to `main` | `v<series>.<n>` | release |
|
||||
| Dev | `ci.yml`, any build of `dev` | `v<series>.<n>-dev` | prerelease |
|
||||
|
||||
Dev builds are published as **prereleases** deliberately: GitHub's "latest
|
||||
release" endpoint skips prereleases, so a production-channel install can never
|
||||
be handed a dev build even by accident. Pull requests build and check but never
|
||||
publish, so a PR from a fork cannot ship anything.
|
||||
|
||||
**Both pipelines gate publishing on the branch rather than on what triggered
|
||||
the build**, so a manual run from the Actions tab publishes exactly as a push
|
||||
does — useful when a push cannot fire the workflow itself, which is the case
|
||||
for anything pushed by an automation token. The safety property is unchanged,
|
||||
because publishing still happens only after that same job has built, tested and
|
||||
linted clean. A pull request is still excluded without having to be named: a PR
|
||||
build runs against `refs/pull/<n>/merge`, never `refs/heads/dev`.
|
||||
|
||||
In `ci.yml` the `PLG_CHANNEL` stamp is gated on the same condition as the
|
||||
publish steps, and the two have to stay in step. Stamping a build that then
|
||||
ships, or shipping one that was left unstamped, both end with an APK claiming
|
||||
`versionCode` 1 — which the updater can never see as an upgrade over anything.
|
||||
|
||||
`release.yml` refuses to run off any branch but `main`, so a manual dispatch
|
||||
aimed elsewhere does nothing rather than shipping unreviewed code.
|
||||
|
||||
**The two channels have independent version sequences** — a build's `versionCode`
|
||||
is the build number of the workflow that produced it (run number + offset), and
|
||||
the two workflows count separately, so the numbers say nothing across the
|
||||
divide.
|
||||
is the run number of the workflow that produced it, and the two workflows count
|
||||
separately. Dev is on 69 while production is on 40, so the numbers say nothing
|
||||
across the divide.
|
||||
|
||||
That is why **an install only ever updates within its own channel**, and why
|
||||
there is no setting for it. The app reads the `-dev` suffix in its own
|
||||
@@ -264,18 +310,18 @@ unit-tested on the JVM like the rules engines are.
|
||||
|
||||
### Both channels on one device
|
||||
|
||||
A dev build installs under its own `applicationId`,
|
||||
`ca.wallacenet.pilgrimsofkatan.dev`, so it is a separate app as far as Android
|
||||
is concerned and can sit beside the production copy rather than replacing it. Each channel still updates itself
|
||||
A dev build installs under its own `applicationId`, `org.prolibertate.games.dev`,
|
||||
so it is a separate app as far as Android is concerned and can sit beside the
|
||||
production copy rather than replacing it. Each channel still updates itself
|
||||
normally, because an update within a channel keeps the same package.
|
||||
|
||||
Three things move together, all off `isDevBuild` in `app/build.gradle.kts`:
|
||||
|
||||
| | Production | Dev |
|
||||
| --- | --- | --- |
|
||||
| `applicationId` | `ca.wallacenet.pilgrimsofkatan` | `ca.wallacenet.pilgrimsofkatan.dev` |
|
||||
| `applicationId` | `org.prolibertate.games` | `org.prolibertate.games.dev` |
|
||||
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
|
||||
| Tartan sett | clan red | hunting green |
|
||||
| Sett | Wallace (red) | Wallace Hunting (green) |
|
||||
|
||||
Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build,
|
||||
so a local `assembleDebug` is a dev build too and installs alongside a release
|
||||
@@ -285,11 +331,11 @@ They have to move together. A dev package wearing the production name and
|
||||
colours is precisely the mix-up that having both installed is meant to prevent,
|
||||
which is why one flag drives all three rather than each being set by hand. The
|
||||
name and the colours reach the resources through `resValue`, so `app_name`,
|
||||
`brand_field` and `splash_background` are generated by the build and are
|
||||
deliberately *not* in `res/values/` — the icon vectors reference
|
||||
`@color/brand_field` rather than a literal, which is how one set of vectors
|
||||
serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag into
|
||||
Kotlin, where `BrandField` picks the matching Compose colour for the splash.
|
||||
`tartan_field`, `splash_background` and `ic_launcher_background` are generated
|
||||
by the build and are deliberately *not* in `res/values/` — the icon vectors
|
||||
reference `@color/tartan_field` rather than a literal, which is how one set of
|
||||
vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
|
||||
into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
|
||||
|
||||
Because they are separate apps, having both is a matter of installing both, and
|
||||
each then updates itself down its own line. Nothing in the app moves a phone
|
||||
@@ -303,7 +349,7 @@ for a signature mismatch.
|
||||
## Rules
|
||||
|
||||
The exact rules implemented, and the options that vary them, are documented per
|
||||
game (index: [docs/README.md](docs/README.md)):
|
||||
game:
|
||||
|
||||
- [Euchre](docs/RULES-euchre.md)
|
||||
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
|
||||
@@ -327,7 +373,9 @@ game (index: [docs/README.md](docs/README.md)):
|
||||
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
|
||||
- [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
|
||||
rules engine, AI and screen are in place
|
||||
- [Katan: Cities & Knights](docs/RULES-catan-ck.md) — the expansion rule set, chosen on
|
||||
the setup screen: commodities, knights, barbarians, city improvements and progress cards
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -350,12 +398,11 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||||
mastermind/ Mastermind codes, scoring, and a code breaker
|
||||
pirates/ Pirates and Bulgars — the cross board, the hunt, search
|
||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||
catan/ Katan island geometry, production, trade, dev cards, heuristic AI
|
||||
catan/ Katan island geometry, production, trade, dev cards, Cities & Knights, heuristic AI
|
||||
net/ wire protocol, LAN transport, lobby, match driver
|
||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||
settings/ DataStore-backed preferences
|
||||
update/ OTA updater against this repo's Gitea releases, and the
|
||||
channel rule — pure, tested
|
||||
update/ Cloudflare Pages OTA updater, and the channel rule — pure, tested
|
||||
ui/ Compose screens
|
||||
```
|
||||
|
||||
|
||||
+9
-28
@@ -16,18 +16,10 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
|
||||
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
|
||||
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
|
||||
|
||||
// The fixed floor every stamped build number is measured from, in
|
||||
// gradle.properties. Gitea Actions restarts its run numbers at 1 for each
|
||||
// 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).
|
||||
// See plgVersionRunOffset in gradle.properties: the stamped build number is the
|
||||
// CI run number plus a permanent floor, so a counter that restarts on a new CI
|
||||
// host can never produce a lower versionCode than one already installed.
|
||||
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
|
||||
|
||||
// The version series, from gradle.properties. Both workflows read the same
|
||||
@@ -46,30 +38,25 @@ val versionSeries = (project.findProperty("plgVersionSeries") as String?) ?: "1.
|
||||
val isDevBuild = buildChannel != "production"
|
||||
|
||||
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"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
// 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
|
||||
// replacing the other.
|
||||
applicationId = if (isDevBuild) "ca.wallacenet.pilgrimsofkatan.dev" else "ca.wallacenet.pilgrimsofkatan"
|
||||
// replacing the other. The namespace above is deliberately left alone:
|
||||
// R and BuildConfig stay in org.prolibertate.games for both, so no
|
||||
// source has to care which package it ended up installed as.
|
||||
applicationId = if (isDevBuild) "org.prolibertate.games.dev" else "org.prolibertate.games"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
// 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
|
||||
// tracks isDevBuild rather than the stamp alone, so an unstamped local
|
||||
// build cannot end up claiming a channel its applicationId contradicts.
|
||||
// versionCode is the stamped build number and nothing else, so it keeps
|
||||
// versionCode stays the run number and nothing else, so it keeps
|
||||
// climbing across a change of series: bumping 1.0 to 1.1 must not make
|
||||
// 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.
|
||||
// an update look like a downgrade to Android.
|
||||
versionCode = if (stampVersion) buildNumber else 1
|
||||
versionName = when {
|
||||
stampVersion && buildChannel == "production" -> "$versionSeries.$buildNumber"
|
||||
@@ -100,12 +87,6 @@ android {
|
||||
resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B")
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- OTA updates from plg.wallacenet.ca, plus LAN play over TCP sockets. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
|
||||
|
||||
@@ -42,7 +42,20 @@ data class GameDescriptor(
|
||||
val teamBased: Boolean,
|
||||
val blurb: String,
|
||||
val available: Boolean,
|
||||
/**
|
||||
* Playable on a dev build while still hidden from production.
|
||||
*
|
||||
* [available] is the release gate: it is what a production build lists and
|
||||
* lets people open. A game that has an engine and a screen but has not yet
|
||||
* been verified on real devices sets this instead, so it can be played and
|
||||
* tested from the dev channel without being offered to everyone. Flip
|
||||
* [available] on (and this off) once it has been verified.
|
||||
*/
|
||||
val devPreview: Boolean = false,
|
||||
) {
|
||||
/** Whether the menu should let this game be opened in the given kind of build. */
|
||||
fun playableIn(devBuild: Boolean): Boolean = available || (devBuild && devPreview)
|
||||
|
||||
/**
|
||||
* The one menu this game is listed under.
|
||||
*
|
||||
@@ -347,11 +360,13 @@ object GameCatalog {
|
||||
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.
|
||||
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
|
||||
// Engine and screen exist but have not been verified on two real
|
||||
// devices yet. Dev builds can open it as a *Preview*; a production
|
||||
// build does not list it. Flip `available` on, and `devPreview`
|
||||
// off, once two-device play has been verified.
|
||||
available = false,
|
||||
devPreview = true,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
*/
|
||||
class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
|
||||
/** Plays the Cities & Knights tables; this class keeps the base game. */
|
||||
private val ck = CatanCkAi(this)
|
||||
|
||||
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
|
||||
"No legal move for seat $seat"
|
||||
}
|
||||
if (state.isCk) return ck.choose(state, seat, legal)
|
||||
return when (state.phase) {
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
||||
@@ -38,10 +42,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
|
||||
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
|
||||
|
||||
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
|
||||
|
||||
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
||||
|
||||
CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||
?: error("The game is over; nobody chooses a move")
|
||||
CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||
?: error("Nothing to choose in phase ${state.phase}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +55,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
// Setup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
|
||||
internal 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) +
|
||||
@@ -260,23 +266,38 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
}
|
||||
|
||||
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) }
|
||||
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull()
|
||||
val offer = state.pendingTrade
|
||||
if (accept == null || offer == null) return legal.first { it is DeclineTrade }
|
||||
// Accept when what arrives (the offerer's give) is worth more than what
|
||||
// leaves (the offerer's get): a mild table, not a shark.
|
||||
val incoming = offer.give.sumOf { want(it) }
|
||||
val outgoing = offer.get.sumOf { want(it) }
|
||||
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the counter-offer that gains the most by the same valuation, or
|
||||
* turns them all down. The AI never counters itself: it only answers.
|
||||
*/
|
||||
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove {
|
||||
val offer = state.pendingTrade ?: return legal.first()
|
||||
val best = legal.filterIsInstance<AcceptCounter>()
|
||||
.map { choice -> choice to offer.counters.first { it.by == choice.seat } }
|
||||
.maxByOrNull { (_, c) -> c.give.sumOf { want(it) } - c.get.sumOf { want(it) } }
|
||||
if (best != null) {
|
||||
val (choice, c) = best
|
||||
if (c.give.sumOf { want(it) } > c.get.sumOf { want(it) }) return choice
|
||||
}
|
||||
return legal.first { it is DismissCounters }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Valuations
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What a seat would think of building on [vertex]: production and ports. */
|
||||
private fun spotValue(state: CatanState, vertex: Int): Double {
|
||||
internal fun spotValue(state: CatanState, vertex: Int): Double {
|
||||
if (state.buildings[vertex] != null) return -1.0
|
||||
var value = 0.0
|
||||
val kinds = mutableSetOf<Resource>()
|
||||
@@ -297,7 +318,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
* 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 {
|
||||
internal 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)) {
|
||||
@@ -309,7 +330,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
}
|
||||
|
||||
/** How much the AI wants to hold one card of [resource]. */
|
||||
private fun want(resource: Resource): Double = when (resource) {
|
||||
internal 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,345 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
/**
|
||||
* The computer opponent for Cities & Knights, reached through [CatanAi].
|
||||
*
|
||||
* It plays the same cautious game as the base AI — settle well, raise cities —
|
||||
* and adds the three things this rule set asks of a player: keep enough active
|
||||
* knights standing when the barbarians land, spend commodities on improvements
|
||||
* as soon as it can, and answer every chore the table raises with the card it
|
||||
* values least. It reads only what a player at the table could see, plus its
|
||||
* own hand.
|
||||
*
|
||||
* It is not a strong player. It never moves knights about (they stay where
|
||||
* hired, and are activated when the barbarians draw near), never plays the
|
||||
* progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat,
|
||||
* Deserter, Intrigue), and makes no domestic offers, though it answers them.
|
||||
*/
|
||||
internal class CatanCkAi(private val ai: CatanAi) {
|
||||
|
||||
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove = when (state.phase) {
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
||||
|
||||
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
|
||||
|
||||
CatanPhase.CHORE -> chooseChore(state, seat, legal)
|
||||
|
||||
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
|
||||
|
||||
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
|
||||
|
||||
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
|
||||
|
||||
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
|
||||
|
||||
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
||||
|
||||
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||
?: error("Nothing to choose in phase ${state.phase}")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Chores
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
val ck = state.ck!!
|
||||
return when (val chore = ck.chores.first()) {
|
||||
is DiscardChore -> {
|
||||
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
|
||||
DiscardGoods(CatanCk.goodsList(state, seat).sortedBy { worth(state, seat, it) }.take(n))
|
||||
}
|
||||
|
||||
is GiveChore -> {
|
||||
val pool = CatanCk.goodsList(state, seat)
|
||||
.filter { !chore.commoditiesOnly || it.isCommodity }
|
||||
.sortedBy { worth(state, seat, it) }
|
||||
GiveGoods(pool.take(minOf(chore.count, pool.size)))
|
||||
}
|
||||
|
||||
is PillageChore ->
|
||||
legal.filterIsInstance<PillageCity>().minByOrNull { production(state, it.vertex) } ?: legal.first()
|
||||
|
||||
is PickDeckChore -> {
|
||||
val picks = legal.filterIsInstance<PickDeck>()
|
||||
picks.maxByOrNull { CatanCk.improvement(state, seat, it.track) } ?: legal.first()
|
||||
}
|
||||
|
||||
is DropProgressChore ->
|
||||
legal.filterIsInstance<DropProgress>().minByOrNull { drop ->
|
||||
ck.progressHands[seat].first { it.id == drop.card }.kind.let(::cardWorth)
|
||||
} ?: legal.first()
|
||||
|
||||
is MetropolisChore ->
|
||||
legal.filterIsInstance<PlaceMetropolis>().maxByOrNull { production(state, it.vertex) }
|
||||
?: legal.first()
|
||||
|
||||
is AqueductChore ->
|
||||
legal.filterIsInstance<TakeAqueduct>().maxByOrNull { ai.want(it.resource) } ?: legal.first()
|
||||
|
||||
is RelocateChore -> legal.first()
|
||||
|
||||
is RemoveKnightChore ->
|
||||
legal.filterIsInstance<RemoveKnight>().minByOrNull {
|
||||
CatanCk.knightAt(state, it.vertex)?.level ?: 0
|
||||
} ?: legal.first()
|
||||
|
||||
is PlaceKnightChore -> legal.first()
|
||||
|
||||
is SpyChore -> legal.first()
|
||||
|
||||
is SmithChore ->
|
||||
legal.filterIsInstance<PromoteKnight>().firstOrNull() ?: legal.first { it is EndSmith }
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The robber
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
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
|
||||
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
|
||||
score += if (building.owner == seat) -worth * odds else worth * odds
|
||||
}
|
||||
if (move.victim != null) {
|
||||
score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim]
|
||||
}
|
||||
score
|
||||
} ?: moves.first()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The main phase
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
val ck = state.ck!!
|
||||
val soon = ck.barbarians <= 3
|
||||
|
||||
// Chase the robber off a hex it is squatting on.
|
||||
if (ck.attacked) {
|
||||
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
|
||||
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
|
||||
if (onMine != null && robberHurtsMe) return onMine
|
||||
}
|
||||
|
||||
// The cheap upgrade first, then the full-price one.
|
||||
legal.filterIsInstance<PlayProgress>()
|
||||
.filter { kindOf(state, seat, it) == ProgressType.MEDICINE }
|
||||
.maxByOrNull { production(state, it.vertex!!) }
|
||||
?.let { return it }
|
||||
|
||||
legal.filterIsInstance<BuildCity>().maxByOrNull { production(state, it.vertex) }
|
||||
?.let { return it }
|
||||
|
||||
legal.filterIsInstance<BuildSettlement>().maxByOrNull { ai.spotValue(state, it.vertex) }
|
||||
?.let { return it }
|
||||
|
||||
// Improvements spend cards nothing else needs. Favour the track furthest along.
|
||||
legal.filterIsInstance<BuyImprovement>()
|
||||
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
|
||||
?.let { return it }
|
||||
|
||||
// Knights: stand ready by the time the barbarians land.
|
||||
defence(state, seat, legal, soon)?.let { return it }
|
||||
|
||||
progress(state, seat, legal)?.let { return it }
|
||||
|
||||
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
|
||||
if (CatanCk.goodsCount(state, seat) >= 7) return it
|
||||
}
|
||||
|
||||
legal.filterIsInstance<BuildRoad>()
|
||||
.filter { ai.roadValue(state, it) > 0.1 }
|
||||
.maxByOrNull { ai.roadValue(state, it) }
|
||||
?.let { return it }
|
||||
|
||||
legal.filterIsInstance<GoodsBankTrade>()
|
||||
.filter { completes(state, seat, it) }
|
||||
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
|
||||
?.let { return it }
|
||||
|
||||
return legal.lastOrNull { it is EndTurn } ?: legal.first()
|
||||
}
|
||||
|
||||
/** Hire, promote and activate so the knights are standing when the barbarians land. */
|
||||
private fun defence(state: CatanState, seat: Int, legal: List<CatanMove>, soon: Boolean): CatanMove? {
|
||||
val mine = CatanCk.knightsOf(state, seat)
|
||||
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
|
||||
|
||||
if (soon) {
|
||||
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
|
||||
}
|
||||
val want = if (soon) 4 else 2
|
||||
if (mine.size < want) {
|
||||
legal.filterIsInstance<HireKnight>().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire ->
|
||||
// Keep a grain back for waking it, once the ship is close.
|
||||
if (!soon || grainLeft >= 1) return hire
|
||||
}
|
||||
}
|
||||
if (soon || mine.size >= 2) {
|
||||
legal.filterIsInstance<PromoteKnight>().firstOrNull()?.let { return it }
|
||||
}
|
||||
if (soon) {
|
||||
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Progress cards worth playing without a plan. */
|
||||
private fun progress(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove? {
|
||||
val ck = state.ck!!
|
||||
val soon = ck.barbarians <= 4
|
||||
val plays = legal.filterIsInstance<PlayProgress>()
|
||||
fun kindOf(play: PlayProgress) = ck.progressHands[seat].first { it.id == play.card }.kind
|
||||
fun first(kind: ProgressType) = plays.firstOrNull { kindOf(it) == kind }
|
||||
|
||||
first(ProgressType.IRRIGATION)?.let { return it }
|
||||
first(ProgressType.MINING)?.let { return it }
|
||||
first(ProgressType.ENGINEER)?.let { return it }
|
||||
first(ProgressType.SMITH)?.let { return it }
|
||||
first(ProgressType.WEDDING)?.let { return it }
|
||||
if (soon) first(ProgressType.WARLORD)?.let { return it }
|
||||
|
||||
plays.filter { kindOf(it) == ProgressType.MASTER_MERCHANT }
|
||||
.maxByOrNull { play -> play.goods.orEmpty().sumOf { worth(state, seat, it) } }
|
||||
?.let { return it }
|
||||
|
||||
first(ProgressType.SPY)?.let { return it }
|
||||
first(ProgressType.BISHOP)?.let { return it }
|
||||
|
||||
if (legal.filterIsInstance<BuildRoad>().size >= 2) first(ProgressType.ROAD_BUILDING)?.let { return it }
|
||||
|
||||
// Crane: only when it makes the difference to an improvement.
|
||||
first(ProgressType.CRANE)?.let { crane ->
|
||||
val helps = Track.entries.any { track ->
|
||||
val level = CatanCk.improvement(state, seat, track)
|
||||
val have = ck.commodities[seat][track.commodity] ?: 0
|
||||
level < IMPROVEMENT_MAX && have == level // one short of the next level's price
|
||||
}
|
||||
if (helps) return crane
|
||||
}
|
||||
|
||||
// Saboteur: when someone level or ahead is sitting on a pile.
|
||||
first(ProgressType.SABOTEUR)?.let { saboteur ->
|
||||
val vp = CatanRules.publicVictoryPoints(state)
|
||||
val pile = (0 until state.playerCount).filter { it != seat && vp[it] >= vp[seat] }
|
||||
.sumOf { CatanCk.goodsCount(state, it) / 2 }
|
||||
if (pile >= 4) return saboteur
|
||||
}
|
||||
|
||||
// Monopolies: only when the table is holding a pile of it.
|
||||
plays.filter { kindOf(it) == ProgressType.RESOURCE_MONOPOLY || kindOf(it) == ProgressType.TRADE_MONOPOLY }
|
||||
.maxByOrNull { held(state, seat, it.good!!) }
|
||||
?.let { best ->
|
||||
val each = if (kindOf(best) == ProgressType.RESOURCE_MONOPOLY) 2 else 1
|
||||
val take = (0 until state.playerCount).filter { it != seat }
|
||||
.sumOf { minOf(each, CatanCk.goodsOf(state, it)[best.good!!] ?: 0) }
|
||||
if (take >= 3) return best
|
||||
}
|
||||
|
||||
// The merchant, on the best hex beside a building of ours.
|
||||
plays.filter { kindOf(it) == ProgressType.MERCHANT }
|
||||
.maxByOrNull { play ->
|
||||
val hex = play.hexA!!
|
||||
CatanBoard.numberOdds(state.numbers[hex]).toDouble()
|
||||
}?.let { return it }
|
||||
|
||||
// A commercial harbour turns spare resources into commodities.
|
||||
plays.filter { kindOf(it) == ProgressType.COMMERCIAL_HARBOR }
|
||||
.maxByOrNull { held(state, seat, it.good!!) }
|
||||
?.let { if (held(state, seat, it.good!!) >= 2) return it }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun kindOf(state: CatanState, seat: Int, play: PlayProgress): ProgressType =
|
||||
state.ck!!.progressHands[seat].first { it.id == play.card }.kind
|
||||
|
||||
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
|
||||
private fun production(state: CatanState, vertex: Int): Double =
|
||||
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
|
||||
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
|
||||
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
|
||||
}
|
||||
|
||||
private fun held(state: CatanState, seat: Int, good: Good): Int = CatanCk.goodsOf(state, seat)[good] ?: 0
|
||||
|
||||
/** Whether the trade leaves a hand that can pay for a city, a settlement or an improvement. */
|
||||
private fun completes(state: CatanState, seat: Int, trade: GoodsBankTrade): Boolean {
|
||||
val hand = CatanCk.goodsOf(state, seat).toMutableMap()
|
||||
hand[trade.give] = (hand[trade.give] ?: 0) - CatanCk.bankRatio(state, seat, trade.give)
|
||||
hand[trade.get] = (hand[trade.get] ?: 0) + 1
|
||||
fun covers(cost: Map<Resource, Int>) = cost.all { (r, n) -> (hand[Good.of(r)] ?: 0) >= n }
|
||||
if (covers(CatanRules.CITY_COST) || covers(CatanRules.SETTLEMENT_COST)) return true
|
||||
return Track.entries.any { track ->
|
||||
val level = CatanCk.improvement(state, seat, track)
|
||||
level < IMPROVEMENT_MAX &&
|
||||
(hand[Good.of(track.commodity)] ?: 0) >= level + 1 &&
|
||||
(state.ck!!.commodities[seat][track.commodity] ?: 0) < level + 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove =
|
||||
legal.filterIsInstance<BuildRoad>()
|
||||
.filter { ai.roadValue(state, it) > 0.05 }
|
||||
.maxByOrNull { ai.roadValue(state, it) }
|
||||
?: legal.firstOrNull { it is EndFreeRoads }
|
||||
?: legal.first()
|
||||
|
||||
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
val decline = legal.first { it is DeclineTrade }
|
||||
val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline
|
||||
val offer = state.ck?.goodsOffer
|
||||
if (offer != null) {
|
||||
val incoming = offer.give.sumOf { worth(state, seat, it) }
|
||||
val outgoing = offer.get.sumOf { worth(state, seat, it) }
|
||||
return if (incoming > outgoing) accept else decline
|
||||
}
|
||||
val plain = state.pendingTrade ?: return decline
|
||||
val incoming = plain.give.sumOf { ai.want(it) }
|
||||
val outgoing = plain.get.sumOf { ai.want(it) }
|
||||
return if (incoming > outgoing) accept else decline
|
||||
}
|
||||
|
||||
/** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */
|
||||
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove =
|
||||
legal.firstOrNull { it is DismissCounters } ?: legal.first()
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Valuations
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
|
||||
private fun worth(state: CatanState, seat: Int, good: Good): Double =
|
||||
good.resource?.let { ai.want(it) } ?: when (good) {
|
||||
Good.PAPER -> 0.95
|
||||
Good.COIN -> 0.9
|
||||
else -> 0.85
|
||||
}
|
||||
|
||||
/** Which track to lean toward when two are level: politics for the knights, then science. */
|
||||
private fun trackLean(track: Track): Int = when (track) {
|
||||
Track.POLITICS -> 2
|
||||
Track.SCIENCE -> 1
|
||||
Track.TRADE -> 0
|
||||
}
|
||||
|
||||
private fun cardWorth(kind: ProgressType): Double = when (kind) {
|
||||
ProgressType.INVENTOR, ProgressType.ALCHEMIST, ProgressType.DIPLOMAT,
|
||||
ProgressType.DESERTER, ProgressType.INTRIGUE, ProgressType.MERCHANT_FLEET,
|
||||
-> 0.0
|
||||
|
||||
ProgressType.MERCHANT, ProgressType.COMMERCIAL_HARBOR, ProgressType.CRANE -> 0.5
|
||||
else -> 1.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/*
|
||||
* The data of the Cities & Knights rule set. The rules themselves live in
|
||||
* [CatanCk]; docs/RULES-catan-ck.md describes them and the choices made.
|
||||
*
|
||||
* Everything here is reached through [CatanState.ck], which is null for the
|
||||
* base game, so a base game never sees any of it.
|
||||
*/
|
||||
|
||||
/** Steps the barbarian ship must make before it lands. */
|
||||
const val BARBARIAN_STEPS = 7
|
||||
|
||||
/** Commodity cards of each type the bank starts with (resources have 19). */
|
||||
const val BANK_PER_COMMODITY = 12
|
||||
|
||||
/** Knights of each strength one player owns. */
|
||||
const val KNIGHTS_PER_LEVEL = 2
|
||||
|
||||
/** City walls one player owns. */
|
||||
const val MAX_WALLS = 3
|
||||
|
||||
/** Levels of city improvement in each track. */
|
||||
const val IMPROVEMENT_MAX = 5
|
||||
|
||||
/** Progress cards a player may keep in hand. */
|
||||
const val PROGRESS_HAND_MAX = 4
|
||||
|
||||
/** Cards a player may hold through a seven, before walls. */
|
||||
const val BASE_HAND_LIMIT = 7
|
||||
|
||||
/** Longest Road is worth two points here; there is no Largest Army. */
|
||||
const val CK_LONGEST_ROAD_VP = 2
|
||||
|
||||
/** Improvement level that unlocks the track's special power. */
|
||||
const val IMPROVEMENT_POWER_LEVEL = 3
|
||||
|
||||
/** Level at which a track can claim its metropolis, and the level that can steal one. */
|
||||
const val METROPOLIS_LEVEL = 4
|
||||
|
||||
/**
|
||||
* What the three cities-of-industry give in place of a second resource:
|
||||
* forest gives paper, pasture cloth and mountains coin.
|
||||
*/
|
||||
@Serializable
|
||||
enum class Commodity { CLOTH, COIN, PAPER }
|
||||
|
||||
/**
|
||||
* Any card a player can hold in hand and trade: a resource or a commodity.
|
||||
* Progress cards are not goods.
|
||||
*/
|
||||
@Serializable
|
||||
enum class Good {
|
||||
LUMBER, BRICK, WOOL, GRAIN, ORE, CLOTH, COIN, PAPER;
|
||||
|
||||
val resource: Resource?
|
||||
get() = when (this) {
|
||||
LUMBER -> Resource.LUMBER
|
||||
BRICK -> Resource.BRICK
|
||||
WOOL -> Resource.WOOL
|
||||
GRAIN -> Resource.GRAIN
|
||||
ORE -> Resource.ORE
|
||||
else -> null
|
||||
}
|
||||
|
||||
val commodity: Commodity?
|
||||
get() = when (this) {
|
||||
CLOTH -> Commodity.CLOTH
|
||||
COIN -> Commodity.COIN
|
||||
PAPER -> Commodity.PAPER
|
||||
else -> null
|
||||
}
|
||||
|
||||
val isCommodity: Boolean get() = commodity != null
|
||||
|
||||
companion object {
|
||||
fun of(resource: Resource): Good = when (resource) {
|
||||
Resource.LUMBER -> LUMBER
|
||||
Resource.BRICK -> BRICK
|
||||
Resource.WOOL -> WOOL
|
||||
Resource.GRAIN -> GRAIN
|
||||
Resource.ORE -> ORE
|
||||
}
|
||||
|
||||
fun of(commodity: Commodity): Good = when (commodity) {
|
||||
Commodity.CLOTH -> CLOTH
|
||||
Commodity.COIN -> COIN
|
||||
Commodity.PAPER -> PAPER
|
||||
}
|
||||
|
||||
val RESOURCES: List<Good> = Resource.entries.map { of(it) }
|
||||
val COMMODITIES: List<Good> = Commodity.entries.map { of(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The three tracks of city improvement. Each is bought with one commodity, has
|
||||
* a colour of progress-card deck and a power that unlocks at level three.
|
||||
*/
|
||||
@Serializable
|
||||
enum class Track(val commodity: Commodity) {
|
||||
/** Yellow, bought with cloth: trade commodities two for one. */
|
||||
TRADE(Commodity.CLOTH),
|
||||
|
||||
/** Blue, bought with coin: knights may become mighty. */
|
||||
POLITICS(Commodity.COIN),
|
||||
|
||||
/** Green, bought with paper: the aqueduct. */
|
||||
SCIENCE(Commodity.PAPER),
|
||||
}
|
||||
|
||||
/**
|
||||
* One face of the event die: a ship, or a city gate of a track's colour.
|
||||
* Three faces are ships.
|
||||
*/
|
||||
@Serializable
|
||||
enum class EventFace { SHIP, TRADE, POLITICS, SCIENCE }
|
||||
|
||||
/** The progress cards: [copies] of each in the deck of its [track]. */
|
||||
@Serializable
|
||||
enum class ProgressType(val track: Track, val copies: Int, val scoresAtOnce: Boolean = false) {
|
||||
// Science (green)
|
||||
ALCHEMIST(Track.SCIENCE, 2),
|
||||
CRANE(Track.SCIENCE, 2),
|
||||
ENGINEER(Track.SCIENCE, 1),
|
||||
INVENTOR(Track.SCIENCE, 2),
|
||||
IRRIGATION(Track.SCIENCE, 2),
|
||||
MEDICINE(Track.SCIENCE, 2),
|
||||
MINING(Track.SCIENCE, 2),
|
||||
PRINTER(Track.SCIENCE, 1, scoresAtOnce = true),
|
||||
ROAD_BUILDING(Track.SCIENCE, 2),
|
||||
SMITH(Track.SCIENCE, 2),
|
||||
|
||||
// Politics (blue)
|
||||
BISHOP(Track.POLITICS, 2),
|
||||
CONSTITUTION(Track.POLITICS, 1, scoresAtOnce = true),
|
||||
DESERTER(Track.POLITICS, 2),
|
||||
DIPLOMAT(Track.POLITICS, 2),
|
||||
INTRIGUE(Track.POLITICS, 2),
|
||||
SABOTEUR(Track.POLITICS, 2),
|
||||
SPY(Track.POLITICS, 3),
|
||||
WARLORD(Track.POLITICS, 2),
|
||||
WEDDING(Track.POLITICS, 2),
|
||||
|
||||
// Trade (yellow)
|
||||
COMMERCIAL_HARBOR(Track.TRADE, 2),
|
||||
MASTER_MERCHANT(Track.TRADE, 2),
|
||||
MERCHANT(Track.TRADE, 6),
|
||||
MERCHANT_FLEET(Track.TRADE, 2),
|
||||
RESOURCE_MONOPOLY(Track.TRADE, 4),
|
||||
TRADE_MONOPOLY(Track.TRADE, 2),
|
||||
}
|
||||
|
||||
/** One progress card; [id] is stable for the life of the game. */
|
||||
@Serializable
|
||||
data class ProgressCard(val id: Int, val kind: ProgressType)
|
||||
|
||||
/**
|
||||
* A knight on the board. [level] is its strength: 1 basic, 2 strong, 3 mighty.
|
||||
*
|
||||
* [activatedTurn] and [promotedTurn] record the turn number each happened on:
|
||||
* a knight cannot act on the turn it was activated, and cannot be promoted twice
|
||||
* in one turn.
|
||||
*/
|
||||
@Serializable
|
||||
data class Knight(
|
||||
val vertex: Int,
|
||||
val owner: Int,
|
||||
val level: Int,
|
||||
val active: Boolean = false,
|
||||
val activatedTurn: Int = 0,
|
||||
val promotedTurn: Int = 0,
|
||||
)
|
||||
|
||||
/** A metropolis stands on one city: [vertex] of seat [seat]. */
|
||||
@Serializable
|
||||
data class MetropolisAt(val seat: Int, val vertex: Int)
|
||||
|
||||
/** The merchant sits on a hex, controlled by [seat] (who holds its point). */
|
||||
@Serializable
|
||||
data class MerchantAt(val seat: Int, val hex: Int)
|
||||
|
||||
/** A counter-proposal to a [GoodsOffer]: seat [by] would [give] these if the offerer handed over [get]. */
|
||||
@Serializable
|
||||
data class GoodsCounter(val by: Int, val give: List<Good>, val get: List<Good>)
|
||||
|
||||
/**
|
||||
* A domestic trade offer in goods, which may include commodities. It is the
|
||||
* base game's [TradeOffer] in cards of any kind, and works the same way: [to] is
|
||||
* the one seat it is addressed to or null for the whole table, [asking] is the
|
||||
* seat that may answer now, and [counters] collect for the offerer to choose from.
|
||||
*/
|
||||
@Serializable
|
||||
data class GoodsOffer(
|
||||
val by: Int,
|
||||
val to: Int?,
|
||||
val give: List<Good>,
|
||||
val get: List<Good>,
|
||||
val asking: Int = to ?: -1,
|
||||
val counters: List<GoodsCounter> = emptyList(),
|
||||
)
|
||||
|
||||
/** Where play goes when the queue of [Chore]s runs out. */
|
||||
@Serializable
|
||||
enum class Back { PLAYING, AWAIT_ROLL, AFTER_EVENT, AFTER_DISCARDS, AFTER_PRODUCTION }
|
||||
|
||||
/**
|
||||
* Something one seat must decide before the game can go on. A chore is queued
|
||||
* when a roll, an attack or a card needs an answer from somebody (possibly not
|
||||
* the player whose turn it is); the first in [CkState.chores] is the one being
|
||||
* asked.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface Chore {
|
||||
val seat: Int
|
||||
}
|
||||
|
||||
/** Give [count] cards to the bank, of the seat's choosing (a seven, a saboteur). */
|
||||
@Serializable
|
||||
@SerialName("c_discard")
|
||||
data class DiscardChore(override val seat: Int, val count: Int) : Chore
|
||||
|
||||
/**
|
||||
* Hand [count] cards of the seat's choosing to seat [to] (a wedding, a
|
||||
* commercial harbour). [commoditiesOnly] restricts them to commodities, and
|
||||
* [reward] is the resource [to] gives back for each.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("c_give")
|
||||
data class GiveChore(
|
||||
override val seat: Int,
|
||||
val count: Int,
|
||||
val to: Int,
|
||||
val commoditiesOnly: Boolean = false,
|
||||
val reward: Good? = null,
|
||||
) : Chore
|
||||
|
||||
/** The barbarians won and this seat lost the contest: pick a city to fall. */
|
||||
@Serializable
|
||||
@SerialName("c_pillage")
|
||||
data class PillageChore(override val seat: Int) : Chore
|
||||
|
||||
/** Choose a colour of progress deck to draw from. */
|
||||
@Serializable
|
||||
@SerialName("c_pickDeck")
|
||||
data class PickDeckChore(override val seat: Int) : Chore
|
||||
|
||||
/** More progress cards than the hand holds: give one back. */
|
||||
@Serializable
|
||||
@SerialName("c_dropProgress")
|
||||
data class DropProgressChore(override val seat: Int) : Chore
|
||||
|
||||
/** Choose the city that will bear the [track] metropolis. */
|
||||
@Serializable
|
||||
@SerialName("c_metropolis")
|
||||
data class MetropolisChore(override val seat: Int, val track: Track) : Chore
|
||||
|
||||
/** The aqueduct pays out: take any one resource. */
|
||||
@Serializable
|
||||
@SerialName("c_aqueduct")
|
||||
data class AqueductChore(override val seat: Int) : Chore
|
||||
|
||||
/** A knight was driven from its place; move it, or lose it. [knight] stood on [Knight.vertex]. */
|
||||
@Serializable
|
||||
@SerialName("c_relocate")
|
||||
data class RelocateChore(override val seat: Int, val knight: Knight) : Chore
|
||||
|
||||
/** A deserter was played against this seat: remove one of your knights. */
|
||||
@Serializable
|
||||
@SerialName("c_removeKnight")
|
||||
data class RemoveKnightChore(override val seat: Int, val by: Int) : Chore
|
||||
|
||||
/** Place a knight of [level] on the board for free, active or not as given. */
|
||||
@Serializable
|
||||
@SerialName("c_placeKnight")
|
||||
data class PlaceKnightChore(override val seat: Int, val level: Int, val active: Boolean) : Chore
|
||||
|
||||
/** Choose one of [target]'s progress cards to take. */
|
||||
@Serializable
|
||||
@SerialName("c_spy")
|
||||
data class SpyChore(override val seat: Int, val target: Int) : Chore
|
||||
|
||||
/** Promote up to [left] more knights for free. */
|
||||
@Serializable
|
||||
@SerialName("c_smith")
|
||||
data class SmithChore(override val seat: Int, val left: Int) : Chore
|
||||
|
||||
/**
|
||||
* Everything Cities & Knights adds to the table.
|
||||
*
|
||||
* Counts that are public ([progressCounts], the knights, the improvement
|
||||
* levels) are stored directly; the decks and the other seats' progress hands are
|
||||
* here in full because the host owns the truth, and [CatanRules.viewFor] keeps
|
||||
* them off the wire.
|
||||
*/
|
||||
@Serializable
|
||||
data class CkState(
|
||||
/** Commodity cards per seat. */
|
||||
val commodities: List<Map<Commodity, Int>>,
|
||||
/** Improvement level (0..5) per seat and track. */
|
||||
val improvements: List<Map<Track, Int>>,
|
||||
/** The metropolis of each track that has one. */
|
||||
val metropolises: Map<Track, MetropolisAt> = emptyMap(),
|
||||
val knights: List<Knight> = emptyList(),
|
||||
/** Vertices whose city has a wall. */
|
||||
val walls: List<Int> = emptyList(),
|
||||
/** Steps left before the barbarians land, [BARBARIAN_STEPS] at the start. */
|
||||
val barbarians: Int = BARBARIAN_STEPS,
|
||||
/** Whether the barbarians have landed once: until then the robber sleeps. */
|
||||
val attacked: Boolean = false,
|
||||
val eventDie: EventFace? = null,
|
||||
/** Progress decks, top first. Order is hidden. */
|
||||
val decks: Map<Track, List<ProgressCard>>,
|
||||
val progressHands: List<List<ProgressCard>>,
|
||||
/** Public mirror of the progress-hand sizes. */
|
||||
val progressCounts: List<Int>,
|
||||
/** Victory points from Constitution and Printer, played face up at once. */
|
||||
val progressVp: List<Int>,
|
||||
/** Defender of Catan cards won, one point each. */
|
||||
val defenderVp: List<Int>,
|
||||
val merchant: MerchantAt? = null,
|
||||
/** A crane was played this turn and its discount is unspent. */
|
||||
val crane: Boolean = false,
|
||||
/** A merchant fleet was played this turn for this good. */
|
||||
val fleet: Good? = null,
|
||||
/** Dice named by an alchemist, used by the next roll. */
|
||||
val alchemist: List<Int>? = null,
|
||||
/** The robber is being moved by a bishop, which robs everyone nearby. */
|
||||
val bishop: Boolean = false,
|
||||
val chores: List<Chore> = emptyList(),
|
||||
val back: Back = Back.PLAYING,
|
||||
val goodsOffer: GoodsOffer? = null,
|
||||
/** Seats that have already drawn a defender or winners' card this attack, for the log only. */
|
||||
val lastAttack: String? = null,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Moves
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Buy the next level of improvement in [track]. */
|
||||
@Serializable
|
||||
@SerialName("buyImprovement")
|
||||
data class BuyImprovement(val track: Track) : CatanMove
|
||||
|
||||
/** Hire a basic knight onto [vertex], an empty crossing at the end of one of your roads. */
|
||||
@Serializable
|
||||
@SerialName("hireKnight")
|
||||
data class HireKnight(val vertex: Int) : CatanMove
|
||||
|
||||
/** Pay a grain to activate the knight on [vertex]. */
|
||||
@Serializable
|
||||
@SerialName("activateKnight")
|
||||
data class ActivateKnight(val vertex: Int) : CatanMove
|
||||
|
||||
/** Promote the knight on [vertex] one level (paid, or free under a Smith). */
|
||||
@Serializable
|
||||
@SerialName("promoteKnight")
|
||||
data class PromoteKnight(val vertex: Int) : CatanMove
|
||||
|
||||
/** Move the active knight on [from] along your roads to [to], driving off a weaker knight there. */
|
||||
@Serializable
|
||||
@SerialName("moveKnight")
|
||||
data class MoveKnight(val from: Int, val to: Int) : CatanMove
|
||||
|
||||
/** The active knight on [vertex] chases the robber; the robber move follows. */
|
||||
@Serializable
|
||||
@SerialName("chaseRobber")
|
||||
data class ChaseRobber(val vertex: Int) : CatanMove
|
||||
|
||||
/** Raise a wall around the city on [vertex]. */
|
||||
@Serializable
|
||||
@SerialName("buildWall")
|
||||
data class BuildWall(val vertex: Int) : CatanMove
|
||||
|
||||
/**
|
||||
* Play a progress card. Which of the optional arguments are used depends on
|
||||
* the card (see [CatanCk]); the engine's legal list names every combination it
|
||||
* accepts, so a client only ever chooses among them.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("progress")
|
||||
data class PlayProgress(
|
||||
val card: Int,
|
||||
val target: Int? = null,
|
||||
val vertex: Int? = null,
|
||||
val edge: Int? = null,
|
||||
val hexA: Int? = null,
|
||||
val hexB: Int? = null,
|
||||
val good: Good? = null,
|
||||
val dice: List<Int>? = null,
|
||||
val goods: List<Good>? = null,
|
||||
) : CatanMove
|
||||
|
||||
/**
|
||||
* Trade [give] to the bank for [get], at the best ratio this seat has for the
|
||||
* card it gives. Replaces [MaritimeTrade] in Cities & Knights, where
|
||||
* commodities trade too.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("goodsBank")
|
||||
data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove
|
||||
|
||||
/**
|
||||
* Offer a trade in goods to seat [to], or to the whole table when [to] is null.
|
||||
* Free-form like [OfferTrade], so it is checked structurally and is not in the
|
||||
* enumerated move list.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("offerGoods")
|
||||
data class OfferGoods(val to: Int?, val give: List<Good>, val get: List<Good>) : CatanMove
|
||||
|
||||
/**
|
||||
* Answer the goods offer on the table with different terms. Structural, like
|
||||
* [OfferGoods]; the answers to the counters are the shared [AcceptCounter] and
|
||||
* [DismissCounters].
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("counterGoods")
|
||||
data class CounterGoods(val give: List<Good>, val get: List<Good>) : CatanMove
|
||||
|
||||
/**
|
||||
* Give up exactly the cards named, to a [DiscardChore]. Structural, not
|
||||
* enumerated; the legal list carries one example so a seat is never without a move.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("discardGoods")
|
||||
data class DiscardGoods(val cards: List<Good>) : CatanMove
|
||||
|
||||
/** Hand over the cards named to a [GiveChore]. Structural, like [DiscardGoods]. */
|
||||
@Serializable
|
||||
@SerialName("giveGoods")
|
||||
data class GiveGoods(val cards: List<Good>) : CatanMove
|
||||
|
||||
/** Let the city on [vertex] fall to the barbarians. */
|
||||
@Serializable
|
||||
@SerialName("pillageCity")
|
||||
data class PillageCity(val vertex: Int) : CatanMove
|
||||
|
||||
/** Draw from the deck of this track's colour. */
|
||||
@Serializable
|
||||
@SerialName("pickDeck")
|
||||
data class PickDeck(val track: Track) : CatanMove
|
||||
|
||||
/** Give up one progress card from a hand that is over the limit. */
|
||||
@Serializable
|
||||
@SerialName("dropProgress")
|
||||
data class DropProgress(val card: Int) : CatanMove
|
||||
|
||||
/** Put the metropolis on the city at [vertex]. */
|
||||
@Serializable
|
||||
@SerialName("placeMetropolis")
|
||||
data class PlaceMetropolis(val vertex: Int) : CatanMove
|
||||
|
||||
/** Take one [resource] from the bank for an aqueduct. */
|
||||
@Serializable
|
||||
@SerialName("aqueduct")
|
||||
data class TakeAqueduct(val resource: Resource) : CatanMove
|
||||
|
||||
/** Move the knight that was driven off to [to]. */
|
||||
@Serializable
|
||||
@SerialName("relocateKnight")
|
||||
data class RelocateKnight(val to: Int) : CatanMove
|
||||
|
||||
/** Take the knight on [vertex] off the board, for a deserter. */
|
||||
@Serializable
|
||||
@SerialName("removeKnight")
|
||||
data class RemoveKnight(val vertex: Int) : CatanMove
|
||||
|
||||
/** Place the free knight from a deserter on [vertex]. */
|
||||
@Serializable
|
||||
@SerialName("placeKnight")
|
||||
data class PlaceKnight(val vertex: Int) : CatanMove
|
||||
|
||||
/** Take the spied progress card with this id. */
|
||||
@Serializable
|
||||
@SerialName("spyTake")
|
||||
data class SpyTake(val card: Int) : CatanMove
|
||||
|
||||
/** Stop promoting knights under a Smith. */
|
||||
@Serializable
|
||||
@SerialName("endSmith")
|
||||
data object EndSmith : CatanMove
|
||||
@@ -12,6 +12,11 @@ const val LONGEST_ROAD_MIN = 5
|
||||
/** Knights required to take Largest Army. */
|
||||
const val LARGEST_ARMY_MIN = 3
|
||||
|
||||
/** Pieces each player has: a player who has laid them all cannot build another. */
|
||||
const val MAX_ROADS = 15
|
||||
const val MAX_SETTLEMENTS = 5
|
||||
const val MAX_CITIES = 4
|
||||
|
||||
/** Resource cards of each type the bank starts with. */
|
||||
const val BANK_PER_RESOURCE = 19
|
||||
|
||||
@@ -69,17 +74,34 @@ enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTOR
|
||||
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.
|
||||
* A counter-proposal to a [TradeOffer]: seat [by] would [give] these cards to
|
||||
* the offerer if the offerer would hand over [get].
|
||||
*/
|
||||
@Serializable
|
||||
data class TradeOffer(val by: Int, val to: Int, val give: List<Resource>, val get: List<Resource>)
|
||||
data class TradeCounter(val by: Int, val give: List<Resource>, val get: List<Resource>)
|
||||
|
||||
/**
|
||||
* A domestic trade offer on the table. [by] is the offering seat. [to] is the
|
||||
* one seat it is addressed to, or null for an open offer to the whole table.
|
||||
* Seats are asked one at a time, in seat order after the offerer; [asking] is
|
||||
* the seat that may answer now. Each may accept (the trade happens at once),
|
||||
* decline, or counter; counters are kept in [counters] and, if nobody accepts,
|
||||
* the offerer chooses among them in [CatanPhase.TRADE_COUNTERS].
|
||||
*/
|
||||
@Serializable
|
||||
data class TradeOffer(
|
||||
val by: Int,
|
||||
val to: Int?,
|
||||
val give: List<Resource>,
|
||||
val get: List<Resource>,
|
||||
val asking: Int = to ?: -1,
|
||||
val counters: List<TradeCounter> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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].
|
||||
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING] and [CatanPhase.TRADE_COUNTERS].
|
||||
*/
|
||||
@Serializable
|
||||
enum class CatanPhase {
|
||||
@@ -104,16 +126,37 @@ enum class CatanPhase {
|
||||
/** A played Road Building card: up to two roads remain to be placed. */
|
||||
FREE_ROADS,
|
||||
|
||||
/** The offered seat must accept or decline. */
|
||||
/** The [TradeOffer.asking] seat must accept, decline or counter. */
|
||||
TRADE_PENDING,
|
||||
|
||||
/** Nobody accepted but some seats countered: the offerer picks one or none. */
|
||||
TRADE_COUNTERS,
|
||||
|
||||
GAME_OVER,
|
||||
|
||||
/**
|
||||
* Cities & Knights only: the first seat in [CkState.chores] has something
|
||||
* to decide (a discard, a city to give up, a card to draw…) and the game
|
||||
* waits on it. Play resumes where [CkState.back] says.
|
||||
*/
|
||||
CHORE,
|
||||
}
|
||||
|
||||
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
|
||||
@Serializable
|
||||
enum class CatanRuleSet {
|
||||
/** The base game: development cards, Largest Army, a robber from the start. */
|
||||
BASE,
|
||||
|
||||
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
|
||||
CITIES_AND_KNIGHTS,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CatanOptions(
|
||||
val playerCount: Int = 3,
|
||||
val pointsToWin: Int = 10,
|
||||
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||
@@ -185,9 +228,14 @@ data class CatanState(
|
||||
val rngStep: Int,
|
||||
val winner: Int?,
|
||||
val log: List<String>,
|
||||
/** Everything Cities & Knights adds; null in the base game. */
|
||||
val ck: CkState? = null,
|
||||
) {
|
||||
val playerCount: Int get() = options.playerCount
|
||||
|
||||
/** Whether this table plays Cities & Knights. */
|
||||
val isCk: Boolean get() = ck != null
|
||||
|
||||
/** Total resource cards held by [seat]. */
|
||||
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
|
||||
|
||||
@@ -222,7 +270,11 @@ data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("roll")
|
||||
data class RollDice(val forced: List<Int>? = null) : CatanMove
|
||||
data class RollDice(
|
||||
val forced: List<Int>? = null,
|
||||
/** Test-only, like [forced]: the event die of a Cities & Knights roll. */
|
||||
val event: EventFace? = null,
|
||||
) : CatanMove
|
||||
|
||||
/** Discard exactly these cards. */
|
||||
@Serializable
|
||||
@@ -273,7 +325,8 @@ data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
|
||||
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
|
||||
|
||||
/**
|
||||
* Offer a domestic trade to [to]: [give] of ours for [get] of theirs.
|
||||
* Offer a domestic trade to [to] (null: everyone, asked in turn): [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
|
||||
@@ -282,7 +335,7 @@ data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("offer")
|
||||
data class OfferTrade(val to: Int, val give: List<Resource>, val get: List<Resource>) : CatanMove
|
||||
data class OfferTrade(val to: Int?, val give: List<Resource>, val get: List<Resource>) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("accept")
|
||||
@@ -292,6 +345,25 @@ data object AcceptTrade : CatanMove
|
||||
@SerialName("decline")
|
||||
data object DeclineTrade : CatanMove
|
||||
|
||||
/**
|
||||
* Answer the offer on the table with different terms: we would [give] these
|
||||
* cards if the offerer handed over [get]. Free-form like [OfferTrade], so
|
||||
* validated by [CatanRules.isLegalMove] rather than listed.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("counter")
|
||||
data class CounterTrade(val give: List<Resource>, val get: List<Resource>) : CatanMove
|
||||
|
||||
/** The offerer takes the counter-proposal made by [seat]. */
|
||||
@Serializable
|
||||
@SerialName("acceptCounter")
|
||||
data class AcceptCounter(val seat: Int) : CatanMove
|
||||
|
||||
/** The offerer takes none of the counter-proposals. */
|
||||
@Serializable
|
||||
@SerialName("dismissCounters")
|
||||
data object DismissCounters : CatanMove
|
||||
|
||||
/** Bank trade: [give] at the best ratio this seat can trade at for [get]. */
|
||||
@Serializable
|
||||
@SerialName("bank")
|
||||
|
||||
@@ -57,9 +57,16 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
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))
|
||||
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
|
||||
// Cities & Knights has no development cards: knights and progress
|
||||
// cards take their place.
|
||||
val devDeck = if (ck) {
|
||||
emptyList()
|
||||
} else {
|
||||
DEV_DECK
|
||||
.mapIndexed { id, type -> DevCard(id, type) }
|
||||
.shuffled(Random(config.seed + 1))
|
||||
}
|
||||
|
||||
return CatanState(
|
||||
options = options,
|
||||
@@ -91,8 +98,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
rngStep = 0,
|
||||
winner = null,
|
||||
log = listOf(
|
||||
"Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.",
|
||||
"Catan${if (ck) " — Cities & Knights" else ""} — ${options.playerCount} players, " +
|
||||
"first to ${options.pointsToWin} victory points.",
|
||||
),
|
||||
ck = if (ck) CatanCk.initial(options, config.seed) else null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,7 +110,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
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.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
|
||||
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
|
||||
CatanPhase.TRADE_COUNTERS -> state.turn
|
||||
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
|
||||
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
||||
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
||||
-> state.turn
|
||||
@@ -130,12 +142,19 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
|
||||
is OfferTrade -> offerLegal(state, seat, move)
|
||||
|
||||
else -> move in legalMoves(state, seat)
|
||||
is CounterTrade -> counterLegal(state, seat, move)
|
||||
|
||||
else -> if (state.isCk) {
|
||||
CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat))
|
||||
} else {
|
||||
move in legalMoves(state, seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
if (state.isCk) return CatanCk.legalMoves(state, seat)
|
||||
return when (state.phase) {
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
|
||||
|
||||
@@ -152,11 +171,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
|
||||
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
||||
|
||||
CatanPhase.GAME_OVER -> emptyList()
|
||||
CatanPhase.TRADE_COUNTERS -> counterChoices(state)
|
||||
|
||||
CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMoves(state: CatanState): List<SetupPlace> {
|
||||
internal fun setupMoves(state: CatanState): List<SetupPlace> {
|
||||
val moves = mutableListOf<SetupPlace>()
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
if (!vertexOpenForSettlement(state, v)) continue
|
||||
@@ -272,11 +293,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
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) }
|
||||
/**
|
||||
* Road placements that connect to [seat]'s network, cost aside. A player
|
||||
* with all fifteen roads laid has none.
|
||||
*/
|
||||
internal fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
|
||||
if (state.roads.count { it == seat } >= MAX_ROADS) {
|
||||
emptyList()
|
||||
} else {
|
||||
(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
|
||||
@@ -284,18 +312,33 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
* 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 }
|
||||
internal fun settlementVertices(state: CatanState, seat: Int): List<Int> =
|
||||
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
|
||||
emptyList()
|
||||
} else {
|
||||
(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)
|
||||
internal fun cityVertices(state: CatanState, seat: Int): List<Int> =
|
||||
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
|
||||
emptyList()
|
||||
} else {
|
||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
|
||||
private fun hasKnight(state: CatanState, vertex: Int): Boolean =
|
||||
state.ck?.knights?.any { it.vertex == vertex } == true
|
||||
|
||||
/** How many settlements or cities [seat] currently has standing. */
|
||||
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
|
||||
state.buildings.count { it?.owner == seat && it.type == type }
|
||||
|
||||
internal 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)) {
|
||||
@@ -305,23 +348,61 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
return responses
|
||||
}
|
||||
|
||||
/** What the offerer may do with the counters: take one that is still affordable, or none. */
|
||||
internal fun counterChoices(state: CatanState): List<CatanMove> {
|
||||
val offer = state.pendingTrade ?: return emptyList()
|
||||
val moves = mutableListOf<CatanMove>()
|
||||
for (counter in offer.counters) {
|
||||
if (state.holdsAll(counter.by, counter.give) && state.holdsAll(offer.by, counter.get)) {
|
||||
moves += AcceptCounter(counter.by)
|
||||
}
|
||||
}
|
||||
moves += DismissCounters
|
||||
return moves
|
||||
}
|
||||
|
||||
private fun counterLegal(state: CatanState, seat: Int, move: CounterTrade): Boolean {
|
||||
val offer = state.pendingTrade ?: return false
|
||||
if (state.phase != CatanPhase.TRADE_PENDING || offer.asking != seat) return false
|
||||
if (move.give.isEmpty() || move.get.isEmpty()) return false
|
||||
if (countOf(move.give) == countOf(move.get)) return false
|
||||
// Taking the offer as made is Accept, not a counter.
|
||||
if (countOf(move.give) == countOf(offer.get) && countOf(move.get) == countOf(offer.give)) return false
|
||||
return state.holdsAll(seat, move.give) && state.holdsAll(offer.by, move.get)
|
||||
}
|
||||
|
||||
/** An empty vertex with no building on it or one edge away. */
|
||||
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||
state.buildings[v] == null &&
|
||||
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||
state.buildings[v] == null && !hasKnight(state, v) &&
|
||||
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||
|
||||
/** A road on [edge] would join [seat]'s network of roads and buildings. */
|
||||
/**
|
||||
* A road on [edge] would join [seat]'s network of roads and buildings.
|
||||
*
|
||||
* It must start from a crossing the network actually reaches. An opponent's
|
||||
* settlement or city on a crossing breaks the network there: roads on either
|
||||
* side of it do not join, so a road cannot be laid *past* it, even with one
|
||||
* of your own roads ending at the same crossing.
|
||||
*/
|
||||
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
|
||||
return networkReaches(state, seat, a) || networkReaches(state, seat, b)
|
||||
}
|
||||
|
||||
/** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
|
||||
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
|
||||
val building = state.buildings[vertex]
|
||||
// A knight is a wall to everyone but its owner, like a building.
|
||||
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
|
||||
if (knight != null && knight.owner != seat) return false
|
||||
// Your own building is always a way in; anyone else's is a wall.
|
||||
if (building != null) return building.owner == seat
|
||||
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == 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.to != null && (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
|
||||
@@ -353,21 +434,28 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
is OfferTrade -> applyOfferTrade(state, seat, move)
|
||||
AcceptTrade -> applyAcceptTrade(state, seat)
|
||||
DeclineTrade -> applyDeclineTrade(state, seat)
|
||||
is CounterTrade -> applyCounterTrade(state, seat, move)
|
||||
is AcceptCounter -> applyAcceptCounter(state, move)
|
||||
DismissCounters -> applyDismissCounters(state)
|
||||
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
|
||||
EndTurn -> applyEndTurn(state, seat)
|
||||
EndFreeRoads -> applyEndFreeRoads(state, seat)
|
||||
}
|
||||
else -> CatanCk.apply(state, seat, move)
|
||||
}.let { if (it.isCk) CatanCk.settle(it) else it }
|
||||
}
|
||||
|
||||
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
|
||||
val n = state.playerCount
|
||||
val second = state.phase == CatanPhase.SETUP_SECOND
|
||||
// In Cities & Knights the second placement is a city.
|
||||
val first = if (state.isCk && second) BuildingType.CITY else BuildingType.SETTLEMENT
|
||||
var s = state.copy(
|
||||
buildings = state.buildings.toMutableList().also {
|
||||
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||
it[move.vertex] = CatanBuilding(first, 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}.",
|
||||
log = state.log + ("Seat $seat ${if (first == BuildingType.CITY) "raises a city" else "settles"} " +
|
||||
"at ${move.vertex} and lays a road on ${move.edge}."),
|
||||
)
|
||||
|
||||
if (second) {
|
||||
@@ -401,6 +489,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
}
|
||||
|
||||
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
|
||||
if (state.isCk) return CatanCk.applyRoll(state, seat, move)
|
||||
val dice = move.forced ?: derivedDice(state)
|
||||
val total = dice[0] + dice[1]
|
||||
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
|
||||
@@ -475,6 +564,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
|
||||
|
||||
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
|
||||
if (state.isCk) return CatanCk.applyRobber(state, seat, move)
|
||||
val hex = CatanBoard.hexIndex[move.hex]
|
||||
?: throw IllegalArgumentException("No hex at ${move.hex}")
|
||||
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
|
||||
@@ -491,8 +581,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
}
|
||||
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.",
|
||||
log = s.log + ("Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
|
||||
" (${move.hex.q},${move.hex.r})$stolen."),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -504,7 +594,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
if (free) {
|
||||
val left = s.pendingFreeRoads - 1
|
||||
s = s.copy(pendingFreeRoads = left)
|
||||
if (left == 0) s = s.copy(phase = CatanPhase.PLAYING)
|
||||
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
|
||||
}
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
|
||||
}
|
||||
@@ -529,7 +619,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
},
|
||||
)
|
||||
s = payCost(s, seat, CITY_COST)
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}."))
|
||||
s = s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.")
|
||||
// A city can be what a waiting metropolis needed.
|
||||
if (s.isCk) s = CatanCk.afterCityBuilt(s, seat)
|
||||
return checkWin(s)
|
||||
}
|
||||
|
||||
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
|
||||
@@ -602,33 +695,117 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState {
|
||||
val audience = if (move.to == null) "the table" else "seat ${move.to}"
|
||||
val offered = state.copy(
|
||||
log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " +
|
||||
"${describe(countOf(move.get))} to seat ${move.to}.",
|
||||
"${describe(countOf(move.get))} to $audience.",
|
||||
)
|
||||
val offer = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get)
|
||||
val first = nextAsker(offered, offer, seat)
|
||||
?: return offered.copy(log = offered.log + "Nobody has any cards to trade.")
|
||||
return offered.copy(
|
||||
phase = CatanPhase.TRADE_PENDING,
|
||||
pendingTrade = offer.copy(asking = first),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
/**
|
||||
* The next seat to ask after [after], or null when the round is over. An
|
||||
* addressed offer asks only its addressee; an open one walks the table in
|
||||
* seat order, skipping seats with no cards (they could neither pay nor counter).
|
||||
*/
|
||||
private fun nextAsker(state: CatanState, offer: TradeOffer, after: Int): Int? {
|
||||
if (offer.to != null) return if (after == offer.by) offer.to else null
|
||||
var s = (after + 1) % state.playerCount
|
||||
while (s != offer.by) {
|
||||
if (state.resourceCount(s) > 0) return s
|
||||
s = (s + 1) % state.playerCount
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Moves the offer on to the next seat, or closes the round when everyone has answered. */
|
||||
private fun advanceTrade(state: CatanState, offer: TradeOffer, after: Int, said: String): CatanState {
|
||||
val next = nextAsker(state, offer, after)
|
||||
val log = state.log + said
|
||||
if (next != null) {
|
||||
return state.copy(pendingTrade = offer.copy(asking = next), log = log)
|
||||
}
|
||||
if (offer.counters.isNotEmpty()) {
|
||||
return state.copy(
|
||||
phase = CatanPhase.TRADE_COUNTERS,
|
||||
pendingTrade = offer.copy(asking = -1),
|
||||
log = log,
|
||||
)
|
||||
}
|
||||
return state.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
pendingTrade = null,
|
||||
resources = resources,
|
||||
log = log + "Nobody takes the offer.",
|
||||
)
|
||||
}
|
||||
|
||||
/** Seat [a] hands [aGives] to seat [b] and gets [bGives] back. */
|
||||
private fun swapCards(
|
||||
resources: List<Map<Resource, Int>>,
|
||||
a: Int,
|
||||
aGives: List<Resource>,
|
||||
b: Int,
|
||||
bGives: List<Resource>,
|
||||
): List<Map<Resource, Int>> {
|
||||
var r = withCards(resources, a, aGives, -1)
|
||||
r = withCards(r, b, bGives, -1)
|
||||
r = withCards(r, b, aGives, 1)
|
||||
return withCards(r, a, bGives, 1)
|
||||
}
|
||||
|
||||
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
|
||||
if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true)
|
||||
val offer = state.pendingTrade!!
|
||||
return state.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
pendingTrade = null,
|
||||
resources = swapCards(state.resources, offer.by, offer.give, seat, offer.get),
|
||||
log = state.log + "Seat $seat accepts — the cards cross the table.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
|
||||
state.copy(
|
||||
if (state.ck?.goodsOffer != null) {
|
||||
CatanCk.answerOffer(state, seat, accept = false)
|
||||
} else {
|
||||
advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.")
|
||||
}
|
||||
|
||||
private fun applyCounterTrade(state: CatanState, seat: Int, move: CounterTrade): CatanState {
|
||||
val offer = state.pendingTrade!!
|
||||
val counter = TradeCounter(by = seat, give = move.give, get = move.get)
|
||||
return advanceTrade(
|
||||
state,
|
||||
offer.copy(counters = offer.counters + counter),
|
||||
seat,
|
||||
"Seat $seat counters: ${describe(countOf(move.give))} for ${describe(countOf(move.get))}.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyAcceptCounter(state: CatanState, move: AcceptCounter): CatanState {
|
||||
if (state.ck?.goodsOffer != null) return CatanCk.acceptCounter(state, move)
|
||||
val offer = state.pendingTrade!!
|
||||
val counter = offer.counters.first { it.by == move.seat }
|
||||
return state.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
pendingTrade = null,
|
||||
log = state.log + "Seat $seat declines the offer.",
|
||||
resources = swapCards(state.resources, offer.by, counter.get, counter.by, counter.give),
|
||||
log = state.log + "Seat ${offer.by} takes seat ${counter.by}'s counter — the cards cross the table.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyDismissCounters(state: CatanState): CatanState =
|
||||
if (state.ck?.goodsOffer != null) CatanCk.dismissCounters(state) else state.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
pendingTrade = null,
|
||||
log = state.log + "Seat ${state.turn} turns the counter-offers down.",
|
||||
)
|
||||
|
||||
private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState {
|
||||
@@ -637,8 +814,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
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.",
|
||||
log = s.log + ("Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
|
||||
"${move.get.name.lowercase()} via $via."),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -648,7 +825,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
if (settled.phase == CatanPhase.GAME_OVER) return settled
|
||||
|
||||
val next = (state.turn + 1) % state.playerCount
|
||||
val s = state.copy(
|
||||
val s = (if (state.isCk) CatanCk.endTurn(state) else state).copy(
|
||||
turn = next,
|
||||
turnNumber = state.turnNumber + 1,
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
@@ -661,9 +838,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
return checkWin(s)
|
||||
}
|
||||
|
||||
/** Where play goes after free roads: a card played before the roll returns to the roll. */
|
||||
private fun phaseAfterRoads(state: CatanState): CatanPhase =
|
||||
if (state.ck?.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING
|
||||
|
||||
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
|
||||
state.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
phase = phaseAfterRoads(state),
|
||||
pendingFreeRoads = 0,
|
||||
log = state.log + "Seat $seat forfeits the remaining free road.",
|
||||
)
|
||||
@@ -679,6 +860,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
* view or the host's copy.
|
||||
*/
|
||||
fun publicVictoryPoints(state: CatanState): List<Int> {
|
||||
if (state.isCk) return CatanCk.publicVictoryPoints(state)
|
||||
val points = IntArray(state.playerCount)
|
||||
state.buildings.forEach { building ->
|
||||
if (building != null) {
|
||||
@@ -694,7 +876,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
publicVictoryPoints(state)[seat] +
|
||||
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
||||
|
||||
private fun checkWin(state: CatanState): CatanState {
|
||||
internal fun checkWin(state: CatanState): CatanState {
|
||||
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||
val seat = state.turn
|
||||
val points = victoryPoints(state, seat)
|
||||
@@ -715,7 +897,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
* 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 {
|
||||
internal fun refreshLongestRoad(state: CatanState): CatanState {
|
||||
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
|
||||
val holder = state.longestRoadOwner
|
||||
val best = lengths
|
||||
@@ -751,6 +933,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val building = state.buildings[v]
|
||||
if (building != null && building.owner != seat) blocked[v] = true
|
||||
}
|
||||
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = 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)
|
||||
@@ -812,7 +995,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
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 =
|
||||
internal 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 =
|
||||
@@ -863,6 +1046,16 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
return best
|
||||
}
|
||||
|
||||
/** 3 if [seat] has a settlement on a generic harbour, else the bank's 4. */
|
||||
fun genericPortRatio(state: CatanState, seat: Int): Int {
|
||||
for (port in state.ports) {
|
||||
if (port.resource != null) continue
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
|
||||
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Results, redaction and wire format
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -877,12 +1070,15 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
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 viewFor(state: CatanState, seat: Int): CatanState = CatanCk.viewFor(
|
||||
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()
|
||||
},
|
||||
),
|
||||
seat,
|
||||
)
|
||||
|
||||
override fun encodeState(state: CatanState): String = json.encodeToString(state)
|
||||
|
||||
@@ -308,6 +308,6 @@ class LanTransport(private val context: Context) : Transport {
|
||||
}
|
||||
|
||||
const val CONNECT_TIMEOUT_MS = 8_000
|
||||
const val WIFI_LOCK_TAG = "PilgrimsOfKatan:lan"
|
||||
const val WIFI_LOCK_TAG = "ProLibertateGames:lan"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const val PURPOSE_SCOREKEEPER = "scorekeeper"
|
||||
|
||||
/** Service type and name advertised over mDNS. */
|
||||
const val SERVICE_TYPE = "_plgames._tcp"
|
||||
const val SERVICE_NAME = "PilgrimsOfKatan"
|
||||
const val SERVICE_NAME = "ProLibertateGames"
|
||||
|
||||
val protocolJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
|
||||
@@ -364,7 +364,11 @@ fun SectionScreen(
|
||||
}
|
||||
}
|
||||
items(games) { game ->
|
||||
GameTile(game = game, onClick = { onPickGame(game) })
|
||||
GameTile(
|
||||
game = game,
|
||||
playable = game.playableIn(devBuild = showComingSoon),
|
||||
onClick = { onPickGame(game) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,12 +407,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
||||
private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
enabled = game.available,
|
||||
enabled = playable,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (game.available) {
|
||||
containerColor = if (playable) {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
@@ -427,13 +431,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
val players = when {
|
||||
// A puzzle for one, rather than "1 players".
|
||||
game.maxPlayers == 1 -> "On your own"
|
||||
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
||||
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
||||
}
|
||||
Text(
|
||||
text = when {
|
||||
!game.available -> "Coming soon"
|
||||
// A puzzle for one, rather than "1 players".
|
||||
game.maxPlayers == 1 -> "On your own"
|
||||
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
||||
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
||||
!playable -> "Coming soon"
|
||||
// Playable on this dev build only; production hides it.
|
||||
!game.available -> "Preview · $players"
|
||||
else -> players
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
|
||||
@@ -57,6 +57,8 @@ import org.prolibertate.games.game.morris.MorrisOptions
|
||||
import org.prolibertate.games.game.pirates.PiratesLevel
|
||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
import org.prolibertate.games.game.tayu.TayuOptions
|
||||
@@ -104,6 +106,7 @@ fun GameSetupScreen(
|
||||
var freecell by remember { mutableStateOf(FreeCellOptions()) }
|
||||
var spider by remember { mutableStateOf(SpiderOptions()) }
|
||||
var pyramid by remember { mutableStateOf(PyramidOptions()) }
|
||||
var catan by remember { mutableStateOf(CatanOptions()) }
|
||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||
// always White, so choosing Black means sitting in seat 1.
|
||||
var playWhite by remember { mutableStateOf(true) }
|
||||
@@ -134,6 +137,7 @@ fun GameSetupScreen(
|
||||
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
|
||||
GameCatalog.SPIDER -> setupJson.encodeToString(spider)
|
||||
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
|
||||
GameCatalog.CATAN -> setupJson.encodeToString(catan)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
@@ -199,6 +203,8 @@ fun GameSetupScreen(
|
||||
|
||||
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
|
||||
|
||||
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
|
||||
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -303,6 +309,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
GameCatalog.TAYU ->
|
||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CATAN ->
|
||||
setupJson.decodeFromString<CatanOptions>(optionsJson).playerCount
|
||||
|
||||
else -> fallback
|
||||
}
|
||||
}.getOrDefault(fallback)
|
||||
@@ -326,6 +335,72 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
|
||||
else -> seat
|
||||
}
|
||||
|
||||
/** The targets the setup screen offers for a Katan rule set. */
|
||||
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) {
|
||||
// Five is a short game for trying things out; ten is the published target.
|
||||
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
|
||||
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
|
||||
}
|
||||
|
||||
/** The published target for a rule set. */
|
||||
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
|
||||
CatanRuleSet.BASE -> 10
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] switched to [ruleSet]. The target moves to the new rule set's own
|
||||
* default unless the one already chosen is on its menu too.
|
||||
*/
|
||||
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions =
|
||||
options.copy(
|
||||
ruleSet = ruleSet,
|
||||
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) }
|
||||
?: catanDefaultPoints(ruleSet),
|
||||
)
|
||||
|
||||
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||
CatanRuleSet.BASE -> "Base game"
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||
}
|
||||
|
||||
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||
CatanRuleSet.BASE ->
|
||||
"Settle the island, trade, and play development cards. Longest Road and Largest Army score."
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS ->
|
||||
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
|
||||
"cards and metropolises. No development cards; the robber sleeps until the first attack."
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Rules",
|
||||
values = CatanRuleSet.entries,
|
||||
selected = options.ruleSet,
|
||||
display = ::catanRuleSetName,
|
||||
onSelect = { onChange(catanSwitchRules(options, it)) },
|
||||
)
|
||||
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall)
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(3, 4),
|
||||
selected = options.playerCount,
|
||||
display = { "$it players" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Game to",
|
||||
values = catanPointsChoices(options.ruleSet),
|
||||
selected = options.pointsToWin,
|
||||
display = { "$it points" },
|
||||
onSelect = { onChange(options.copy(pointsToWin = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
@@ -49,7 +49,7 @@ fun LaunchUpdateCheck(env: AppEnv, visible: Boolean) {
|
||||
}
|
||||
|
||||
// Held back behind the splash, so an update prompt cannot appear over the
|
||||
// splash before the app itself has.
|
||||
// tartan before the app itself has.
|
||||
val pending = release
|
||||
if (pending == null || dismissed || !visible) return
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
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.chess.ChessAi
|
||||
import org.prolibertate.games.game.chess.ChessMove
|
||||
import org.prolibertate.games.game.chess.ChessRules
|
||||
@@ -110,6 +114,7 @@ import org.prolibertate.games.game.wizard.WizardState
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.ui.game.BackgammonScreen
|
||||
import org.prolibertate.games.ui.game.catan.CatanScreen
|
||||
import org.prolibertate.games.ui.game.CheckersScreen
|
||||
import org.prolibertate.games.ui.game.ChessScreen
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
@@ -668,6 +673,30 @@ fun PlayScreen(
|
||||
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,
|
||||
// A computer's turn is several small moves (roll, build,
|
||||
// trade, end), so the pause is per move, not per turn.
|
||||
aiThinkingMillis = { settings.scaled(450L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
CatanScreen(
|
||||
controller = controller,
|
||||
localSeat = route.localSeat,
|
||||
seatNames = route.config.seats.sortedBy { it.seat }.map { it.name },
|
||||
onExit = onExit,
|
||||
)
|
||||
}
|
||||
|
||||
else -> Text("That game isn't playable yet.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
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.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.prolibertate.games.game.catan.BuildingType
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.NO_PLAYER
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
|
||||
/**
|
||||
* The island, drawn and tappable.
|
||||
*
|
||||
* A stateless view: it is handed the state, the parts of the board that are
|
||||
* currently tappable ([targets]) and the one setup settlement that is waiting
|
||||
* for its road ([selectedVertex]), and it reports taps. It knows no rules; what
|
||||
* a tap *means* is decided by [CatanInteraction]. Where things are is decided by
|
||||
* [BoardLayout], and what they look like by [CatanStyle], so this file is only
|
||||
* the glue between the three.
|
||||
*
|
||||
* To change how a piece is drawn, edit the matching `draw…` function below. To
|
||||
* change a colour or a proportion, edit [CatanStyle] instead.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanBoardView(
|
||||
state: CatanState,
|
||||
targets: BoardTargets,
|
||||
selectedVertex: Int?,
|
||||
onVertexTap: (Int) -> Unit,
|
||||
onEdgeTap: (Int) -> Unit,
|
||||
onHexTap: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
/** Hexes to light up as having produced on the latest roll. */
|
||||
rolledHexes: Set<Int> = emptySet(),
|
||||
/** Cities & Knights: the knight whose menu is open or that is about to move. */
|
||||
selectedKnight: Int? = null,
|
||||
) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val layout = remember(size) {
|
||||
if (size.width > 0 && size.height > 0) {
|
||||
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Held in State so the gesture detector, which is only restarted when the
|
||||
// layout or targets change, always calls the latest callbacks.
|
||||
val vertexTap by rememberUpdatedState(onVertexTap)
|
||||
val edgeTap by rememberUpdatedState(onEdgeTap)
|
||||
val hexTap by rememberUpdatedState(onHexTap)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.aspectRatio(CatanGeometry.ASPECT)
|
||||
.onSizeChanged { size = it }
|
||||
.semantics { contentDescription = "Katan island board" }
|
||||
.pointerInput(layout, targets, style) {
|
||||
detectTapGestures { tap ->
|
||||
val board = layout ?: return@detectTapGestures
|
||||
val at = Pt(tap.x, tap.y)
|
||||
val reach = board.radius * style.tapReach
|
||||
when {
|
||||
targets.tappableVertices.isNotEmpty() ->
|
||||
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
|
||||
|
||||
targets.edges.isNotEmpty() ->
|
||||
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
|
||||
|
||||
targets.hexes.isNotEmpty() ->
|
||||
// A hex is big: accept a tap anywhere near its middle.
|
||||
board.nearestHex(at, board.radius * 0.95f, targets.hexes)?.let { hexTap(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
val board = layout ?: return@Canvas
|
||||
drawRect(color = style.sea)
|
||||
drawPorts(state, board, style, measurer)
|
||||
drawHexes(state, board, style, measurer)
|
||||
drawRolled(rolledHexes, board, style)
|
||||
drawRobber(state, board, style)
|
||||
drawMerchant(state, board, style, measurer)
|
||||
drawRoads(state, board, style)
|
||||
drawEdgeTargets(targets, board, style)
|
||||
drawBuildings(state, board, style)
|
||||
drawWalls(state, board, style)
|
||||
drawMetropolises(state, board, style)
|
||||
drawKnights(state, board, style)
|
||||
drawKnightTargets(targets, board, style)
|
||||
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
|
||||
drawHexTargets(targets, board, style)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terrain and tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawHexes(
|
||||
state: CatanState,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
measurer: TextMeasurer,
|
||||
) {
|
||||
for (hex in 0 until CatanBoard.HEX_COUNT) {
|
||||
val centre = board.hexCenter(hex)
|
||||
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
|
||||
val path = polygon(corners)
|
||||
drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
|
||||
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
|
||||
|
||||
val number = state.numbers[hex]
|
||||
if (number != 0) drawToken(number, centre, board, style, measurer)
|
||||
}
|
||||
}
|
||||
|
||||
/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */
|
||||
private fun DrawScope.drawRolled(hexes: Set<Int>, board: BoardLayout, style: CatanStyle) {
|
||||
for (hex in hexes) {
|
||||
val centre = board.hexCenter(hex)
|
||||
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) })
|
||||
drawPath(path, color = style.rolled.copy(alpha = 0.28f))
|
||||
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f))
|
||||
drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawToken(
|
||||
number: Int,
|
||||
at: Pt,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
measurer: TextMeasurer,
|
||||
) {
|
||||
val radius = board.radius * style.tokenRadius
|
||||
drawCircle(color = style.token, radius = radius, center = at.offset())
|
||||
drawCircle(
|
||||
color = style.hexEdge,
|
||||
radius = radius,
|
||||
center = at.offset(),
|
||||
style = Stroke(width = board.radius * 0.02f),
|
||||
)
|
||||
val hot = number == 6 || number == 8
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = number.toString(),
|
||||
at = Pt(at.x, at.y - radius * 0.12f),
|
||||
fontPx = board.radius * style.tokenText,
|
||||
color = if (hot) style.tokenHotInk else style.tokenInk,
|
||||
weight = if (hot) FontWeight.ExtraBold else FontWeight.Bold,
|
||||
)
|
||||
// One pip per thirty-sixth of the odds, so the likelier numbers read at a glance.
|
||||
val pips = CatanBoard.numberOdds(number)
|
||||
val pipRadius = radius * 0.075f
|
||||
val gap = pipRadius * 2.6f
|
||||
val startX = at.x - gap * (pips - 1) / 2f
|
||||
for (i in 0 until pips) {
|
||||
drawCircle(
|
||||
color = if (hot) style.tokenHotInk else style.tokenInk,
|
||||
radius = pipRadius,
|
||||
center = Offset(startX + i * gap, at.y + radius * 0.52f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
// In Cities & Knights the robber sleeps, drawn faintly, until the barbarians first land.
|
||||
val asleep = state.ck?.attacked == false
|
||||
val tint = if (asleep) style.robber.copy(alpha = 0.3f) else style.robber
|
||||
val rim = Color.White.copy(alpha = if (asleep) 0.25f else 0.7f)
|
||||
val hex = board.hexCenter(state.robber)
|
||||
// Beside the number token rather than on it, so the number stays readable.
|
||||
val x = hex.x + board.radius * 0.5f
|
||||
val y = hex.y + board.radius * 0.3f
|
||||
val s = board.radius * style.robberSize
|
||||
val pawn = Path().apply {
|
||||
moveTo(x - s * 0.55f, y + s * 0.9f)
|
||||
lineTo(x - s * 0.25f, y - s * 0.15f)
|
||||
lineTo(x + s * 0.25f, y - s * 0.15f)
|
||||
lineTo(x + s * 0.55f, y + s * 0.9f)
|
||||
close()
|
||||
}
|
||||
drawPath(pawn, color = tint)
|
||||
drawCircle(color = tint, radius = s * 0.38f, center = Offset(x, y - s * 0.42f))
|
||||
drawPath(pawn, color = rim, style = Stroke(width = s * 0.07f))
|
||||
drawCircle(
|
||||
color = rim,
|
||||
radius = s * 0.38f,
|
||||
center = Offset(x, y - s * 0.42f),
|
||||
style = Stroke(width = s * 0.07f),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harbours
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawPorts(
|
||||
state: CatanState,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
measurer: TextMeasurer,
|
||||
) {
|
||||
for (port in state.ports) {
|
||||
val anchor = board.portAnchor(port.edge)
|
||||
val (a, b) = board.edgeEnds(port.edge)
|
||||
val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk
|
||||
// Two piers from the marker to the vertices that can use the harbour.
|
||||
for (end in listOf(a, b)) {
|
||||
drawLine(
|
||||
color = style.portMarker.copy(alpha = 0.9f),
|
||||
start = anchor.offset(),
|
||||
end = end.offset(),
|
||||
strokeWidth = board.radius * 0.07f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
val radius = board.radius * style.portRadius
|
||||
drawCircle(color = style.portMarker, radius = radius, center = anchor.offset())
|
||||
drawCircle(
|
||||
color = tint,
|
||||
radius = radius,
|
||||
center = anchor.offset(),
|
||||
style = Stroke(width = board.radius * 0.07f),
|
||||
)
|
||||
// What the harbour trades: a picture of the resource, or "?" for any.
|
||||
val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f)
|
||||
val iconSize = radius * 0.5f
|
||||
val resource = port.resource
|
||||
if (resource != null) {
|
||||
drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline)
|
||||
} else {
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = "?",
|
||||
at = iconAt,
|
||||
fontPx = board.radius * style.portText * 1.5f,
|
||||
color = style.portInk,
|
||||
weight = FontWeight.ExtraBold,
|
||||
)
|
||||
}
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = "${port.ratio}:1",
|
||||
at = Pt(anchor.x, anchor.y + radius * 0.5f),
|
||||
fontPx = board.radius * style.portText,
|
||||
color = style.portInk,
|
||||
weight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small pictogram of [resource] centred on [at], about 2·[s] across: a tree,
|
||||
* bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no
|
||||
* image assets to ship or scale.
|
||||
*/
|
||||
private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) {
|
||||
val outline = Stroke(width = s * 0.12f)
|
||||
when (resource) {
|
||||
Resource.LUMBER -> {
|
||||
val crown = Path().apply {
|
||||
moveTo(at.x, at.y - s)
|
||||
lineTo(at.x + s * 0.8f, at.y + s * 0.3f)
|
||||
lineTo(at.x - s * 0.8f, at.y + s * 0.3f)
|
||||
close()
|
||||
}
|
||||
drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f))
|
||||
drawPath(crown, color = fill)
|
||||
drawPath(crown, color = line, style = outline)
|
||||
}
|
||||
|
||||
Resource.BRICK -> {
|
||||
val w = s * 0.9f
|
||||
val h = s * 0.42f
|
||||
for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) {
|
||||
val top = at.y - h + row * h
|
||||
val left = at.x - w + shift - w * 0.25f
|
||||
for (col in 0..1) {
|
||||
val x = left + col * w
|
||||
drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h))
|
||||
drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Resource.WOOL -> {
|
||||
for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) {
|
||||
drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s))
|
||||
}
|
||||
drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f))
|
||||
drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
|
||||
drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
|
||||
}
|
||||
|
||||
Resource.GRAIN -> {
|
||||
drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f)
|
||||
for (i in 0..2) {
|
||||
val y = at.y - s * 0.7f + i * s * 0.5f
|
||||
for (side in listOf(-1f, 1f)) {
|
||||
val kernel = Path().apply {
|
||||
moveTo(at.x, y + s * 0.3f)
|
||||
lineTo(at.x + side * s * 0.6f, y - s * 0.05f)
|
||||
lineTo(at.x + side * s * 0.15f, y - s * 0.2f)
|
||||
close()
|
||||
}
|
||||
drawPath(kernel, color = fill)
|
||||
drawPath(kernel, color = line, style = Stroke(width = s * 0.08f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Resource.ORE -> {
|
||||
val peak = Path().apply {
|
||||
moveTo(at.x - s, at.y + s * 0.8f)
|
||||
lineTo(at.x - s * 0.3f, at.y - s * 0.6f)
|
||||
lineTo(at.x + s * 0.1f, at.y + s * 0.05f)
|
||||
lineTo(at.x + s * 0.45f, at.y - s * 0.3f)
|
||||
lineTo(at.x + s, at.y + s * 0.8f)
|
||||
close()
|
||||
}
|
||||
drawPath(peak, color = fill)
|
||||
drawPath(peak, color = line, style = outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
for (edge in 0 until CatanBoard.EDGE_COUNT) {
|
||||
val owner = state.roads[edge]
|
||||
if (owner == NO_PLAYER) continue
|
||||
val (a, b) = board.edgeEnds(edge)
|
||||
// Pulled in from the ends so adjacent roads do not fuse into a blob.
|
||||
val from = lerp(a, b, 0.12f)
|
||||
val to = lerp(a, b, 0.88f)
|
||||
val width = board.radius * style.roadWidth
|
||||
drawLine(
|
||||
color = style.pieceOutline,
|
||||
start = from.offset(),
|
||||
end = to.offset(),
|
||||
strokeWidth = width * 1.45f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
drawLine(
|
||||
color = style.seats[owner % style.seats.size],
|
||||
start = from.offset(),
|
||||
end = to.offset(),
|
||||
strokeWidth = width,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
val building = state.buildings[vertex] ?: continue
|
||||
val at = board.vertex(vertex)
|
||||
val fill = style.seats[building.owner % style.seats.size]
|
||||
val path = when (building.type) {
|
||||
BuildingType.SETTLEMENT -> housePath(at, board.radius * style.settlementSize)
|
||||
BuildingType.CITY -> cityPath(at, board.radius * style.citySize * 0.8f)
|
||||
}
|
||||
drawPath(path, color = fill)
|
||||
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.045f))
|
||||
}
|
||||
}
|
||||
|
||||
/** One gable: a settlement. [s] is its half-width. */
|
||||
private fun housePath(at: Pt, s: Float): Path = Path().apply {
|
||||
moveTo(at.x - s, at.y + s * 0.7f)
|
||||
lineTo(at.x - s, at.y - s * 0.1f)
|
||||
lineTo(at.x, at.y - s)
|
||||
lineTo(at.x + s, at.y - s * 0.1f)
|
||||
lineTo(at.x + s, at.y + s * 0.7f)
|
||||
close()
|
||||
}
|
||||
|
||||
/** Two gables, the taller on the left: a city. [s] is the unit its outline is scaled by. */
|
||||
private fun cityPath(at: Pt, s: Float): Path = Path().apply {
|
||||
moveTo(at.x - s, at.y + s * 0.7f)
|
||||
lineTo(at.x - s, at.y - s * 0.5f)
|
||||
lineTo(at.x - s * 0.45f, at.y - s * 1.1f)
|
||||
lineTo(at.x + s * 0.1f, at.y - s * 0.5f)
|
||||
lineTo(at.x + s * 0.1f, at.y - s * 0.1f)
|
||||
lineTo(at.x + s * 0.55f, at.y - s * 0.55f)
|
||||
lineTo(at.x + s * 1.1f, at.y - s * 0.1f)
|
||||
lineTo(at.x + s * 1.1f, at.y + s * 0.7f)
|
||||
close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cities & Knights pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A dashed wall round each walled city, crenellated by the dashes. */
|
||||
private fun DrawScope.drawWalls(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
val walls = state.ck?.walls ?: return
|
||||
for (vertex in walls) {
|
||||
val at = board.vertex(vertex).offset()
|
||||
val radius = board.radius * style.citySize * 1.25f
|
||||
val dash = PathEffect.dashPathEffect(floatArrayOf(radius * 0.38f, radius * 0.22f))
|
||||
drawCircle(
|
||||
color = style.pieceOutline,
|
||||
radius = radius,
|
||||
center = at,
|
||||
style = Stroke(width = board.radius * 0.13f, pathEffect = dash),
|
||||
)
|
||||
drawCircle(
|
||||
color = style.wall,
|
||||
radius = radius,
|
||||
center = at,
|
||||
style = Stroke(width = board.radius * 0.09f, pathEffect = dash),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A pennant in the track's colour flying over a city that carries its metropolis. */
|
||||
private fun DrawScope.drawMetropolises(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
val metropolises = state.ck?.metropolises ?: return
|
||||
for ((track, at) in metropolises) {
|
||||
val base = board.vertex(at.vertex)
|
||||
val unit = board.radius * style.citySize
|
||||
val poleTop = Pt(base.x + unit * 0.9f, base.y - unit * 2.0f)
|
||||
val poleBottom = Pt(base.x + unit * 0.9f, base.y - unit * 0.4f)
|
||||
drawLine(
|
||||
color = style.pieceOutline,
|
||||
start = poleBottom.offset(),
|
||||
end = poleTop.offset(),
|
||||
strokeWidth = board.radius * 0.05f,
|
||||
)
|
||||
val flag = Path().apply {
|
||||
moveTo(poleTop.x, poleTop.y)
|
||||
lineTo(poleTop.x + unit * 1.1f, poleTop.y + unit * 0.35f)
|
||||
lineTo(poleTop.x, poleTop.y + unit * 0.7f)
|
||||
close()
|
||||
}
|
||||
drawPath(flag, color = style.track.getValue(track))
|
||||
drawPath(flag, color = style.pieceOutline, style = Stroke(width = board.radius * 0.035f))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each knight is a shield in its owner's colour, marked with one, two or three
|
||||
* dots for its strength. A sleeping knight is drawn faded; an awake one has a
|
||||
* bright rim.
|
||||
*/
|
||||
private fun DrawScope.drawKnights(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
val knights = state.ck?.knights ?: return
|
||||
for (knight in knights) {
|
||||
val at = board.vertex(knight.vertex)
|
||||
val s = board.radius * style.knightSize
|
||||
val seat = style.seats[knight.owner % style.seats.size]
|
||||
val path = shieldPath(at, s)
|
||||
if (knight.active) {
|
||||
drawPath(path, color = style.knightReady, style = Stroke(width = s * 0.55f))
|
||||
}
|
||||
drawPath(path, color = if (knight.active) seat else seat.copy(alpha = 0.55f))
|
||||
drawPath(path, color = style.pieceOutline, style = Stroke(width = s * 0.16f))
|
||||
val pip = s * 0.14f
|
||||
val gap = pip * 2.8f
|
||||
val startX = at.x - gap * (knight.level - 1) / 2f
|
||||
for (i in 0 until knight.level) {
|
||||
drawCircle(
|
||||
color = style.pieceOutline,
|
||||
radius = pip,
|
||||
center = Offset(startX + i * gap, at.y - s * 0.05f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shieldPath(at: Pt, s: Float): Path = Path().apply {
|
||||
moveTo(at.x - s * 0.8f, at.y - s)
|
||||
lineTo(at.x + s * 0.8f, at.y - s)
|
||||
lineTo(at.x + s * 0.8f, at.y + s * 0.1f)
|
||||
quadraticBezierTo(at.x + s * 0.8f, at.y + s * 0.9f, at.x, at.y + s * 1.25f)
|
||||
quadraticBezierTo(at.x - s * 0.8f, at.y + s * 0.9f, at.x - s * 0.8f, at.y + s * 0.1f)
|
||||
close()
|
||||
}
|
||||
|
||||
/** A ring round each of the player's knights that can do something now. */
|
||||
private fun DrawScope.drawKnightTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||
for (vertex in targets.knights) {
|
||||
drawCircle(
|
||||
color = style.target,
|
||||
radius = board.radius * style.knightSize * 1.9f,
|
||||
center = board.vertex(vertex).offset(),
|
||||
style = Stroke(width = board.radius * 0.07f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The merchant: a coin in its controller's colour, beside the hex it sits on. */
|
||||
private fun DrawScope.drawMerchant(
|
||||
state: CatanState,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
measurer: TextMeasurer,
|
||||
) {
|
||||
val merchant = state.ck?.merchant ?: return
|
||||
val hex = board.hexCenter(merchant.hex)
|
||||
val at = Pt(hex.x - board.radius * 0.5f, hex.y + board.radius * 0.3f)
|
||||
val r = board.radius * 0.2f
|
||||
drawCircle(color = style.seats[merchant.seat % style.seats.size], radius = r, center = at.offset())
|
||||
drawCircle(color = style.pieceOutline, radius = r, center = at.offset(), style = Stroke(width = r * 0.3f))
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = "M",
|
||||
at = at,
|
||||
fontPx = r * 1.3f,
|
||||
color = style.pieceOutline,
|
||||
weight = FontWeight.ExtraBold,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tap targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawVertexTargets(
|
||||
targets: BoardTargets,
|
||||
selected: Int?,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
) {
|
||||
val radius = board.radius * style.targetRadius
|
||||
for (vertex in targets.vertices) {
|
||||
val at = board.vertex(vertex).offset()
|
||||
drawCircle(color = style.target.copy(alpha = 0.85f), radius = radius, center = at)
|
||||
drawCircle(
|
||||
color = style.pieceOutline,
|
||||
radius = radius,
|
||||
center = at,
|
||||
style = Stroke(width = board.radius * 0.04f),
|
||||
)
|
||||
}
|
||||
if (selected != null) {
|
||||
val at = board.vertex(selected).offset()
|
||||
drawCircle(
|
||||
color = style.targetSelected,
|
||||
radius = radius * 1.6f,
|
||||
center = at,
|
||||
style = Stroke(width = board.radius * 0.09f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawEdgeTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||
for (edge in targets.edges) {
|
||||
val (a, b) = board.edgeEnds(edge)
|
||||
val from = lerp(a, b, 0.15f)
|
||||
val to = lerp(a, b, 0.85f)
|
||||
drawLine(
|
||||
color = style.pieceOutline,
|
||||
start = from.offset(),
|
||||
end = to.offset(),
|
||||
strokeWidth = board.radius * style.roadWidth * 1.5f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
drawLine(
|
||||
color = style.target.copy(alpha = 0.9f),
|
||||
start = from.offset(),
|
||||
end = to.offset(),
|
||||
strokeWidth = board.radius * style.roadWidth,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawHexTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||
for (hex in targets.hexes) {
|
||||
val centre = board.hexCenter(hex)
|
||||
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.08f) })
|
||||
drawPath(path, color = style.target.copy(alpha = 0.38f))
|
||||
drawPath(path, color = style.target, style = Stroke(width = board.radius * 0.07f))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun Pt.offset(): Offset = Offset(x, y)
|
||||
|
||||
private fun lerp(a: Pt, b: Pt, t: Float): Pt = Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
|
||||
|
||||
/** [p] moved [fraction] of the way toward [centre]. */
|
||||
private fun insetToward(p: Pt, centre: Pt, fraction: Float): Pt = lerp(p, centre, fraction)
|
||||
|
||||
private fun polygon(points: List<Pt>): Path = Path().apply {
|
||||
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
|
||||
close()
|
||||
}
|
||||
|
||||
private fun DrawScope.drawCentered(
|
||||
measurer: TextMeasurer,
|
||||
text: String,
|
||||
at: Pt,
|
||||
fontPx: Float,
|
||||
color: Color,
|
||||
weight: FontWeight,
|
||||
) {
|
||||
val layout = measurer.measure(
|
||||
text = text,
|
||||
style = TextStyle(fontSize = fontPx.toSp(), fontWeight = weight, color = color),
|
||||
)
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.catan.AcceptCounter
|
||||
import org.prolibertate.games.game.catan.ActivateKnight
|
||||
import org.prolibertate.games.game.catan.BuyImprovement
|
||||
import org.prolibertate.games.game.catan.CatanCk
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.ChaseRobber
|
||||
import org.prolibertate.games.game.catan.CounterGoods
|
||||
import org.prolibertate.games.game.catan.DropProgress
|
||||
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||
import org.prolibertate.games.game.catan.Good
|
||||
import org.prolibertate.games.game.catan.GoodsCounter
|
||||
import org.prolibertate.games.game.catan.GoodsOffer
|
||||
import org.prolibertate.games.game.catan.IMPROVEMENT_MAX
|
||||
import org.prolibertate.games.game.catan.MoveKnight
|
||||
import org.prolibertate.games.game.catan.OfferGoods
|
||||
import org.prolibertate.games.game.catan.PickDeck
|
||||
import org.prolibertate.games.game.catan.PlayProgress
|
||||
import org.prolibertate.games.game.catan.ProgressCard
|
||||
import org.prolibertate.games.game.catan.ProgressType
|
||||
import org.prolibertate.games.game.catan.PromoteKnight
|
||||
import org.prolibertate.games.game.catan.SpyTake
|
||||
import org.prolibertate.games.game.catan.TakeAqueduct
|
||||
import org.prolibertate.games.game.catan.Track
|
||||
|
||||
/*
|
||||
* The Cities & Knights dialogs. Like the base game's, each is a stateless
|
||||
* composable apart from the choice it is helping the player make, and what is
|
||||
* allowed is always asked of the engine's legal list through CatanInteraction.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A card tile with a minus and a plus, for choosing how many of a good. */
|
||||
@Composable
|
||||
private fun GoodCounter(good: Good, count: Int, max: Int, onChange: (Int) -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
GoodTile(good, count, enabled = max > 0 || count > 0)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { onChange(count - 1) },
|
||||
enabled = count > 0,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(34.dp).height(32.dp),
|
||||
) { Text("−") }
|
||||
OutlinedButton(
|
||||
onClick = { onChange(count + 1) },
|
||||
enabled = count < max,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(34.dp).height(32.dp),
|
||||
) { Text("+") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<Good, Int>.asGoods(): List<Good> = Good.entries.flatMap { g -> List(this[g] ?: 0) { g } }
|
||||
|
||||
/** A row of the three track colours, used for decks and improvements. */
|
||||
@Composable
|
||||
private fun TrackChip(track: Track, style: CatanStyle = LocalCatanStyle.current) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(style.track.getValue(track)),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Choosing cards to give up
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pick exactly [quota] cards out of [held] — to discard on a seven or a saboteur,
|
||||
* or to hand over for a wedding or a commercial harbour. Not dismissable: the
|
||||
* game is waiting on it. [allowed] is the engine's structural check, so the
|
||||
* confirm button only lights when the move would be accepted.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanGoodsPickDialog(
|
||||
title: String,
|
||||
intro: String,
|
||||
held: Map<Good, Int>,
|
||||
quota: Int,
|
||||
confirmLabel: String,
|
||||
selectable: (Good) -> Boolean,
|
||||
allowed: (List<Good>) -> Boolean,
|
||||
onConfirm: (List<Good>) -> Unit,
|
||||
) {
|
||||
var picked by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||
val selection = picked.asGoods()
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("$intro (${selection.size} of $quota chosen).")
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Good.entries.forEach { good ->
|
||||
val have = held[good] ?: 0
|
||||
if (have > 0 && selectable(good)) {
|
||||
GoodCounter(
|
||||
good = good,
|
||||
count = picked[good] ?: 0,
|
||||
max = if (selection.size < quota) have else (picked[good] ?: 0),
|
||||
onChange = { n -> picked = picked + (good to n.coerceIn(0, have)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { onConfirm(selection) }, enabled = allowed(selection)) { Text(confirmLabel) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// City improvements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The three improvement tracks: where the player stands, what the next level costs, and what it earns. */
|
||||
@Composable
|
||||
fun CatanImproveDialog(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
legal: List<CatanMove>,
|
||||
onBuy: (BuyImprovement) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val ck = state.ck ?: return
|
||||
val buyable = CatanInteraction.improvable(legal)
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(CatanText.IMPROVE) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Track.entries.forEach { track ->
|
||||
val level = CatanCk.improvement(state, seat, track)
|
||||
val have = ck.commodities[seat][track.commodity] ?: 0
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
TrackChip(track)
|
||||
Text(CatanText.track(track), fontWeight = FontWeight.Bold)
|
||||
Text("level $level of $IMPROVEMENT_MAX", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
Text(CatanText.trackPower(track), style = MaterialTheme.typography.bodySmall)
|
||||
if (level >= IMPROVEMENT_MAX) {
|
||||
Text("Fully built.", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
val cost = CatanCk.improvementCost(state, seat, track)
|
||||
Button(onClick = { onBuy(BuyImprovement(track)) }, enabled = track in buyable) {
|
||||
Text(
|
||||
"${CatanCk.improvementName(track, level + 1)}: $cost " +
|
||||
"${CatanText.good(Good.of(track.commodity))} (you hold $have)",
|
||||
)
|
||||
}
|
||||
if (level + 1 == org.prolibertate.games.game.catan.METROPOLIS_LEVEL ||
|
||||
level + 1 == IMPROVEMENT_MAX
|
||||
) {
|
||||
Text(
|
||||
if (level + 1 == IMPROVEMENT_MAX) {
|
||||
"Level five takes a metropolis from a seat still on four."
|
||||
} else {
|
||||
"Level four earns the first metropolis in this track: two more points."
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The player's progress cards. A card with nothing to decide plays at once; one
|
||||
* that names a seat, a card or dice asks for it in a second step; one that
|
||||
* needs a tap on the board closes the dialog and arms the board (see [ArmedCard]).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanProgressDialog(
|
||||
hand: List<ProgressInHand>,
|
||||
seats: CatanSeats,
|
||||
onPlay: (CatanMove) -> Unit,
|
||||
onArm: (ArmedCard) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var chosen by remember { mutableStateOf<ProgressInHand?>(null) }
|
||||
var target by remember { mutableStateOf<Int?>(null) }
|
||||
var yellow by remember { mutableStateOf(1) }
|
||||
var red by remember { mutableStateOf(1) }
|
||||
val entry = chosen
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(entry?.let { CatanText.progress(it.card.kind) } ?: "Progress cards") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (entry == null) {
|
||||
if (hand.isEmpty()) Text("You hold no progress cards.")
|
||||
hand.forEach { card ->
|
||||
ProgressRow(card) { picked ->
|
||||
val kind = picked.card.kind
|
||||
when {
|
||||
kind in CatanInteraction.BOARD_CARDS -> onArm(ArmedCard(kind, picked.plays))
|
||||
picked.plays.size == 1 && picked.plays[0].isBare() -> onPlay(picked.plays[0])
|
||||
else -> {
|
||||
chosen = picked
|
||||
target = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(CatanText.progressHelp(entry.card.kind), style = MaterialTheme.typography.bodySmall)
|
||||
val kind = entry.card.kind
|
||||
val plays = entry.plays
|
||||
when {
|
||||
kind == ProgressType.ALCHEMIST -> {
|
||||
Text("Production die (yellow)", fontWeight = FontWeight.Bold)
|
||||
DiePicker(yellow) { yellow = it }
|
||||
Text("Progress die (red)", fontWeight = FontWeight.Bold)
|
||||
DiePicker(red) { red = it }
|
||||
}
|
||||
|
||||
kind == ProgressType.DESERTER || kind == ProgressType.SPY -> {
|
||||
Text("Choose a player:", fontWeight = FontWeight.Bold)
|
||||
plays.mapNotNull { it.target }.distinct().forEach { seat ->
|
||||
OutlinedButton(
|
||||
onClick = { plays.firstOrNull { it.target == seat }?.let(onPlay) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(seats.label(seat)) }
|
||||
}
|
||||
}
|
||||
|
||||
kind == ProgressType.MASTER_MERCHANT -> {
|
||||
Text("Choose a player with more points:", fontWeight = FontWeight.Bold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
plays.mapNotNull { it.target }.distinct().forEach { seat ->
|
||||
if (target == seat) {
|
||||
FilledTonalButton(onClick = { target = seat }) { Text(seats.label(seat)) }
|
||||
} else {
|
||||
OutlinedButton(onClick = { target = seat }) { Text(seats.label(seat)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val seat = target
|
||||
if (seat != null) {
|
||||
Text("Take:", fontWeight = FontWeight.Bold)
|
||||
plays.filter { it.target == seat }.forEach { play ->
|
||||
OutlinedButton(
|
||||
onClick = { onPlay(play) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(CatanText.goods(play.goods.orEmpty())) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val label = when (kind) {
|
||||
ProgressType.COMMERCIAL_HARBOR -> "Offer which resource?"
|
||||
ProgressType.MERCHANT_FLEET -> "Trade which card two for one?"
|
||||
ProgressType.RESOURCE_MONOPOLY -> "Name a resource:"
|
||||
else -> "Name a commodity:"
|
||||
}
|
||||
Text(label, fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
plays.mapNotNull { it.good }.distinct().forEach { good ->
|
||||
GoodTile(
|
||||
good = good,
|
||||
count = 0,
|
||||
onClick = { plays.firstOrNull { it.good == good }?.let(onPlay) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (entry != null && entry.card.kind == ProgressType.ALCHEMIST) {
|
||||
val play = entry.plays.firstOrNull { it.dice == listOf(yellow, red) }
|
||||
Button(onClick = { play?.let(onPlay) }, enabled = play != null) { Text("Play") }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (entry == null) {
|
||||
onCancel()
|
||||
} else {
|
||||
chosen = null
|
||||
target = null
|
||||
}
|
||||
},
|
||||
) { Text(if (entry == null) CatanText.CANCEL else "Back") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun PlayProgress.isBare(): Boolean =
|
||||
target == null && vertex == null && edge == null && hexA == null && hexB == null &&
|
||||
good == null && dice == null && goods == null
|
||||
|
||||
@Composable
|
||||
private fun DiePicker(selected: Int, onPick: (Int) -> Unit) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
(1..6).forEach { n ->
|
||||
if (n == selected) {
|
||||
FilledTonalButton(
|
||||
onClick = { onPick(n) },
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.size(40.dp),
|
||||
) { Text("$n") }
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { onPick(n) },
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.size(40.dp),
|
||||
) { Text("$n") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressRow(entry: ProgressInHand, onChoose: (ProgressInHand) -> Unit) {
|
||||
val kind = entry.card.kind
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TrackChip(kind.track)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(CatanText.progress(kind), fontWeight = FontWeight.Bold)
|
||||
Text(CatanText.progressHelp(kind), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (entry.playable) {
|
||||
Button(onClick = { onChoose(entry) }) { Text("Play") }
|
||||
} else {
|
||||
Text("Not now", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** What the knight on one crossing can do: wake, promote, move, chase the robber. */
|
||||
@Composable
|
||||
fun CatanKnightDialog(
|
||||
state: CatanState,
|
||||
vertex: Int,
|
||||
actions: List<CatanMove>,
|
||||
onDo: (CatanMove) -> Unit,
|
||||
onMove: (Int) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(knight?.let { CatanText.knightName(it.level) } ?: "Knight") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (knight != null) {
|
||||
val mood = when {
|
||||
!knight.active -> "Asleep. Waking it costs ${CatanText.ACTIVATE_COST}."
|
||||
knight.activatedTurn == state.turnNumber -> "Awake, but it woke this turn and cannot act yet."
|
||||
else -> "Awake and ready. It counts against the barbarians."
|
||||
}
|
||||
Text(mood, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
actions.forEach { move ->
|
||||
when (move) {
|
||||
is ActivateKnight ->
|
||||
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Wake (${CatanText.ACTIVATE_COST})")
|
||||
}
|
||||
|
||||
is PromoteKnight ->
|
||||
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Promote (${CatanText.KNIGHT_COST})")
|
||||
}
|
||||
|
||||
is MoveKnight ->
|
||||
OutlinedButton(onClick = { onMove(move.from) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Move along your roads")
|
||||
}
|
||||
|
||||
is ChaseRobber ->
|
||||
OutlinedButton(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Chase away the robber")
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
if (actions.isEmpty()) Text("Nothing to do with it right now.")
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chores that are answered in a dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Choose a colour of progress deck to draw from. */
|
||||
@Composable
|
||||
fun CatanPickDeckDialog(legal: List<CatanMove>, onPick: (PickDeck) -> Unit) {
|
||||
val open = legal.filterIsInstance<PickDeck>()
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Draw a progress card") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Catan held. You tied for the strongest defence, so choose any deck.")
|
||||
open.forEach { pick ->
|
||||
Button(onClick = { onPick(pick) }, modifier = Modifier.fillMaxWidth()) {
|
||||
TrackChip(pick.track)
|
||||
Text(" ${CatanText.track(pick.track)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
)
|
||||
}
|
||||
|
||||
/** The hand is over its limit: give one progress card back. */
|
||||
@Composable
|
||||
fun CatanDropProgressDialog(hand: List<ProgressCard>, onDrop: (DropProgress) -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Too many progress cards") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("You may keep four. Choose one to give back.")
|
||||
hand.forEach { card ->
|
||||
OutlinedButton(onClick = { onDrop(DropProgress(card.id)) }, modifier = Modifier.fillMaxWidth()) {
|
||||
TrackChip(card.kind.track)
|
||||
Text(" ${CatanText.progress(card.kind)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
)
|
||||
}
|
||||
|
||||
/** The aqueduct pays: take any one resource. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanAqueductDialog(legal: List<CatanMove>, onTake: (TakeAqueduct) -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Aqueduct") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("The dice paid you nothing. Take any resource.")
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
legal.filterIsInstance<TakeAqueduct>().forEach { take ->
|
||||
ResourceTile(resource = take.resource, count = 1, onClick = { onTake(take) })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
)
|
||||
}
|
||||
|
||||
/** A spy: these are the progress cards the other player holds; take one. */
|
||||
@Composable
|
||||
fun CatanSpyDialog(cards: List<ProgressCard>, legal: List<CatanMove>, onTake: (SpyTake) -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Spy") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Choose a card to take.")
|
||||
cards.forEach { card ->
|
||||
val take = SpyTake(card.id)
|
||||
OutlinedButton(
|
||||
onClick = { onTake(take) },
|
||||
enabled = take in legal,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TrackChip(card.kind.track)
|
||||
Text(" ${CatanText.progress(card.kind)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trading in goods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private enum class GoodsTab { BANK, PLAYER }
|
||||
|
||||
/**
|
||||
* Trade with the bank at the best ratio each card has, or offer cards —
|
||||
* commodities included — to one other player.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanGoodsTradeDialog(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
seats: CatanSeats,
|
||||
legal: List<CatanMove>,
|
||||
onBank: (GoodsBankTrade) -> Unit,
|
||||
onOffer: (OfferGoods) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var tab by remember { mutableStateOf(GoodsTab.BANK) }
|
||||
var bankGive by remember { mutableStateOf<Good?>(null) }
|
||||
var everyone by remember { mutableStateOf(true) }
|
||||
var partner by remember { mutableStateOf<Int?>(null) }
|
||||
var give by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||
var get by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||
val held = CatanCk.goodsOf(state, seat)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(CatanText.TRADE) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
GoodsTabButton(CatanText.TRADE_WITH_BANK, tab == GoodsTab.BANK) { tab = GoodsTab.BANK }
|
||||
GoodsTabButton(CatanText.TRADE_WITH_PLAYER, tab == GoodsTab.PLAYER) { tab = GoodsTab.PLAYER }
|
||||
}
|
||||
if (tab == GoodsTab.BANK) {
|
||||
val trades = CatanInteraction.goodsBankTrades(legal)
|
||||
if (trades.isEmpty()) {
|
||||
Text("You do not hold enough of any one card to trade with the bank.")
|
||||
} else {
|
||||
Text("Give:", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
trades.keys.forEach { good ->
|
||||
val ratio = CatanCk.bankRatio(state, seat, good)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
GoodTile(good, ratio, selected = bankGive == good, onClick = { bankGive = good })
|
||||
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
val chosen = bankGive
|
||||
if (chosen != null && chosen in trades) {
|
||||
Text("Get one:", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
trades.getValue(chosen).forEach { good ->
|
||||
GoodTile(good, 1, onClick = { onBank(GoodsBankTrade(chosen, good)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Offer to:", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GoodsTabButton(CatanText.EVERYONE, everyone) {
|
||||
everyone = true
|
||||
partner = null
|
||||
}
|
||||
(0 until state.playerCount).filter { it != seat }.forEach { other ->
|
||||
GoodsTabButton(seats.label(other), !everyone && partner == other) {
|
||||
everyone = false
|
||||
partner = other
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||
GoodCounters(give, { held[it] ?: 0 }) { give = it }
|
||||
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||
GoodCounters(get, { 9 }) { get = it }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (tab == GoodsTab.PLAYER) {
|
||||
val to = if (everyone) null else partner
|
||||
val giveCards = give.asGoods()
|
||||
val getCards = get.asGoods()
|
||||
val allowed = (everyone || partner != null) &&
|
||||
CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards)
|
||||
Button(
|
||||
onClick = { onOffer(OfferGoods(to, giveCards, getCards)) },
|
||||
enabled = allowed,
|
||||
) { Text(CatanText.SEND_OFFER) }
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun GoodCounters(counts: Map<Good, Int>, limit: (Good) -> Int, onChange: (Map<Good, Int>) -> Unit) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Good.entries.forEach { good ->
|
||||
GoodCounter(
|
||||
good = good,
|
||||
count = counts[good] ?: 0,
|
||||
max = limit(good),
|
||||
onChange = { n -> onChange(counts + (good to n.coerceIn(0, limit(good)))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GoodsTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
if (selected) {
|
||||
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||
} else {
|
||||
OutlinedButton(onClick = onClick) { Text(label) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A goods offer has reached the local player: accept it, decline it, or answer
|
||||
* with different terms. The counter's two sides start as the offer turned around.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanGoodsTradeResponseDialog(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
offer: GoodsOffer,
|
||||
seats: CatanSeats,
|
||||
canAccept: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
onCounter: (CounterGoods) -> Unit,
|
||||
) {
|
||||
var countering by remember(offer) { mutableStateOf(false) }
|
||||
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
|
||||
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to " +
|
||||
"${if (offer.to == null) "the table" else "you"} for ${CatanText.goods(offer.get)}",
|
||||
)
|
||||
if (countering) {
|
||||
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||
GoodCounters(give, { CatanCk.goodsOf(state, seat)[it] ?: 0 }) { give = it }
|
||||
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||
GoodCounters(get, { CatanCk.goodsOf(state, offer.by)[it] ?: 0 }) { get = it }
|
||||
} else if (!canAccept) {
|
||||
Text(
|
||||
"You cannot accept this: one side no longer holds the cards.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (countering) {
|
||||
val move = CounterGoods(give.asGoods(), get.asGoods())
|
||||
Button(
|
||||
onClick = { onCounter(move) },
|
||||
enabled = CatanInteraction.goodsCounterAllowed(state, seat, move.give, move.get),
|
||||
) { Text(CatanText.SEND_COUNTER) }
|
||||
} else {
|
||||
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (countering) {
|
||||
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
|
||||
} else {
|
||||
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
|
||||
}
|
||||
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The offerer's choice once nobody accepted a goods offer: take a counter-offer, or none. */
|
||||
@Composable
|
||||
fun CatanGoodsCounterChoiceDialog(
|
||||
counters: List<GoodsCounter>,
|
||||
seats: CatanSeats,
|
||||
onTake: (AcceptCounter) -> Unit,
|
||||
onNoDeal: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Counter-offers") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (counters.isEmpty()) Text("None of the counter-offers can be met any more.")
|
||||
counters.forEach { counter ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"${seats.label(counter.by)} would give ${CatanText.goods(counter.give)} " +
|
||||
"for ${CatanText.goods(counter.get)}",
|
||||
)
|
||||
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.DevCardType
|
||||
import org.prolibertate.games.game.catan.Discard
|
||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||
import org.prolibertate.games.game.catan.OfferTrade
|
||||
import org.prolibertate.games.game.catan.TradeCounter
|
||||
import org.prolibertate.games.game.catan.CounterTrade
|
||||
import org.prolibertate.games.game.catan.AcceptCounter
|
||||
import org.prolibertate.games.game.catan.PlayKnight
|
||||
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.TradeOffer
|
||||
|
||||
/*
|
||||
* Every Katan dialog. Each is a stateless composable apart from the selection it
|
||||
* is helping the player make (cards picked, a partner chosen); the move it ends
|
||||
* in is reported through a lambda and sent by the screen. What is *allowed* is
|
||||
* always asked of [CatanInteraction], never decided here.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A resource tile with a minus and a plus, for choosing how many cards. */
|
||||
@Composable
|
||||
private fun ResourceCounter(
|
||||
resource: Resource,
|
||||
count: Int,
|
||||
max: Int,
|
||||
onChange: (Int) -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ResourceTile(resource, count, enabled = max > 0 || count > 0)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { onChange(count - 1) },
|
||||
enabled = count > 0,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(34.dp).height(32.dp),
|
||||
) { Text("−") }
|
||||
OutlinedButton(
|
||||
onClick = { onChange(count + 1) },
|
||||
enabled = count < max,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(34.dp).height(32.dp),
|
||||
) { Text("+") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<Resource, Int>.asCards(): List<Resource> =
|
||||
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discarding on a seven
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A seven was rolled and this player holds more than seven cards: choose which
|
||||
* half to give up. Not dismissable — the game is waiting on it.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanDiscardDialog(
|
||||
held: Map<Resource, Int>,
|
||||
quota: Int,
|
||||
legal: List<CatanMove>,
|
||||
onSubmit: (Discard) -> Unit,
|
||||
) {
|
||||
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
val selection = picked.asCards()
|
||||
val move = CatanInteraction.discardMove(legal, selection)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Discard $quota cards") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"A seven was rolled. Choose $quota cards to give up " +
|
||||
"(${selection.size} of $quota chosen).",
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
val have = held[resource] ?: 0
|
||||
if (have > 0) {
|
||||
ResourceCounter(
|
||||
resource = resource,
|
||||
count = picked[resource] ?: 0,
|
||||
max = if (selection.size < quota) have else (picked[resource] ?: 0),
|
||||
onChange = { n -> picked = picked + (resource to n.coerceIn(0, have)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text("Discard") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The robber
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** More than one player can be robbed from this hex: choose whom. */
|
||||
@Composable
|
||||
fun CatanVictimDialog(
|
||||
victims: List<Int>,
|
||||
seats: CatanSeats,
|
||||
onPick: (Int) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Steal from…") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
victims.forEach { seat ->
|
||||
OutlinedButton(onClick = { onPick(seat) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(seats.label(seat))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Development cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The player's development cards. A Knight or Road Building plays at once; a
|
||||
* Monopoly or Year of Plenty asks for its resource choice in a second step.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanPlayCardDialog(
|
||||
hand: List<DevCardInHand>,
|
||||
onPlay: (CatanMove) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var chosen by remember { mutableStateOf<DevCardInHand?>(null) }
|
||||
var picked by remember { mutableStateOf(emptyList<Resource>()) }
|
||||
val card = chosen
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(card?.let { CatanText.devCard(it.card.type) } ?: "Development cards") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
when {
|
||||
card == null -> {
|
||||
if (hand.isEmpty()) Text("You have no development cards.")
|
||||
hand.forEach { entry -> DevCardRow(entry, onPlay) { chosen = it } }
|
||||
}
|
||||
|
||||
card.card.type == DevCardType.MONOPOLY -> {
|
||||
Text("Name a resource. Every other player hands over all of it.")
|
||||
val choices = CatanInteraction.monopolyChoices(card.plays)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
val move = choices[resource]
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = 0,
|
||||
enabled = move != null,
|
||||
onClick = { move?.let(onPlay) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
card.card.type == DevCardType.YEAR_OF_PLENTY -> {
|
||||
Text("Take two resource cards from the bank (${picked.size} of 2 chosen).")
|
||||
val available = CatanInteraction.plentyAvailable(card.plays, picked)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = picked.count { it == resource },
|
||||
selected = resource in picked,
|
||||
enabled = resource in available,
|
||||
onClick = { picked = picked + resource },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (picked.isNotEmpty()) {
|
||||
TextButton(onClick = { picked = emptyList() }) { Text("Clear") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (card != null && card.card.type == DevCardType.YEAR_OF_PLENTY) {
|
||||
val move = CatanInteraction.plentyMove(card.plays, picked)
|
||||
Button(onClick = { move?.let(onPlay) }, enabled = move != null) { Text("Take") }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (card == null) {
|
||||
onCancel()
|
||||
} else {
|
||||
chosen = null
|
||||
picked = emptyList()
|
||||
}
|
||||
},
|
||||
) { Text(if (card == null) CatanText.CANCEL else "Back") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DevCardRow(
|
||||
entry: DevCardInHand,
|
||||
onPlay: (CatanMove) -> Unit,
|
||||
onChoose: (DevCardInHand) -> Unit,
|
||||
) {
|
||||
val type = entry.card.type
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(CatanText.devCard(type), fontWeight = FontWeight.Bold)
|
||||
Text(CatanText.devCardHelp(type), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
when {
|
||||
type == DevCardType.VICTORY_POINT -> Text("Hidden", style = MaterialTheme.typography.labelSmall)
|
||||
!entry.playable -> Text("Not now", style = MaterialTheme.typography.labelSmall)
|
||||
entry.plays.size == 1 && (entry.plays[0] is PlayKnight || entry.plays[0] is PlayRoadBuilding) ->
|
||||
Button(onClick = { onPlay(entry.plays[0]) }) { Text("Play") }
|
||||
|
||||
else -> Button(onClick = { onChoose(entry) }) { Text("Play…") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private enum class TradeTab { BANK, PLAYER }
|
||||
|
||||
/**
|
||||
* Trade with the bank at the best ratio the player has, or offer cards to one
|
||||
* other player. Offers are free-form, so they are checked with the engine's
|
||||
* structural rule ([CatanInteraction.offerAllowed]) rather than looked up in
|
||||
* the legal list.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanTradeDialog(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
seats: CatanSeats,
|
||||
legal: List<CatanMove>,
|
||||
onBank: (MaritimeTrade) -> Unit,
|
||||
onOffer: (OfferTrade) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var tab by remember { mutableStateOf(TradeTab.BANK) }
|
||||
var bankGive by remember { mutableStateOf<Resource?>(null) }
|
||||
var everyone by remember { mutableStateOf(true) }
|
||||
var partner by remember { mutableStateOf<Int?>(null) }
|
||||
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(CatanText.TRADE) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TabButton(CatanText.TRADE_WITH_BANK, tab == TradeTab.BANK) { tab = TradeTab.BANK }
|
||||
TabButton(CatanText.TRADE_WITH_PLAYER, tab == TradeTab.PLAYER) { tab = TradeTab.PLAYER }
|
||||
}
|
||||
|
||||
if (tab == TradeTab.BANK) {
|
||||
val trades = CatanInteraction.bankTrades(legal)
|
||||
if (trades.isEmpty()) {
|
||||
Text("You do not hold enough of any one resource to trade with the bank.")
|
||||
} else {
|
||||
Text("Give:", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
trades.keys.forEach { resource ->
|
||||
val ratio = CatanInteraction.bankRatio(state, seat, resource)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = ratio,
|
||||
selected = bankGive == resource,
|
||||
onClick = { bankGive = resource },
|
||||
)
|
||||
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
val chosen = bankGive
|
||||
if (chosen != null && chosen in trades) {
|
||||
Text("Get one:", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
trades.getValue(chosen).forEach { resource ->
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = 1,
|
||||
onClick = { onBank(MaritimeTrade(chosen, resource)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Offer to:", fontWeight = FontWeight.Bold)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TabButton(CatanText.EVERYONE, everyone) { everyone = true; partner = null }
|
||||
(0 until state.playerCount).filter { it != seat }.forEach { other ->
|
||||
TabButton(seats.label(other), !everyone && partner == other) {
|
||||
everyone = false
|
||||
partner = other
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||
CounterRow(
|
||||
counts = give,
|
||||
limit = { state.resources[seat][it] ?: 0 },
|
||||
onChange = { give = it },
|
||||
)
|
||||
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||
CounterRow(counts = get, limit = { 9 }, onChange = { get = it })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (tab == TradeTab.PLAYER) {
|
||||
val to = if (everyone) null else partner
|
||||
val giveCards = give.asCards()
|
||||
val getCards = get.asCards()
|
||||
val allowed = (everyone || partner != null) &&
|
||||
CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
|
||||
Button(
|
||||
onClick = { onOffer(OfferTrade(to, giveCards, getCards)) },
|
||||
enabled = allowed,
|
||||
) { Text(CatanText.SEND_OFFER) }
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CounterRow(
|
||||
counts: Map<Resource, Int>,
|
||||
limit: (Resource) -> Int,
|
||||
onChange: (Map<Resource, Int>) -> Unit,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.forEach { resource ->
|
||||
ResourceCounter(
|
||||
resource = resource,
|
||||
count = counts[resource] ?: 0,
|
||||
max = limit(resource),
|
||||
onChange = { n -> onChange(counts + (resource to n.coerceIn(0, limit(resource)))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
if (selected) {
|
||||
FilledTonalButton(onClick = onClick) { Text(label) }
|
||||
} else {
|
||||
OutlinedButton(onClick = onClick) { Text(label) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Another player has put an offer to the local player (alone, or as one of the
|
||||
* table). Accept, decline, or — via a second step — answer with different terms.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanTradeResponseDialog(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
offer: TradeOffer,
|
||||
seats: CatanSeats,
|
||||
canAccept: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
onCounter: (CounterTrade) -> Unit,
|
||||
) {
|
||||
var countering by remember(offer) { mutableStateOf(false) }
|
||||
// What we would give the offerer, and what we would ask of them.
|
||||
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
|
||||
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
CatanText.tradeSummary(
|
||||
by = seats.label(offer.by),
|
||||
to = if (offer.to == null) "the table" else "you",
|
||||
give = offer.give,
|
||||
get = offer.get,
|
||||
),
|
||||
)
|
||||
if (countering) {
|
||||
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
|
||||
CounterRow(
|
||||
counts = give,
|
||||
limit = { state.resources[seat][it] ?: 0 },
|
||||
onChange = { give = it },
|
||||
)
|
||||
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
|
||||
CounterRow(
|
||||
counts = get,
|
||||
limit = { state.resources[offer.by][it] ?: 0 },
|
||||
onChange = { get = it },
|
||||
)
|
||||
} else if (!canAccept) {
|
||||
Text(
|
||||
"You cannot accept this: one side no longer holds the cards.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (countering) {
|
||||
val move = CounterTrade(give.asCards(), get.asCards())
|
||||
Button(
|
||||
onClick = { onCounter(move) },
|
||||
enabled = CatanInteraction.counterAllowed(state, seat, move.give, move.get),
|
||||
) { Text(CatanText.SEND_COUNTER) }
|
||||
} else {
|
||||
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (countering) {
|
||||
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
|
||||
} else {
|
||||
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
|
||||
}
|
||||
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The offerer's choice once nobody accepted: take a counter-offer, or none. */
|
||||
@Composable
|
||||
fun CatanCounterChoiceDialog(
|
||||
counters: List<TradeCounter>,
|
||||
seats: CatanSeats,
|
||||
onTake: (AcceptCounter) -> Unit,
|
||||
onNoDeal: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text("Counter-offers") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (counters.isEmpty()) {
|
||||
Text("None of the counter-offers can be met any more.")
|
||||
}
|
||||
counters.forEach { counter ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(CatanText.counterSummary(seats.label(counter.by), counter.give, counter.get))
|
||||
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ending
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The game is over: who won, and what everyone finished with. */
|
||||
@Composable
|
||||
fun CatanGameOverDialog(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
onExit: () -> Unit,
|
||||
onLook: () -> Unit,
|
||||
) {
|
||||
val points = (0 until state.playerCount).map { seat ->
|
||||
// The winner's hidden victory-point cards are revealed by winning; the
|
||||
// host's copy of the state has them, a client's has only its own.
|
||||
seat to if (seat == seats.me) CatanRules.victoryPoints(state, seat)
|
||||
else CatanRules.publicVictoryPoints(state)[seat]
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onLook,
|
||||
title = { Text(CatanText.winnerHeadline(state, seats)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
points.sortedByDescending { it.second }.forEach { (seat, vp) ->
|
||||
Text("${seats.label(seat)}: $vp points")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
|
||||
dismissButton = { TextButton(onClick = onLook) { Text(CatanText.LOOK_AT_BOARD) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the position is not saved. Anyone else " +
|
||||
"at the table is told you have left.",
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CatanAbandonedDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text("$notice There are no more moves to play.") },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text(CatanText.LOOK_AT_BOARD) } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
|
||||
data class Pt(val x: Float, val y: Float)
|
||||
|
||||
/**
|
||||
* Where the island sits on screen: the one place the engine's integer lattice
|
||||
* (see [CatanBoard]) becomes pixels, and pixels become "which vertex / edge /
|
||||
* hex did the player tap".
|
||||
*
|
||||
* Pure and Android-free, so it is unit-tested (`CatanGeometryTest`). The drawing
|
||||
* code ([CatanBoardView]) and the tap handling both go through this, which is
|
||||
* what keeps what is drawn and what is tappable from ever disagreeing.
|
||||
*
|
||||
* Lattice to pixel: a hex of circumradius `R` is pointy-top, so one lattice x-unit
|
||||
* is `R·√3/2` and one lattice y-unit is `R/2`, and lattice +Y points up the board
|
||||
* while pixel +y points down.
|
||||
*/
|
||||
class BoardLayout(
|
||||
val width: Float,
|
||||
val height: Float,
|
||||
/** Circumradius of one hex, in pixels. */
|
||||
val radius: Float,
|
||||
) {
|
||||
private val centreX = width / 2f
|
||||
private val centreY = height / 2f
|
||||
private val unitX = radius * SQRT3 / 2f
|
||||
private val unitY = radius / 2f
|
||||
|
||||
fun point(latticeX: Int, latticeY: Int): Pt =
|
||||
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
|
||||
|
||||
fun hexCenter(hex: Int): Pt {
|
||||
val (lx, ly) = CatanBoard.hexCenter(hex)
|
||||
return point(lx, ly)
|
||||
}
|
||||
|
||||
fun vertex(vertex: Int): Pt {
|
||||
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
|
||||
return point(lx, ly)
|
||||
}
|
||||
|
||||
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) }
|
||||
|
||||
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||
return vertex(a) to vertex(b)
|
||||
}
|
||||
|
||||
fun edgeMidpoint(edge: Int): Pt {
|
||||
val (a, b) = edgeEnds(edge)
|
||||
return Pt((a.x + b.x) / 2f, (a.y + b.y) / 2f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a harbour's marker is drawn: just off the coast, straight out from
|
||||
* the middle of its edge. The marker is joined to the edge's two vertices by
|
||||
* the board view, which is what shows which settlements can use it.
|
||||
*/
|
||||
fun portAnchor(edge: Int): Pt {
|
||||
val mid = edgeMidpoint(edge)
|
||||
val dx = mid.x - centreX
|
||||
val dy = mid.y - centreY
|
||||
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
|
||||
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
|
||||
}
|
||||
|
||||
/** The nearest of [among] within [reach] pixels of [at], or null. */
|
||||
fun nearestVertex(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||
nearest(at, reach, among) { vertex(it) }
|
||||
|
||||
fun nearestEdge(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||
nearest(at, reach, among) { edgeMidpoint(it) }
|
||||
|
||||
fun nearestHex(at: Pt, reach: Float, among: Collection<Int>): Int? =
|
||||
nearest(at, reach, among) { hexCenter(it) }
|
||||
|
||||
private fun nearest(at: Pt, reach: Float, among: Collection<Int>, position: (Int) -> Pt): Int? {
|
||||
var best: Int? = null
|
||||
var bestDistance = reach * reach
|
||||
for (candidate in among) {
|
||||
val p = position(candidate)
|
||||
val dx = p.x - at.x
|
||||
val dy = p.y - at.y
|
||||
val d = dx * dx + dy * dy
|
||||
if (d <= bestDistance) {
|
||||
best = candidate
|
||||
bestDistance = d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
object CatanGeometry {
|
||||
|
||||
/** Lattice extents of the island: x spans -5..5, y spans -8..8. */
|
||||
private const val LATTICE_X_UNITS = 10
|
||||
private const val LATTICE_Y_UNITS = 16
|
||||
|
||||
/** Empty space round the island, in hex radii, for the sea and the harbours. */
|
||||
const val MARGIN = 1.75f
|
||||
|
||||
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
|
||||
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
|
||||
|
||||
/** Width ÷ height of the board including its margin, for sizing its box. */
|
||||
val ASPECT: Float = boardWidthInRadii / boardHeightInRadii
|
||||
|
||||
/** The largest layout that fits [width] × [height] pixels. */
|
||||
fun fit(width: Float, height: Float): BoardLayout {
|
||||
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii)
|
||||
return BoardLayout(width, height, radius)
|
||||
}
|
||||
}
|
||||
|
||||
private const val SQRT3 = 1.7320508f
|
||||
|
||||
/** How far past the coast a harbour marker sits, in hex radii. Far enough that its piers are clearly visible. */
|
||||
internal const val PORT_OFFSET = 1.0f
|
||||
@@ -0,0 +1,578 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.game.catan.AcceptCounter
|
||||
import org.prolibertate.games.game.catan.AcceptTrade
|
||||
import org.prolibertate.games.game.catan.ActivateKnight
|
||||
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.BuildWall
|
||||
import org.prolibertate.games.game.catan.BuyDevCard
|
||||
import org.prolibertate.games.game.catan.BuyImprovement
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.ChaseRobber
|
||||
import org.prolibertate.games.game.catan.Chore
|
||||
import org.prolibertate.games.game.catan.CounterTrade
|
||||
import org.prolibertate.games.game.catan.DeclineTrade
|
||||
import org.prolibertate.games.game.catan.DevCard
|
||||
import org.prolibertate.games.game.catan.DevCardType
|
||||
import org.prolibertate.games.game.catan.Discard
|
||||
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||
import org.prolibertate.games.game.catan.EndSmith
|
||||
import org.prolibertate.games.game.catan.EndTurn
|
||||
import org.prolibertate.games.game.catan.CounterGoods
|
||||
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||
import org.prolibertate.games.game.catan.GoodsCounter
|
||||
import org.prolibertate.games.game.catan.Good
|
||||
import org.prolibertate.games.game.catan.HexCoord
|
||||
import org.prolibertate.games.game.catan.HireKnight
|
||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||
import org.prolibertate.games.game.catan.MoveKnight
|
||||
import org.prolibertate.games.game.catan.MoveRobber
|
||||
import org.prolibertate.games.game.catan.OfferGoods
|
||||
import org.prolibertate.games.game.catan.OfferTrade
|
||||
import org.prolibertate.games.game.catan.PillageCity
|
||||
import org.prolibertate.games.game.catan.PlaceKnight
|
||||
import org.prolibertate.games.game.catan.PlaceMetropolis
|
||||
import org.prolibertate.games.game.catan.TradeCounter
|
||||
import org.prolibertate.games.game.catan.PlayKnight
|
||||
import org.prolibertate.games.game.catan.PlayMonopoly
|
||||
import org.prolibertate.games.game.catan.PlayProgress
|
||||
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
||||
import org.prolibertate.games.game.catan.PlayYearOfPlenty
|
||||
import org.prolibertate.games.game.catan.ProgressCard
|
||||
import org.prolibertate.games.game.catan.ProgressType
|
||||
import org.prolibertate.games.game.catan.PromoteKnight
|
||||
import org.prolibertate.games.game.catan.RelocateKnight
|
||||
import org.prolibertate.games.game.catan.RemoveKnight
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.RollDice
|
||||
import org.prolibertate.games.game.catan.SetupPlace
|
||||
import org.prolibertate.games.game.catan.Track
|
||||
|
||||
/**
|
||||
* What a player can ask to build by tapping the board. Wall and Knight exist
|
||||
* only in Cities & Knights; the base game's build menu lists the first three.
|
||||
*/
|
||||
enum class BuildKind {
|
||||
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
|
||||
|
||||
companion object {
|
||||
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of the board the player may tap right now. Everything else is inert.
|
||||
* [knights] are crossings holding one of the player's own knights that can do
|
||||
* something this turn: tapping one opens its menu.
|
||||
*/
|
||||
data class BoardTargets(
|
||||
val vertices: Set<Int> = emptySet(),
|
||||
val edges: Set<Int> = emptySet(),
|
||||
val hexes: Set<Int> = emptySet(),
|
||||
val knights: Set<Int> = emptySet(),
|
||||
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
|
||||
val idleKnights: Set<Int> = emptySet(),
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() && idleKnights.isEmpty()
|
||||
|
||||
/** Every crossing a tap can land on. */
|
||||
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
|
||||
}
|
||||
|
||||
/**
|
||||
* A progress card that is waiting for a tap on the board: [plays] are every play
|
||||
* of it the engine accepts, and the player's tap picks one. [firstHex] is the
|
||||
* first of the two hexes an Inventor swaps, once tapped.
|
||||
*/
|
||||
data class ArmedCard(val kind: ProgressType, val plays: List<PlayProgress>, val firstHex: Int? = null)
|
||||
|
||||
/** One progress card in the local player's hand, and the plays the engine allows for it. */
|
||||
data class ProgressInHand(val card: ProgressCard, val plays: List<PlayProgress>) {
|
||||
val playable: Boolean get() = plays.isNotEmpty()
|
||||
}
|
||||
|
||||
/** What a tap on the board amounts to. */
|
||||
sealed interface TapOutcome {
|
||||
/** Nothing: the tap landed somewhere that is not a legal target. */
|
||||
data object Ignored : TapOutcome
|
||||
|
||||
/** Send this move to the table. */
|
||||
data class Submit(val move: CatanMove) : TapOutcome
|
||||
|
||||
/** Setup: remember this settlement; the road that goes with it is tapped next. */
|
||||
data class PickSetupVertex(val vertex: Int) : TapOutcome
|
||||
|
||||
/** The robber's hex has more than one possible victim: ask which. */
|
||||
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
|
||||
|
||||
/** One of the player's own knights was tapped: open its menu. */
|
||||
data class PickKnight(val vertex: Int) : TapOutcome
|
||||
|
||||
/** An Inventor's first hex was tapped; the second completes the swap. */
|
||||
data class PickFirstHex(val hex: Int) : TapOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* One development card in the local player's hand, and what can be done with it
|
||||
* right now. [plays] is empty when it cannot be played this turn (bought this
|
||||
* turn, a card has already been played, a victory-point card, or it is not the
|
||||
* player's turn).
|
||||
*/
|
||||
data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
|
||||
val playable: Boolean get() = plays.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules of *tapping*: given the game state, the moves the engine says are
|
||||
* legal, and what the player has selected, what can be tapped and what a tap does.
|
||||
*
|
||||
* Pure functions with no Android and no Compose, so the whole interaction model is
|
||||
* unit-tested (`CatanInteractionTest`). The screen holds the two bits of UI state
|
||||
* ([BuildKind] and the chosen setup settlement) and passes them in; it contains no
|
||||
* game rules of its own. **Anything that decides whether a tap is allowed belongs
|
||||
* here, never in a composable** — the engine's [legal] list is the single source
|
||||
* of truth, so the UI can never offer a move the rules would refuse.
|
||||
*/
|
||||
object CatanInteraction {
|
||||
|
||||
// --- Board ---------------------------------------------------------------
|
||||
|
||||
fun targets(
|
||||
state: CatanState,
|
||||
legal: List<CatanMove>,
|
||||
building: BuildKind?,
|
||||
setupVertex: Int?,
|
||||
movingKnight: Int? = null,
|
||||
armed: ArmedCard? = null,
|
||||
/** The local seat, so its sleeping knights can be tapped for their menu too. */
|
||||
localSeat: Int? = null,
|
||||
): BoardTargets {
|
||||
val setup = legal.filterIsInstance<SetupPlace>()
|
||||
if (setup.isNotEmpty()) {
|
||||
return if (setupVertex == null) {
|
||||
BoardTargets(vertices = setup.map { it.vertex }.toSet())
|
||||
} else {
|
||||
BoardTargets(edges = setup.filter { it.vertex == setupVertex }.map { it.edge }.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
val robber = legal.filterIsInstance<MoveRobber>()
|
||||
if (robber.isNotEmpty()) {
|
||||
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
|
||||
}
|
||||
|
||||
// Cities & Knights: answers to a queued chore are taps on the board.
|
||||
val chore = choreVertices(legal)
|
||||
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
|
||||
|
||||
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
|
||||
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
|
||||
|
||||
if (armed != null) return armedTargets(armed)
|
||||
|
||||
if (movingKnight != null) {
|
||||
return BoardTargets(
|
||||
vertices = legal.filterIsInstance<MoveKnight>().filter { it.from == movingKnight }.map { it.to }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
return when (building) {
|
||||
BuildKind.ROAD -> BoardTargets(edges = roads)
|
||||
BuildKind.SETTLEMENT ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
|
||||
|
||||
BuildKind.CITY ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
|
||||
|
||||
BuildKind.WALL ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<BuildWall>().map { it.vertex }.toSet())
|
||||
|
||||
BuildKind.KNIGHT ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<HireKnight>().map { it.vertex }.toSet())
|
||||
|
||||
null -> {
|
||||
val ready = knightsWithActions(legal)
|
||||
val mine = if (localSeat != null && legal.isNotEmpty()) {
|
||||
state.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
BoardTargets(knights = ready, idleKnights = mine - ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The crossings a queued chore is asking the player to tap, from the legal list. */
|
||||
private fun choreVertices(legal: List<CatanMove>): Set<Int> = buildSet {
|
||||
legal.forEach { move ->
|
||||
when (move) {
|
||||
is PillageCity -> add(move.vertex)
|
||||
is PlaceMetropolis -> add(move.vertex)
|
||||
is RelocateKnight -> add(move.to)
|
||||
is RemoveKnight -> add(move.vertex)
|
||||
is PlaceKnight -> add(move.vertex)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
// A Smith's promotions are knights to tap, while the chore is open.
|
||||
if (legal.any { it is EndSmith }) {
|
||||
legal.filterIsInstance<PromoteKnight>().forEach { add(it.vertex) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun choreMove(legal: List<CatanMove>, vertex: Int): CatanMove? = legal.firstOrNull { move ->
|
||||
when (move) {
|
||||
is PillageCity -> move.vertex == vertex
|
||||
is PlaceMetropolis -> move.vertex == vertex
|
||||
is RelocateKnight -> move.to == vertex
|
||||
is RemoveKnight -> move.vertex == vertex
|
||||
is PlaceKnight -> move.vertex == vertex
|
||||
is PromoteKnight -> legal.any { it is EndSmith } && move.vertex == vertex
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cities & Knights: knights ---------------------------------------------
|
||||
|
||||
/** Crossings of the player's knights that have an action available. */
|
||||
fun knightsWithActions(legal: List<CatanMove>): Set<Int> = buildSet {
|
||||
legal.forEach { move ->
|
||||
when (move) {
|
||||
is ActivateKnight -> add(move.vertex)
|
||||
is MoveKnight -> add(move.from)
|
||||
is ChaseRobber -> add(move.vertex)
|
||||
is PromoteKnight -> if (legal.none { it is EndSmith }) add(move.vertex)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the knight on [vertex] can do right now, in the order its menu lists them. */
|
||||
fun knightActions(legal: List<CatanMove>, vertex: Int): List<CatanMove> =
|
||||
legal.filter { move ->
|
||||
when (move) {
|
||||
is ActivateKnight -> move.vertex == vertex
|
||||
is PromoteKnight -> move.vertex == vertex && legal.none { it is EndSmith }
|
||||
is ChaseRobber -> move.vertex == vertex
|
||||
else -> false
|
||||
}
|
||||
} + listOfNotNull(legal.firstOrNull { it is MoveKnight && it.from == vertex })
|
||||
|
||||
fun canEndSmith(legal: List<CatanMove>): Boolean = legal.any { it is EndSmith }
|
||||
|
||||
// --- Cities & Knights: progress cards --------------------------------------
|
||||
|
||||
/** [seat]'s progress cards, each with the plays the engine currently allows. */
|
||||
fun progressHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<ProgressInHand> =
|
||||
state.ck?.progressHands?.getOrNull(seat).orEmpty().map { card ->
|
||||
ProgressInHand(card, legal.filterIsInstance<PlayProgress>().filter { it.card == card.id })
|
||||
}.sortedWith(compareBy({ it.card.kind.track.ordinal }, { it.card.kind.ordinal }, { it.card.id }))
|
||||
|
||||
/** The cards that are played by tapping the board, not by choosing in a dialog. */
|
||||
val BOARD_CARDS: Set<ProgressType> = setOf(
|
||||
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.MERCHANT,
|
||||
ProgressType.DIPLOMAT, ProgressType.INTRIGUE, ProgressType.INVENTOR,
|
||||
)
|
||||
|
||||
private fun armedTargets(armed: ArmedCard): BoardTargets = when (armed.kind) {
|
||||
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.INTRIGUE ->
|
||||
BoardTargets(vertices = armed.plays.mapNotNull { it.vertex }.toSet())
|
||||
|
||||
ProgressType.DIPLOMAT -> BoardTargets(edges = armed.plays.mapNotNull { it.edge }.toSet())
|
||||
|
||||
ProgressType.MERCHANT -> BoardTargets(hexes = armed.plays.mapNotNull { it.hexA }.toSet())
|
||||
|
||||
ProgressType.INVENTOR -> {
|
||||
val first = armed.firstHex
|
||||
val all = armed.plays.flatMap { listOfNotNull(it.hexA, it.hexB) }.toSet()
|
||||
if (first == null) {
|
||||
BoardTargets(hexes = all)
|
||||
} else {
|
||||
BoardTargets(
|
||||
hexes = armed.plays
|
||||
.filter { it.hexA == first || it.hexB == first }
|
||||
.flatMap { listOfNotNull(it.hexA, it.hexB) }
|
||||
.filter { it != first }
|
||||
.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> BoardTargets()
|
||||
}
|
||||
|
||||
/** The improvement tracks the player can buy a level of right now. */
|
||||
fun improvable(legal: List<CatanMove>): Set<Track> =
|
||||
legal.filterIsInstance<BuyImprovement>().map { it.track }.toSet()
|
||||
|
||||
// --- Cities & Knights: goods ----------------------------------------------
|
||||
|
||||
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
|
||||
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
|
||||
legal.filterIsInstance<GoodsBankTrade>()
|
||||
.groupBy({ it.give }, { it.get })
|
||||
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
|
||||
.toSortedMap(compareBy<Good> { it.ordinal })
|
||||
|
||||
fun goodsOfferAllowed(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
to: Int?,
|
||||
give: List<Good>,
|
||||
get: List<Good>,
|
||||
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
|
||||
CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get))
|
||||
|
||||
/** Whether [seat] may answer the goods offer on the table with [give] for [get]. */
|
||||
fun goodsCounterAllowed(state: CatanState, seat: Int, give: List<Good>, get: List<Good>): Boolean =
|
||||
give.isNotEmpty() && get.isNotEmpty() &&
|
||||
CatanRules.isLegalMove(state, seat, CounterGoods(give, get))
|
||||
|
||||
/** The goods counter-offers the offerer may take right now, in the order they came in. */
|
||||
fun goodsCounterChoices(state: CatanState, legal: List<CatanMove>): List<GoodsCounter> {
|
||||
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
|
||||
return state.ck?.goodsOffer?.counters.orEmpty().filter { it.by in takeable }
|
||||
}
|
||||
|
||||
/** The chore at the front of the queue, if the table is waiting on one. */
|
||||
fun chore(state: CatanState): Chore? =
|
||||
if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null
|
||||
|
||||
fun onVertexTap(
|
||||
vertex: Int,
|
||||
legal: List<CatanMove>,
|
||||
building: BuildKind?,
|
||||
setupVertex: Int?,
|
||||
movingKnight: Int? = null,
|
||||
armed: ArmedCard? = null,
|
||||
/** The local player's knights, which can be tapped for their menu even with nothing to do. */
|
||||
ownKnights: Set<Int> = emptySet(),
|
||||
): TapOutcome {
|
||||
val setup = legal.filterIsInstance<SetupPlace>()
|
||||
if (setup.isNotEmpty()) {
|
||||
// Re-picking is allowed until the road is chosen, so a misplaced
|
||||
// tap does not commit the settlement.
|
||||
return if (setup.any { it.vertex == vertex }) {
|
||||
TapOutcome.PickSetupVertex(vertex)
|
||||
} else {
|
||||
TapOutcome.Ignored
|
||||
}
|
||||
}
|
||||
choreMove(legal, vertex)?.let { return TapOutcome.Submit(it) }
|
||||
|
||||
if (armed != null) {
|
||||
val play = armed.plays.firstOrNull { it.vertex == vertex } ?: return TapOutcome.Ignored
|
||||
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||
}
|
||||
if (movingKnight != null) {
|
||||
val move = MoveKnight(movingKnight, vertex)
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
|
||||
val move: CatanMove = when (building) {
|
||||
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
|
||||
BuildKind.CITY -> BuildCity(vertex)
|
||||
BuildKind.WALL -> BuildWall(vertex)
|
||||
BuildKind.KNIGHT -> HireKnight(vertex)
|
||||
else -> return if (building == null && (vertex in knightsWithActions(legal) || vertex in ownKnights)) {
|
||||
TapOutcome.PickKnight(vertex)
|
||||
} else {
|
||||
TapOutcome.Ignored
|
||||
}
|
||||
}
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
|
||||
fun onEdgeTap(
|
||||
edge: Int,
|
||||
legal: List<CatanMove>,
|
||||
building: BuildKind?,
|
||||
setupVertex: Int?,
|
||||
armed: ArmedCard? = null,
|
||||
): TapOutcome {
|
||||
if (legal.any { it is SetupPlace }) {
|
||||
if (setupVertex == null) return TapOutcome.Ignored
|
||||
val move = SetupPlace(setupVertex, edge)
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
if (armed != null) {
|
||||
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
|
||||
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||
}
|
||||
// Free roads need no build mode; a paid road does.
|
||||
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
|
||||
if (!canBuild) return TapOutcome.Ignored
|
||||
val move = BuildRoad(edge)
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
|
||||
fun onHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard? = null): TapOutcome {
|
||||
if (armed != null) return onArmedHexTap(hex, legal, armed)
|
||||
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
|
||||
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
||||
return when {
|
||||
moves.isEmpty() -> TapOutcome.Ignored
|
||||
moves.size == 1 -> TapOutcome.Submit(moves.first())
|
||||
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
|
||||
}
|
||||
}
|
||||
|
||||
private fun onArmedHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard): TapOutcome {
|
||||
if (armed.kind == ProgressType.INVENTOR) {
|
||||
val first = armed.firstHex ?: return if (hex in armedTargets(armed).hexes) {
|
||||
TapOutcome.PickFirstHex(hex)
|
||||
} else {
|
||||
TapOutcome.Ignored
|
||||
}
|
||||
val play = armed.plays.firstOrNull {
|
||||
(it.hexA == first && it.hexB == hex) || (it.hexA == hex && it.hexB == first)
|
||||
} ?: return TapOutcome.Ignored
|
||||
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||
}
|
||||
val play = armed.plays.firstOrNull { it.hexA == hex } ?: return TapOutcome.Ignored
|
||||
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
|
||||
}
|
||||
|
||||
// --- Action bar ------------------------------------------------------------
|
||||
|
||||
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
|
||||
|
||||
/**
|
||||
* The hexes that paid out for this turn's roll, so the board can light them.
|
||||
* Empty until the turn's roll has happened (the dice left over from the last
|
||||
* turn are not this turn's), on a seven, and for the hex the robber sits on.
|
||||
*/
|
||||
fun rolledHexes(state: CatanState): Set<Int> {
|
||||
if (state.lastDice.size != 2) return emptySet()
|
||||
if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST ||
|
||||
state.phase == CatanPhase.SETUP_SECOND
|
||||
) {
|
||||
return emptySet()
|
||||
}
|
||||
val total = state.lastDice.sum()
|
||||
if (total == 7) return emptySet()
|
||||
return (0 until CatanBoard.HEX_COUNT)
|
||||
.filter { state.numbers[it] == total && it != state.robber }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
|
||||
|
||||
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
|
||||
|
||||
fun canBuyDevCard(legal: List<CatanMove>): Boolean = legal.any { it is BuyDevCard }
|
||||
|
||||
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
|
||||
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
|
||||
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
|
||||
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
||||
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
|
||||
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
|
||||
}
|
||||
|
||||
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
|
||||
legal.any { it is AcceptTrade } to legal.any { it is DeclineTrade }
|
||||
|
||||
// --- Development cards -----------------------------------------------------
|
||||
|
||||
/** [seat]'s development cards, each with the plays the engine currently allows. */
|
||||
fun devHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<DevCardInHand> =
|
||||
state.devHands.getOrNull(seat).orEmpty().map { card ->
|
||||
DevCardInHand(card, legal.filter { playOf(it) == card.id })
|
||||
}.sortedWith(compareBy({ it.card.type.ordinal }, { it.card.id }))
|
||||
|
||||
private fun playOf(move: CatanMove): Int? = when (move) {
|
||||
is PlayKnight -> move.card
|
||||
is PlayRoadBuilding -> move.card
|
||||
is PlayMonopoly -> move.card
|
||||
is PlayYearOfPlenty -> move.card
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** The Monopoly moves for one card, by the resource each would name. */
|
||||
fun monopolyChoices(plays: List<CatanMove>): Map<Resource, PlayMonopoly> =
|
||||
plays.filterIsInstance<PlayMonopoly>().associateBy { it.resource }
|
||||
|
||||
/** The Year of Plenty move that takes exactly [take], if the engine allows it. */
|
||||
fun plentyMove(plays: List<CatanMove>, take: List<Resource>): PlayYearOfPlenty? {
|
||||
val want = take.groupingBy { it }.eachCount()
|
||||
return plays.filterIsInstance<PlayYearOfPlenty>().firstOrNull {
|
||||
it.take.groupingBy { r -> r }.eachCount() == want
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resources a Year of Plenty can still be asked for, given what is already
|
||||
* [picked]: those that complete some take the engine allows. Empty once the
|
||||
* two cards are chosen.
|
||||
*/
|
||||
fun plentyAvailable(plays: List<CatanMove>, picked: List<Resource>): Set<Resource> {
|
||||
val chosen = picked.groupingBy { it }.eachCount()
|
||||
val available = mutableSetOf<Resource>()
|
||||
for (play in plays.filterIsInstance<PlayYearOfPlenty>()) {
|
||||
if (!chosen.all { (resource, n) -> play.take.count { it == resource } >= n }) continue
|
||||
val remaining = play.take.toMutableList()
|
||||
picked.forEach { remaining.remove(it) }
|
||||
available += remaining
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
// --- Discarding -----------------------------------------------------------
|
||||
|
||||
/** How many cards a player holding [held] must discard on a seven. */
|
||||
fun discardQuota(held: Int): Int = held / 2
|
||||
|
||||
/** The discard move that gives up exactly [selection], if the engine allows it. */
|
||||
fun discardMove(legal: List<CatanMove>, selection: List<Resource>): Discard? {
|
||||
val want = selection.groupingBy { it }.eachCount()
|
||||
return legal.filterIsInstance<Discard>().firstOrNull {
|
||||
it.cards.groupingBy { r -> r }.eachCount() == want
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trading ---------------------------------------------------------------
|
||||
|
||||
/** Bank trades on offer: what can be given, and for what. */
|
||||
fun bankTrades(legal: List<CatanMove>): Map<Resource, List<Resource>> =
|
||||
legal.filterIsInstance<MaritimeTrade>()
|
||||
.groupBy({ it.give }, { it.get })
|
||||
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
|
||||
.toSortedMap(compareBy<Resource> { it.ordinal })
|
||||
|
||||
/** What it costs [seat] to give one [resource] to the bank: 4, 3 or 2 cards. */
|
||||
fun bankRatio(state: CatanState, seat: Int, resource: Resource): Int =
|
||||
CatanRules.bestPortRatio(state, seat, resource)
|
||||
|
||||
/**
|
||||
* Whether [seat] can offer [give] for [get] to [to] right now. Uses the
|
||||
* engine's own structural check, since offers are not in the enumerated
|
||||
* move list.
|
||||
*/
|
||||
fun offerAllowed(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
to: Int?,
|
||||
give: List<Resource>,
|
||||
get: List<Resource>,
|
||||
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
|
||||
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get))
|
||||
|
||||
/** Whether [seat] may answer the offer on the table with [give] for [get]. */
|
||||
fun counterAllowed(state: CatanState, seat: Int, give: List<Resource>, get: List<Resource>): Boolean =
|
||||
give.isNotEmpty() && get.isNotEmpty() &&
|
||||
CatanRules.isLegalMove(state, seat, CounterTrade(give, get))
|
||||
|
||||
/** The counter-offers the offerer may take right now, in the order they came in. */
|
||||
fun counterChoices(state: CatanState, legal: List<CatanMove>): List<TradeCounter> {
|
||||
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
|
||||
return state.pendingTrade?.counters.orEmpty().filter { it.by in takeable }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.catan.BuildingType
|
||||
import org.prolibertate.games.game.catan.CatanCk
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.EventFace
|
||||
import org.prolibertate.games.game.catan.Good
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.Track
|
||||
|
||||
/*
|
||||
* Stateless building blocks for the Katan screen.
|
||||
*
|
||||
* Every composable here takes plain values and reports events through lambdas;
|
||||
* none of them holds game state or touches the controller, so each can be
|
||||
* restyled, re-laid-out or replaced on its own. Colours and proportions come
|
||||
* from [CatanStyle] and wording from [CatanText].
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource tile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One resource as a coloured tile with its count. The hand, the discard picker
|
||||
* and the trade pickers all use this, so a resource looks the same everywhere.
|
||||
*/
|
||||
@Composable
|
||||
fun ResourceTile(
|
||||
resource: Resource,
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = 56.dp)
|
||||
.clip(shape)
|
||||
.background(style.resource.getValue(resource).copy(alpha = if (enabled) 1f else 0.4f))
|
||||
.border(
|
||||
width = if (selected) 3.dp else 1.dp,
|
||||
color = if (selected) Color.White else Color(0x33000000),
|
||||
shape = shape,
|
||||
)
|
||||
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
|
||||
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
color = style.resourceInk,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
Text(
|
||||
text = CatanText.resource(resource),
|
||||
color = style.resourceInk,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One card type — a resource or a commodity — as a coloured tile with its count.
|
||||
* Cities & Knights hands, discards and trades use this so commodities look the
|
||||
* same everywhere.
|
||||
*/
|
||||
@Composable
|
||||
fun GoodTile(
|
||||
good: Good,
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val resource = good.resource
|
||||
if (resource != null) {
|
||||
ResourceTile(resource, count, modifier, selected, enabled, style, onClick)
|
||||
return
|
||||
}
|
||||
val commodity = good.commodity!!
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
val ink = style.commodityInk.getValue(commodity)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = 56.dp)
|
||||
.clip(shape)
|
||||
.background(style.commodity.getValue(commodity).copy(alpha = if (enabled) 1f else 0.4f))
|
||||
.border(
|
||||
width = if (selected) 3.dp else 1.dp,
|
||||
color = if (selected) Color.White else Color(0x33000000),
|
||||
shape = shape,
|
||||
)
|
||||
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
|
||||
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = count.toString(), color = ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
|
||||
Text(text = CatanText.good(good), color = ink, fontSize = 11.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The local player's cards: one tile per resource, and in Cities & Knights one
|
||||
* per commodity as well, in a row that wraps.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanHand(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (state.isCk) {
|
||||
val held = CatanCk.goodsOf(state, seat)
|
||||
Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) }
|
||||
} else {
|
||||
Resource.entries.forEach { resource ->
|
||||
ResourceTile(resource, state.resources[seat][resource] ?: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Players
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One player's public standing: colour, name, points, how many cards they hold
|
||||
* (not which), and — in the roomier layout — development cards, knights and
|
||||
* their longest road.
|
||||
*
|
||||
* **Fixed height.** Every line is always present (blank when there is nothing to
|
||||
* say) and never wraps, so the card is the same height whatever the game is
|
||||
* doing. The board is given whatever room is left, so anything that made this
|
||||
* card taller or shorter would rescale the island under the player's thumb.
|
||||
*
|
||||
* [points] is passed in rather than computed here because the local player's
|
||||
* own count includes hidden victory-point cards and everyone else's does not;
|
||||
* the screen decides which. [compact] is the narrow portrait strip (four cards
|
||||
* across a phone); the default has room for the detail lines.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanPlayerCard(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
seats: CatanSeats,
|
||||
points: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.border(
|
||||
width = if (onTurn) 3.dp else 1.dp,
|
||||
color = if (onTurn) MaterialTheme.colorScheme.primary else Color(0x22000000),
|
||||
shape = shape,
|
||||
)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clip(CircleShape)
|
||||
.background(style.seats[seat % style.seats.size])
|
||||
.border(1.dp, style.pieceOutline, CircleShape),
|
||||
)
|
||||
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
|
||||
}
|
||||
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
|
||||
val ck = state.ck
|
||||
if (ck != null) {
|
||||
val cards = CatanCk.goodsCount(state, seat)
|
||||
val strength = CatanCk.strength(state, seat)
|
||||
if (compact) {
|
||||
CardLine("$cards cards · ⚔$strength", MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall)
|
||||
CardLine(
|
||||
"⚔ ${CatanCk.knightsOf(state, seat).size} · str $strength · " +
|
||||
"Road ${CatanRules.longestRoadFor(state, seat)}",
|
||||
MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
// Trade, politics, science: the three improvement levels.
|
||||
CardLine(
|
||||
Track.entries.joinToString(" ") { "${it.name.first()}${CatanCk.improvement(state, seat, it)}" },
|
||||
MaterialTheme.typography.bodySmall,
|
||||
color = Color.Unspecified,
|
||||
)
|
||||
val ckAwards = buildList {
|
||||
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||
ck.metropolises.forEach { (track, at) ->
|
||||
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
|
||||
}
|
||||
if (ck.merchant?.seat == seat) add("Merchant")
|
||||
}
|
||||
CardLine(
|
||||
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
weight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
if (compact) {
|
||||
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
CardLine(
|
||||
"${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
|
||||
MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
CardLine(
|
||||
"Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
|
||||
MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
val awards = buildList {
|
||||
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
|
||||
}
|
||||
// Always drawn, so the card does not grow when somebody takes an award.
|
||||
CardLine(
|
||||
text = awards.joinToString(" · ").ifEmpty { BLANK },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
weight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A non-breaking space: a line of text with nothing in it, still one line tall. */
|
||||
private const val BLANK = "\u00A0"
|
||||
|
||||
/** One line of a player card: never wraps, so the card's height cannot change. */
|
||||
@Composable
|
||||
private fun CardLine(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
weight: FontWeight? = null,
|
||||
color: Color = Color.Unspecified,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
fontWeight = weight,
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The two dice as last rolled, with their total. Blank until the first roll. In
|
||||
* Cities & Knights the dice are yellow and red (the red one decides progress
|
||||
* cards) and a third, the event die, shows a ship or a coloured city gate.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanDice(
|
||||
dice: List<Int>,
|
||||
modifier: Modifier = Modifier,
|
||||
ck: Boolean = false,
|
||||
event: EventFace? = null,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val shown = if (dice.size == 2) dice else listOf(null, null)
|
||||
shown.forEachIndexed { index, value ->
|
||||
val face = when {
|
||||
!ck -> Color.White
|
||||
index == 0 -> Color(0xFFFFF176)
|
||||
else -> Color(0xFFEF9A9A)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(face)
|
||||
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = value?.toString() ?: "–",
|
||||
color = Color(0xFF1C1B1F),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (ck) {
|
||||
val gate = when (event) {
|
||||
EventFace.TRADE -> style.track.getValue(Track.TRADE)
|
||||
EventFace.POLITICS -> style.track.getValue(Track.POLITICS)
|
||||
EventFace.SCIENCE -> style.track.getValue(Track.SCIENCE)
|
||||
EventFace.SHIP -> style.barbarian
|
||||
null -> Color(0x33000000)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 52.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(gate)
|
||||
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (event == EventFace.SHIP) "⛵ Ship" else CatanText.event(event),
|
||||
color = if (event == EventFace.TRADE) Color(0xFF1C1B1F) else Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Always drawn ("= –" before the first roll) so the row never changes width.
|
||||
Text(
|
||||
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cities & Knights: how close the barbarians are, and how the cities on the
|
||||
* island compare with the knights standing ready. **Fixed height**, like every
|
||||
* other strip around the board.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanBarbarians(state: CatanState, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current) {
|
||||
val ck = state.ck ?: return
|
||||
val cities = state.buildings.count { it?.type == BuildingType.CITY }
|
||||
val defence = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().height(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("⛵", fontSize = 14.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) {
|
||||
// The ship starts seven steps out and lands when the last box fills.
|
||||
val sailed = BARBARIAN_STEPS_DISPLAY - ck.barbarians
|
||||
for (i in 0 until BARBARIAN_STEPS_DISPLAY) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(10.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(if (i < sailed) style.barbarian else Color(0x33000000)),
|
||||
)
|
||||
}
|
||||
}
|
||||
val verdict = if (defence >= cities) "holds" else "falls"
|
||||
Text(
|
||||
text = "${CatanText.barbarianSteps(ck.barbarians)} · knights $defence v. $cities cities ($verdict)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Steps shown on the barbarian strip: the engine's seven. */
|
||||
private const val BARBARIAN_STEPS_DISPLAY = org.prolibertate.games.game.catan.BARBARIAN_STEPS
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
|
||||
data class CatanActions(
|
||||
val canRoll: Boolean = false,
|
||||
val canBuild: Set<BuildKind> = emptySet(),
|
||||
val building: BuildKind? = null,
|
||||
val canBuyCard: Boolean = false,
|
||||
val canPlayCard: Boolean = false,
|
||||
val canTrade: Boolean = false,
|
||||
val canEndTurn: Boolean = false,
|
||||
val canEndFreeRoads: Boolean = false,
|
||||
/** A setup settlement is chosen and a different one may still be picked. */
|
||||
val canRepickSetup: Boolean = false,
|
||||
/** Which kinds the Build menu lists: three in the base game, five in Cities & Knights. */
|
||||
val buildKinds: List<BuildKind> = BuildKind.BASE,
|
||||
/** Cities & Knights: the table plays the expansion, so the bar shows its buttons instead. */
|
||||
val ck: Boolean = false,
|
||||
val canImprove: Boolean = false,
|
||||
val canPlayProgress: Boolean = false,
|
||||
val progressCount: Int = 0,
|
||||
val canEndSmith: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* The buttons for whatever the player can do now. Pure display: every press is a lambda.
|
||||
*
|
||||
* **The bar never changes size.** The top row holds the one "primary" action
|
||||
* (roll, end turn, done with roads, cancel) and keeps its height when it is
|
||||
* empty; the second row is always all six buttons, greyed out when they do not
|
||||
* apply. Buttons appearing and disappearing used to resize this area and, with
|
||||
* it, the board above — so nothing here may be conditionally *present*, only
|
||||
* conditionally *enabled*.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanActionBar(
|
||||
actions: CatanActions,
|
||||
onRoll: () -> Unit,
|
||||
onBuild: (BuildKind?) -> Unit,
|
||||
onBuyCard: () -> Unit,
|
||||
onPlayCard: () -> Unit,
|
||||
onTrade: () -> Unit,
|
||||
onEndTurn: () -> Unit,
|
||||
onEndFreeRoads: () -> Unit,
|
||||
onRepickSetup: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
|
||||
compact: Boolean = false,
|
||||
onImprove: () -> Unit = {},
|
||||
onProgress: () -> Unit = {},
|
||||
onEndSmith: () -> Unit = {},
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
|
||||
if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
|
||||
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
|
||||
if (actions.canEndSmith) Button(onClick = onEndSmith) { Text(CatanText.STOP_SMITH) }
|
||||
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (compact || actions.ck) {
|
||||
BuildMenuButton(actions, onBuild)
|
||||
} else {
|
||||
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
|
||||
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
|
||||
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
|
||||
}
|
||||
if (actions.ck) {
|
||||
OutlinedButton(onClick = onImprove, enabled = actions.canImprove) {
|
||||
LabelWithCost(CatanText.IMPROVE, CatanText.NO_COST)
|
||||
}
|
||||
OutlinedButton(onClick = onProgress, enabled = actions.canPlayProgress) {
|
||||
LabelWithCost(CatanText.CARDS_BUTTON, "${actions.progressCount} held")
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
|
||||
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
|
||||
}
|
||||
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
|
||||
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
|
||||
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Room for one button, held whether or not there is one to show. */
|
||||
private val PRIMARY_ROW_HEIGHT = 48.dp
|
||||
|
||||
@Composable
|
||||
private fun BuildButton(
|
||||
kind: BuildKind,
|
||||
label: String,
|
||||
cost: String,
|
||||
actions: CatanActions,
|
||||
onBuild: (BuildKind?) -> Unit,
|
||||
) {
|
||||
val enabled = kind in actions.canBuild
|
||||
val active = actions.building == kind
|
||||
val onClick = { onBuild(if (active) null else kind) }
|
||||
if (active) {
|
||||
FilledTonalButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
|
||||
} else {
|
||||
OutlinedButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The small-screen stand-in for the three build buttons: one "Build" button
|
||||
* whose menu lists Road, Settlement and City with their costs. While a build
|
||||
* mode is armed the button shows which, and choosing that entry again cancels it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val armed = actions.building
|
||||
val anyEnabled = actions.canBuild.isNotEmpty() || armed != null
|
||||
val label: @Composable () -> Unit = {
|
||||
LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST)
|
||||
}
|
||||
Box {
|
||||
if (armed != null) {
|
||||
FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||
} else {
|
||||
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
for (kind in actions.buildKinds) {
|
||||
DropdownMenuItem(
|
||||
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
|
||||
enabled = kind in actions.canBuild,
|
||||
onClick = {
|
||||
open = false
|
||||
onBuild(if (armed == kind) null else kind)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildLabel(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.ROAD -> CatanText.ROAD
|
||||
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
|
||||
BuildKind.CITY -> CatanText.CITY
|
||||
BuildKind.WALL -> CatanText.WALL
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||
}
|
||||
|
||||
private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.ROAD -> CatanText.ROAD_COST
|
||||
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
|
||||
BuildKind.CITY -> CatanText.CITY_COST
|
||||
BuildKind.WALL -> CatanText.WALL_COST
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelWithCost(label: String, cost: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(label)
|
||||
Text(cost, fontSize = 9.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The most recent happenings, newest last. Scrolls. */
|
||||
@Composable
|
||||
fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
|
||||
if (state.log.isEmpty()) {
|
||||
Text(CatanText.NO_LOG, style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
state.log.takeLast(40).forEach {
|
||||
Text(text = it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/*
|
||||
* Android Studio previews for the Katan UI. They exist so the look can be
|
||||
* iterated on without starting a match: open this file, open the Preview pane,
|
||||
* and edit CatanStyle / CatanText / the panels. Each preview draws a real
|
||||
* position (the computer plays itself for a while), not a mock.
|
||||
*
|
||||
* Nothing here ships in a behaviour sense: previews are only compiled for tooling.
|
||||
*/
|
||||
|
||||
private val previewJson = Json { encodeDefaults = true }
|
||||
|
||||
/** A real game, played forward by the computer for [moves] moves, for previewing. */
|
||||
private fun sampleState(players: Int = 4, moves: Int = 90, seed: Long = 11L): CatanState {
|
||||
val config = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
|
||||
optionsJson = previewJson.encodeToString(CatanOptions(playerCount = players)),
|
||||
seed = seed,
|
||||
)
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config)
|
||||
repeat(moves) {
|
||||
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||
val seat = CatanRules.currentSeat(state) ?: return state
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
if (legal.isEmpty()) return state
|
||||
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||
@Composable
|
||||
private fun BoardPreview() {
|
||||
val state = remember { sampleState() }
|
||||
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
|
||||
CatanBoardView(
|
||||
state = state,
|
||||
targets = BoardTargets(),
|
||||
selectedVertex = null,
|
||||
onVertexTap = {},
|
||||
onEdgeTap = {},
|
||||
onHexTap = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||
@Composable
|
||||
private fun BoardDarkPreview() {
|
||||
val state = remember { sampleState() }
|
||||
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = true)) {
|
||||
CatanBoardView(
|
||||
state = state,
|
||||
targets = BoardTargets(),
|
||||
selectedVertex = null,
|
||||
onVertexTap = {},
|
||||
onEdgeTap = {},
|
||||
onHexTap = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
|
||||
@Composable
|
||||
private fun BoardWithTargetsPreview() {
|
||||
val state = remember { sampleState(moves = 6) }
|
||||
val legal = remember(state) { CatanRules.currentSeat(state)?.let { CatanRules.legalMoves(state, it) }.orEmpty() }
|
||||
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
|
||||
CatanBoardView(
|
||||
state = state,
|
||||
targets = CatanInteraction.targets(state, legal, building = null, setupVertex = null),
|
||||
selectedVertex = null,
|
||||
onVertexTap = {},
|
||||
onEdgeTap = {},
|
||||
onHexTap = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 560)
|
||||
@Composable
|
||||
private fun PanelsPreview() {
|
||||
val state = remember { sampleState() }
|
||||
val seats = CatanSeats(me = 0, names = listOf("You", "Ann", "Bo", "Cy"))
|
||||
val points = remember(state) { CatanRules.publicVictoryPoints(state) }
|
||||
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
(0 until state.playerCount).forEach { seat ->
|
||||
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f), compact = true)
|
||||
}
|
||||
}
|
||||
CatanHand(state, seat = 0)
|
||||
CatanDice(state.lastDice)
|
||||
CatanActionBar(
|
||||
actions = CatanActions(
|
||||
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
|
||||
building = BuildKind.ROAD,
|
||||
canBuyCard = true,
|
||||
canTrade = true,
|
||||
canEndTurn = true,
|
||||
),
|
||||
onRoll = {}, onBuild = {}, onBuyCard = {}, onPlayCard = {}, onTrade = {},
|
||||
onEndTurn = {}, onEndFreeRoads = {}, onRepickSetup = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.catan.AcceptTrade
|
||||
import org.prolibertate.games.game.catan.AqueductChore
|
||||
import org.prolibertate.games.game.catan.BuyDevCard
|
||||
import org.prolibertate.games.game.catan.CatanCk
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.DeclineTrade
|
||||
import org.prolibertate.games.game.catan.DiscardChore
|
||||
import org.prolibertate.games.game.catan.DiscardGoods
|
||||
import org.prolibertate.games.game.catan.DropProgress
|
||||
import org.prolibertate.games.game.catan.DropProgressChore
|
||||
import org.prolibertate.games.game.catan.DismissCounters
|
||||
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||
import org.prolibertate.games.game.catan.EndSmith
|
||||
import org.prolibertate.games.game.catan.EndTurn
|
||||
import org.prolibertate.games.game.catan.GiveChore
|
||||
import org.prolibertate.games.game.catan.GiveGoods
|
||||
import org.prolibertate.games.game.catan.MoveKnight
|
||||
import org.prolibertate.games.game.catan.MoveRobber
|
||||
import org.prolibertate.games.game.catan.PickDeckChore
|
||||
import org.prolibertate.games.game.catan.RollDice
|
||||
import org.prolibertate.games.game.catan.SetupPlace
|
||||
import org.prolibertate.games.game.catan.SpyChore
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
/**
|
||||
* Katan, on the island.
|
||||
*
|
||||
* This composable is only wiring. It reads the match from the controller, holds
|
||||
* the handful of purely visual choices (which build mode is armed, which setup
|
||||
* settlement is waiting for its road, which dialog is open), and hands
|
||||
* everything else to single-purpose pieces:
|
||||
*
|
||||
* | Concern | Where it lives |
|
||||
* | ------------------------------ | -------------------------- |
|
||||
* | Colours, sizes, shapes | [CatanStyle] |
|
||||
* | Every word on screen | [CatanText] |
|
||||
* | Island → pixels, taps → hexes | [CatanGeometry] |
|
||||
* | What is tappable, what a tap does | [CatanInteraction] |
|
||||
* | Drawing the island | [CatanBoardView] |
|
||||
* | Cards, players, dice, buttons | `CatanPanels.kt` |
|
||||
* | Every dialog | `CatanDialogs.kt` |
|
||||
*
|
||||
* There are no game rules here: the engine's legal-move list is the only source
|
||||
* of what the player may do, so the screen cannot offer a move the rules refuse.
|
||||
* See `docs/UI-catan.md` for how to adapt the look and feel.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanScreen(
|
||||
controller: MatchController<CatanState, CatanMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
seatNames: List<String> = emptyList(),
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
val notice by controller.notice.collectAsState()
|
||||
|
||||
// Purely visual state. Anything that affects the game goes through submit().
|
||||
var building by remember { mutableStateOf<BuildKind?>(null) }
|
||||
var setupVertex by remember { mutableStateOf<Int?>(null) }
|
||||
var victimChoice by remember { mutableStateOf<TapOutcome.PickRobberVictim?>(null) }
|
||||
var showCards by remember { mutableStateOf(false) }
|
||||
var showTrade by remember { mutableStateOf(false) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
var resultDismissed by remember { mutableStateOf(false) }
|
||||
// Cities & Knights: the knight whose menu is open, the one about to move,
|
||||
// a progress card waiting for a tap on the board, and the two list dialogs.
|
||||
var knightMenu by remember { mutableStateOf<Int?>(null) }
|
||||
var movingKnight by remember { mutableStateOf<Int?>(null) }
|
||||
var armed by remember { mutableStateOf<ArmedCard?>(null) }
|
||||
var showImprove by remember { mutableStateOf(false) }
|
||||
var showProgress by remember { mutableStateOf(false) }
|
||||
|
||||
val dark = isSystemInDarkTheme()
|
||||
val style = remember(dark) { catanStyle(dark) }
|
||||
val seats = remember(localSeat, seatNames) { CatanSeats(localSeat, seatNames) }
|
||||
|
||||
// The engine's list changes after every move; drop any selection it no
|
||||
// longer supports (a build mode the player can no longer afford, a setup
|
||||
// settlement that is no longer pending).
|
||||
LaunchedEffect(legal) {
|
||||
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
|
||||
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
|
||||
setupVertex = null
|
||||
}
|
||||
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
|
||||
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
|
||||
if (legal.isEmpty()) {
|
||||
showCards = false
|
||||
showTrade = false
|
||||
victimChoice = null
|
||||
showImprove = false
|
||||
showProgress = false
|
||||
knightMenu = null
|
||||
movingKnight = null
|
||||
armed = null
|
||||
}
|
||||
}
|
||||
|
||||
val submit: (CatanMove) -> Unit = { move ->
|
||||
controller.submit(move)
|
||||
building = null
|
||||
setupVertex = null
|
||||
victimChoice = null
|
||||
showCards = false
|
||||
showTrade = false
|
||||
showImprove = false
|
||||
showProgress = false
|
||||
knightMenu = null
|
||||
movingKnight = null
|
||||
armed = null
|
||||
}
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text(CatanText.END_GAME) }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { CatanAbandonedDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) CatanEndGameDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat)
|
||||
val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
|
||||
val devHand = CatanInteraction.devHand(current, localSeat, legal)
|
||||
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
|
||||
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
||||
val actions = CatanActions(
|
||||
buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE,
|
||||
ck = current.isCk,
|
||||
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
|
||||
canPlayProgress = progressHand.any { it.playable },
|
||||
progressCount = progressHand.size,
|
||||
canEndSmith = CatanInteraction.canEndSmith(legal),
|
||||
canRoll = CatanInteraction.canRoll(legal),
|
||||
canBuild = CatanInteraction.canBuild(legal),
|
||||
building = building,
|
||||
canBuyCard = CatanInteraction.canBuyDevCard(legal),
|
||||
canPlayCard = devHand.any { it.playable },
|
||||
canTrade = playing,
|
||||
canEndTurn = CatanInteraction.canEndTurn(legal),
|
||||
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
|
||||
canRepickSetup = setupVertex != null,
|
||||
)
|
||||
val rolledHexes = CatanInteraction.rolledHexes(current)
|
||||
val prompt = CatanText.prompt(
|
||||
current,
|
||||
seats,
|
||||
setupVertexChosen = setupVertex != null,
|
||||
building = building,
|
||||
movingKnight = movingKnight != null,
|
||||
armed = armed?.kind,
|
||||
)
|
||||
|
||||
val handle: (TapOutcome) -> Unit = { outcome ->
|
||||
when (outcome) {
|
||||
TapOutcome.Ignored -> Unit
|
||||
is TapOutcome.Submit -> submit(outcome.move)
|
||||
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
|
||||
is TapOutcome.PickRobberVictim -> victimChoice = outcome
|
||||
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
|
||||
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
|
||||
}
|
||||
}
|
||||
|
||||
val publicPoints = CatanRules.publicVictoryPoints(current)
|
||||
val pointsOf: (Int) -> Int = { seat ->
|
||||
if (seat == localSeat) CatanRules.victoryPoints(current, seat) else publicPoints[seat]
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalCatanStyle provides style) {
|
||||
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight * 1.1f
|
||||
// Phones (either way up) get the one-button build menu.
|
||||
val compactBuild = !wide || maxWidth < 600.dp
|
||||
|
||||
val board: @Composable (Modifier) -> Unit = { boardModifier ->
|
||||
CatanBoardView(
|
||||
state = current,
|
||||
targets = targets,
|
||||
selectedVertex = setupVertex,
|
||||
onVertexTap = {
|
||||
handle(
|
||||
CatanInteraction.onVertexTap(
|
||||
it, legal, building, setupVertex, movingKnight, armed,
|
||||
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
|
||||
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
|
||||
modifier = boardModifier,
|
||||
style = style,
|
||||
rolledHexes = rolledHexes,
|
||||
selectedKnight = movingKnight ?: knightMenu,
|
||||
)
|
||||
}
|
||||
val players: @Composable (Boolean) -> Unit = { grid ->
|
||||
PlayerCards(current, seats, pointsOf, style, twoPerRow = grid)
|
||||
}
|
||||
val promptRow: @Composable () -> Unit = {
|
||||
PromptRow(prompt, notice, current)
|
||||
}
|
||||
val handAndActions: @Composable () -> Unit = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CatanHand(current, localSeat)
|
||||
CatanActionBar(
|
||||
actions = actions,
|
||||
onRoll = { submit(RollDice()) },
|
||||
onBuild = { building = it },
|
||||
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
|
||||
onPlayCard = { showCards = true },
|
||||
onTrade = { showTrade = true },
|
||||
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
|
||||
onEndFreeRoads = {
|
||||
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
|
||||
},
|
||||
onRepickSetup = { setupVertex = null },
|
||||
compact = compactBuild,
|
||||
onImprove = { showImprove = true },
|
||||
onProgress = { showProgress = true },
|
||||
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
board(Modifier)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
players(true)
|
||||
if (current.isCk) CatanBarbarians(current, style = style)
|
||||
promptRow()
|
||||
handAndActions()
|
||||
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
players(false)
|
||||
if (current.isCk) CatanBarbarians(current, style = style)
|
||||
promptRow()
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
board(Modifier)
|
||||
}
|
||||
handAndActions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CatanDialogHost(
|
||||
state = current,
|
||||
seats = seats,
|
||||
localSeat = localSeat,
|
||||
legal = legal,
|
||||
abandoned = abandoned,
|
||||
confirmingEnd = confirmingEnd,
|
||||
resultDismissed = resultDismissed,
|
||||
victimChoice = victimChoice,
|
||||
showCards = showCards,
|
||||
showTrade = showTrade,
|
||||
devHand = devHand,
|
||||
knightMenu = knightMenu,
|
||||
showImprove = showImprove,
|
||||
showProgress = showProgress,
|
||||
progressHand = progressHand,
|
||||
onMoveKnight = { from ->
|
||||
knightMenu = null
|
||||
movingKnight = from
|
||||
},
|
||||
onArmCard = { card ->
|
||||
showProgress = false
|
||||
armed = card
|
||||
},
|
||||
onCancelKnight = { knightMenu = null },
|
||||
onCancelImprove = { showImprove = false },
|
||||
onCancelProgress = { showProgress = false },
|
||||
onExit = onExit,
|
||||
onSubmit = submit,
|
||||
onStay = controller::dismissAbandoned,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
onDismissResult = { resultDismissed = true },
|
||||
onCancelVictim = { victimChoice = null },
|
||||
onCancelCards = { showCards = false },
|
||||
onCancelTrade = { showTrade = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun PlayerCards(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
pointsOf: (Int) -> Int,
|
||||
style: CatanStyle,
|
||||
twoPerRow: Boolean,
|
||||
) {
|
||||
val order = (0 until state.playerCount).toList()
|
||||
val rows = if (twoPerRow) order.chunked(2) else listOf(order)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
row.forEach { seat ->
|
||||
CatanPlayerCard(
|
||||
state = state,
|
||||
seat = seat,
|
||||
seats = seats,
|
||||
points = pointsOf(seat),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = style,
|
||||
compact = !twoPerRow,
|
||||
)
|
||||
}
|
||||
// Keep an odd last row's card the same width as the others.
|
||||
if (twoPerRow && row.size == 1) Box(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line telling the player what is going on, and the dice.
|
||||
*
|
||||
* **Fixed height.** The text always occupies exactly two lines (padded when it is
|
||||
* shorter, clipped when it is longer) and a refused move replaces the prompt
|
||||
* rather than adding to it, so this row is the same height whatever is said and
|
||||
* the board below it never moves.
|
||||
*/
|
||||
@Composable
|
||||
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = notice ?: prompt,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses which dialog, if any, is showing. Pulled out of [CatanScreen] so the
|
||||
* priority order — an ended table beats a result beats a forced discard beats
|
||||
* an optional picker — is in one readable place.
|
||||
*/
|
||||
@Composable
|
||||
private fun CatanDialogHost(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
localSeat: Int,
|
||||
legal: List<CatanMove>,
|
||||
abandoned: String?,
|
||||
confirmingEnd: Boolean,
|
||||
resultDismissed: Boolean,
|
||||
victimChoice: TapOutcome.PickRobberVictim?,
|
||||
showCards: Boolean,
|
||||
showTrade: Boolean,
|
||||
devHand: List<DevCardInHand>,
|
||||
knightMenu: Int?,
|
||||
showImprove: Boolean,
|
||||
showProgress: Boolean,
|
||||
progressHand: List<ProgressInHand>,
|
||||
onMoveKnight: (Int) -> Unit,
|
||||
onArmCard: (ArmedCard) -> Unit,
|
||||
onCancelKnight: () -> Unit,
|
||||
onCancelImprove: () -> Unit,
|
||||
onCancelProgress: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
onSubmit: (CatanMove) -> Unit,
|
||||
onStay: () -> Unit,
|
||||
onKeepPlaying: () -> Unit,
|
||||
onDismissResult: () -> Unit,
|
||||
onCancelVictim: () -> Unit,
|
||||
onCancelCards: () -> Unit,
|
||||
onCancelTrade: () -> Unit,
|
||||
) {
|
||||
val offer = state.pendingTrade
|
||||
val goodsOffer = state.ck?.goodsOffer
|
||||
when {
|
||||
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
|
||||
|
||||
confirmingEnd -> CatanEndGameDialog(onEnd = onExit, onKeepPlaying = onKeepPlaying)
|
||||
|
||||
state.phase == CatanPhase.GAME_OVER ->
|
||||
if (!resultDismissed) CatanGameOverDialog(state, seats, onExit, onDismissResult)
|
||||
|
||||
state.phase == CatanPhase.DISCARDING && state.discardSeat == localSeat ->
|
||||
CatanDiscardDialog(
|
||||
held = state.resources[localSeat],
|
||||
quota = CatanInteraction.discardQuota(state.resourceCount(localSeat)),
|
||||
legal = legal,
|
||||
onSubmit = onSubmit,
|
||||
)
|
||||
|
||||
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
|
||||
ChoreDialog(state, localSeat, legal, onSubmit)
|
||||
|
||||
state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.asking == localSeat ->
|
||||
CatanGoodsTradeResponseDialog(
|
||||
state = state,
|
||||
seat = localSeat,
|
||||
offer = goodsOffer,
|
||||
seats = seats,
|
||||
canAccept = CatanInteraction.tradeResponses(legal).first,
|
||||
onAccept = { onSubmit(AcceptTrade) },
|
||||
onDecline = { onSubmit(DeclineTrade) },
|
||||
onCounter = onSubmit,
|
||||
)
|
||||
|
||||
state.phase == CatanPhase.TRADE_COUNTERS && goodsOffer != null && state.turn == localSeat && legal.isNotEmpty() ->
|
||||
CatanGoodsCounterChoiceDialog(
|
||||
counters = CatanInteraction.goodsCounterChoices(state, legal),
|
||||
seats = seats,
|
||||
onTake = onSubmit,
|
||||
onNoDeal = { onSubmit(DismissCounters) },
|
||||
)
|
||||
|
||||
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat ->
|
||||
CatanTradeResponseDialog(
|
||||
state = state,
|
||||
seat = localSeat,
|
||||
offer = offer,
|
||||
seats = seats,
|
||||
canAccept = CatanInteraction.tradeResponses(legal).first,
|
||||
onAccept = { onSubmit(AcceptTrade) },
|
||||
onDecline = { onSubmit(DeclineTrade) },
|
||||
onCounter = onSubmit,
|
||||
)
|
||||
|
||||
state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() ->
|
||||
CatanCounterChoiceDialog(
|
||||
counters = CatanInteraction.counterChoices(state, legal),
|
||||
seats = seats,
|
||||
onTake = onSubmit,
|
||||
onNoDeal = { onSubmit(DismissCounters) },
|
||||
)
|
||||
|
||||
victimChoice != null ->
|
||||
CatanVictimDialog(
|
||||
victims = victimChoice.victims,
|
||||
seats = seats,
|
||||
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) },
|
||||
onCancel = onCancelVictim,
|
||||
)
|
||||
|
||||
knightMenu != null ->
|
||||
CatanKnightDialog(
|
||||
state = state,
|
||||
vertex = knightMenu,
|
||||
actions = CatanInteraction.knightActions(legal, knightMenu),
|
||||
onDo = onSubmit,
|
||||
onMove = onMoveKnight,
|
||||
onCancel = onCancelKnight,
|
||||
)
|
||||
|
||||
showImprove ->
|
||||
CatanImproveDialog(
|
||||
state = state,
|
||||
seat = localSeat,
|
||||
legal = legal,
|
||||
onBuy = onSubmit,
|
||||
onCancel = onCancelImprove,
|
||||
)
|
||||
|
||||
showProgress ->
|
||||
CatanProgressDialog(
|
||||
hand = progressHand,
|
||||
seats = seats,
|
||||
onPlay = onSubmit,
|
||||
onArm = onArmCard,
|
||||
onCancel = onCancelProgress,
|
||||
)
|
||||
|
||||
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
|
||||
|
||||
showTrade && state.isCk ->
|
||||
CatanGoodsTradeDialog(
|
||||
state = state,
|
||||
seat = localSeat,
|
||||
seats = seats,
|
||||
legal = legal,
|
||||
onBank = onSubmit,
|
||||
onOffer = onSubmit,
|
||||
onCancel = onCancelTrade,
|
||||
)
|
||||
|
||||
showTrade ->
|
||||
CatanTradeDialog(
|
||||
state = state,
|
||||
seat = localSeat,
|
||||
seats = seats,
|
||||
legal = legal,
|
||||
onBank = onSubmit,
|
||||
onOffer = onSubmit,
|
||||
onCancel = onCancelTrade,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog for the chore at the front of the queue, when it is the local
|
||||
* player's. Chores that are answered by tapping the board (a city to lose, a
|
||||
* metropolis site, a knight to place) have no dialog: the prompt line tells the
|
||||
* player what to tap.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChoreDialog(
|
||||
state: CatanState,
|
||||
localSeat: Int,
|
||||
legal: List<CatanMove>,
|
||||
onSubmit: (CatanMove) -> Unit,
|
||||
) {
|
||||
val ck = state.ck ?: return
|
||||
val check: (List<org.prolibertate.games.game.catan.Good>, (List<org.prolibertate.games.game.catan.Good>) -> CatanMove) -> Boolean =
|
||||
{ cards, make -> CatanRules.isLegalMove(state, localSeat, make(cards)) }
|
||||
when (val chore = ck.chores.firstOrNull()) {
|
||||
is DiscardChore -> {
|
||||
val quota = minOf(chore.count, CatanCk.goodsCount(state, localSeat))
|
||||
CatanGoodsPickDialog(
|
||||
title = "Discard $quota cards",
|
||||
intro = "Choose $quota cards to give up",
|
||||
held = CatanCk.goodsOf(state, localSeat),
|
||||
quota = quota,
|
||||
confirmLabel = "Discard",
|
||||
selectable = { true },
|
||||
allowed = { check(it, ::DiscardGoods) },
|
||||
onConfirm = { onSubmit(DiscardGoods(it)) },
|
||||
)
|
||||
}
|
||||
|
||||
is GiveChore -> {
|
||||
val pool = CatanCk.goodsOf(state, localSeat).filterKeys { !chore.commoditiesOnly || it.isCommodity }
|
||||
val quota = minOf(chore.count, pool.values.sum())
|
||||
CatanGoodsPickDialog(
|
||||
title = "Hand over $quota ${if (quota == 1) "card" else "cards"}",
|
||||
intro = if (chore.commoditiesOnly) "Choose a commodity to give" else "Choose which cards to give",
|
||||
held = pool,
|
||||
quota = quota,
|
||||
confirmLabel = "Give",
|
||||
selectable = { !chore.commoditiesOnly || it.isCommodity },
|
||||
allowed = { check(it, ::GiveGoods) },
|
||||
onConfirm = { onSubmit(GiveGoods(it)) },
|
||||
)
|
||||
}
|
||||
|
||||
is PickDeckChore -> CatanPickDeckDialog(legal) { onSubmit(it) }
|
||||
|
||||
is DropProgressChore -> CatanDropProgressDialog(ck.progressHands[localSeat]) { onSubmit(it) }
|
||||
|
||||
is AqueductChore -> CatanAqueductDialog(legal) { onSubmit(it) }
|
||||
|
||||
is SpyChore -> CatanSpyDialog(ck.progressHands[chore.target], legal) { onSubmit(it) }
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.prolibertate.games.game.catan.Commodity
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.Terrain
|
||||
import org.prolibertate.games.game.catan.Track
|
||||
|
||||
/**
|
||||
* Every visual decision for the Katan screen, in one place.
|
||||
*
|
||||
* **To restyle Katan, edit this file.** Nothing else in `ui/game/catan/` hard-
|
||||
* codes a colour or a proportion: the board, the hand, the player strip and the
|
||||
* dialogs all read a [CatanStyle] from [LocalCatanStyle]. A different art
|
||||
* direction is a different [CatanStyle] value (see [catanStyle]); a new theme
|
||||
* can be supplied at the screen's root with
|
||||
* `CompositionLocalProvider(LocalCatanStyle provides myStyle)`.
|
||||
*
|
||||
* Wording is the same story in [CatanText]; geometry is in [CatanGeometry].
|
||||
*
|
||||
* Proportions are fractions of the *hex radius* (centre of a hex to one of its
|
||||
* corners), so they scale with the board whatever the screen size.
|
||||
*/
|
||||
@Immutable
|
||||
data class CatanStyle(
|
||||
// --- Board surfaces -----------------------------------------------------
|
||||
/** The water around the island. */
|
||||
val sea: Color,
|
||||
/** Fill of each hex by terrain. */
|
||||
val terrain: Map<Terrain, Color>,
|
||||
/** Outline between hexes. */
|
||||
val hexEdge: Color,
|
||||
/** The number-token disc, and the ink for ordinary and hot (6 / 8) numbers. */
|
||||
val token: Color,
|
||||
val tokenInk: Color,
|
||||
val tokenHotInk: Color,
|
||||
/** The robber's pawn. */
|
||||
val robber: Color,
|
||||
/** Outline and wash on hexes that produced for the latest dice roll. */
|
||||
val rolled: Color,
|
||||
/** Harbour marker disc and its ink. */
|
||||
val portMarker: Color,
|
||||
val portInk: Color,
|
||||
|
||||
// --- Resources (hand tiles, trade pickers, harbour markers) -------------
|
||||
val resource: Map<Resource, Color>,
|
||||
val resourceInk: Color,
|
||||
|
||||
// --- Cities & Knights ---------------------------------------------------
|
||||
/** Commodity cards: cloth, coin and paper. */
|
||||
val commodity: Map<Commodity, Color> = LightCommodities,
|
||||
/** Ink on a commodity tile (paper is pale, so it is not white). */
|
||||
val commodityInk: Map<Commodity, Color> = LightCommodityInk,
|
||||
/** The colour of each improvement track, its city gate, its deck and its metropolis. */
|
||||
val track: Map<Track, Color> = LightTracks,
|
||||
/** Wall round a city, and the barbarian strip. */
|
||||
val wall: Color = Color(0xFF5D4037),
|
||||
val barbarian: Color = Color(0xFFB71C1C),
|
||||
/** The halo round a knight that is awake. White, so it is not mistaken for the yellow tap ring. */
|
||||
val knightReady: Color = Color(0xFFFFFFFF),
|
||||
|
||||
// --- Players ------------------------------------------------------------
|
||||
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
|
||||
val seats: List<Color>,
|
||||
/** Outline drawn round every piece so any seat colour reads on any terrain. */
|
||||
val pieceOutline: Color,
|
||||
|
||||
// --- Interaction feedback ----------------------------------------------
|
||||
/** Marks where the player may build or move right now. */
|
||||
val target: Color,
|
||||
/** The setup settlement that has been chosen and is waiting for its road. */
|
||||
val targetSelected: Color,
|
||||
|
||||
// --- Metrics (fractions of the hex radius) ------------------------------
|
||||
/** How far each tile polygon is pulled in from its corners, leaving a gap. */
|
||||
val hexInset: Float = 0.04f,
|
||||
val roadWidth: Float = 0.20f,
|
||||
val settlementSize: Float = 0.30f,
|
||||
val citySize: Float = 0.42f,
|
||||
val tokenRadius: Float = 0.36f,
|
||||
val robberSize: Float = 0.34f,
|
||||
val portRadius: Float = 0.46f,
|
||||
val targetRadius: Float = 0.20f,
|
||||
/** How close a tap must land to a vertex / edge / hex centre to count. */
|
||||
val tapReach: Float = 0.55f,
|
||||
val tokenText: Float = 0.34f,
|
||||
val portText: Float = 0.27f,
|
||||
val knightSize: Float = 0.30f,
|
||||
)
|
||||
|
||||
private val LightCommodities = mapOf(
|
||||
Commodity.CLOTH to Color(0xFF7E57C2),
|
||||
Commodity.COIN to Color(0xFFF9A825),
|
||||
Commodity.PAPER to Color(0xFFECEFF1),
|
||||
)
|
||||
|
||||
private val LightCommodityInk = mapOf(
|
||||
Commodity.CLOTH to Color(0xFFFFFFFF),
|
||||
Commodity.COIN to Color(0xFF3E2723),
|
||||
Commodity.PAPER to Color(0xFF263238),
|
||||
)
|
||||
|
||||
private val LightTracks = mapOf(
|
||||
Track.TRADE to Color(0xFFF2C300),
|
||||
Track.POLITICS to Color(0xFF1E6FD9),
|
||||
Track.SCIENCE to Color(0xFF2E9E4F),
|
||||
)
|
||||
|
||||
/** The light palette, which is also the reference for the dark one. */
|
||||
private val LightStyle = CatanStyle(
|
||||
sea = Color(0xFF3A7CA5),
|
||||
terrain = mapOf(
|
||||
Terrain.FOREST to Color(0xFF2F6B3A),
|
||||
Terrain.PASTURE to Color(0xFF9CCC65),
|
||||
Terrain.FIELDS to Color(0xFFE8C84A),
|
||||
Terrain.HILLS to Color(0xFFB4532F),
|
||||
Terrain.MOUNTAINS to Color(0xFF7C8794),
|
||||
Terrain.DESERT to Color(0xFFE6D3A0),
|
||||
),
|
||||
hexEdge = Color(0x55000000),
|
||||
token = Color(0xFFF7EFD9),
|
||||
tokenInk = Color(0xFF2B2B2B),
|
||||
tokenHotInk = Color(0xFFC62828),
|
||||
robber = Color(0xFF263238),
|
||||
rolled = Color(0xFFFFFFFF),
|
||||
portMarker = Color(0xFFFFF8E1),
|
||||
portInk = Color(0xFF2B2B2B),
|
||||
resource = mapOf(
|
||||
Resource.LUMBER to Color(0xFF2F6B3A),
|
||||
Resource.BRICK to Color(0xFFB4532F),
|
||||
Resource.WOOL to Color(0xFF8BC34A),
|
||||
Resource.GRAIN to Color(0xFFE0B020),
|
||||
Resource.ORE to Color(0xFF6B7684),
|
||||
),
|
||||
resourceInk = Color(0xFFFFFFFF),
|
||||
seats = listOf(
|
||||
Color(0xFF1565C0), // blue
|
||||
Color(0xFFEF6C00), // orange
|
||||
Color(0xFFF5F5F5), // white
|
||||
Color(0xFF8E24AA), // purple
|
||||
),
|
||||
pieceOutline = Color(0xFF1B1B1B),
|
||||
target = Color(0xFFFFEB3B),
|
||||
targetSelected = Color(0xFF00E5FF),
|
||||
)
|
||||
|
||||
/** Deeper sea and slightly dimmer land so the board is not a glare at night. */
|
||||
private val DarkStyle = LightStyle.copy(
|
||||
sea = Color(0xFF14354D),
|
||||
terrain = LightStyle.terrain.mapValues { (_, c) -> c.dim(0.82f) },
|
||||
token = Color(0xFFE9DFC4),
|
||||
)
|
||||
|
||||
/** The style for the current light / dark setting. */
|
||||
fun catanStyle(dark: Boolean): CatanStyle = if (dark) DarkStyle else LightStyle
|
||||
|
||||
/**
|
||||
* What the Katan composables read their look from. Defaults to the light style
|
||||
* so a preview or a test with no provider still draws something sensible;
|
||||
* [CatanScreen] provides the right one for the current theme.
|
||||
*/
|
||||
val LocalCatanStyle = staticCompositionLocalOf { LightStyle }
|
||||
|
||||
private fun Color.dim(factor: Float): Color =
|
||||
Color(red * factor, green * factor, blue * factor, alpha)
|
||||
@@ -0,0 +1,475 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.game.catan.AqueductChore
|
||||
import org.prolibertate.games.game.catan.CatanCk
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.DevCardType
|
||||
import org.prolibertate.games.game.catan.DiscardChore
|
||||
import org.prolibertate.games.game.catan.DropProgressChore
|
||||
import org.prolibertate.games.game.catan.EventFace
|
||||
import org.prolibertate.games.game.catan.GiveChore
|
||||
import org.prolibertate.games.game.catan.Good
|
||||
import org.prolibertate.games.game.catan.MetropolisChore
|
||||
import org.prolibertate.games.game.catan.PickDeckChore
|
||||
import org.prolibertate.games.game.catan.PillageChore
|
||||
import org.prolibertate.games.game.catan.PlaceKnightChore
|
||||
import org.prolibertate.games.game.catan.Port
|
||||
import org.prolibertate.games.game.catan.ProgressType
|
||||
import org.prolibertate.games.game.catan.RelocateChore
|
||||
import org.prolibertate.games.game.catan.RemoveKnightChore
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.SmithChore
|
||||
import org.prolibertate.games.game.catan.SpyChore
|
||||
import org.prolibertate.games.game.catan.Terrain
|
||||
import org.prolibertate.games.game.catan.Track
|
||||
|
||||
/**
|
||||
* Who is who at the table, for wording. [names] may be empty or short — a
|
||||
* client device does not always know everyone's name — and the rest fall back
|
||||
* to "Seat N".
|
||||
*/
|
||||
class CatanSeats(val me: Int, private val names: List<String> = emptyList()) {
|
||||
fun label(seat: Int): String = when {
|
||||
seat == me -> "You"
|
||||
else -> names.getOrNull(seat)?.takeIf { it.isNotBlank() } ?: "Seat ${seat + 1}"
|
||||
}
|
||||
|
||||
/** For mid-sentence use: "you" rather than "You". */
|
||||
fun lower(seat: Int): String = if (seat == me) "you" else label(seat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every word the Katan screen shows, in one place.
|
||||
*
|
||||
* **To change copy, or to localise, edit this file.** The composables never
|
||||
* build a sentence themselves; they ask for it here. Keeping the sentences as
|
||||
* plain functions of state also means they are unit-tested (`CatanTextTest`)
|
||||
* without a device.
|
||||
*
|
||||
* "Katan" is this app's name for the game (see `docs/RULES-catan.md`); the
|
||||
* resources are lumber, brick, wool, grain and ore.
|
||||
*/
|
||||
object CatanText {
|
||||
|
||||
const val TITLE = "Katan"
|
||||
|
||||
// --- Names ---------------------------------------------------------------
|
||||
|
||||
fun resource(resource: Resource): String = when (resource) {
|
||||
Resource.LUMBER -> "Lumber"
|
||||
Resource.BRICK -> "Brick"
|
||||
Resource.WOOL -> "Wool"
|
||||
Resource.GRAIN -> "Grain"
|
||||
Resource.ORE -> "Ore"
|
||||
}
|
||||
|
||||
fun terrain(terrain: Terrain): String = when (terrain) {
|
||||
Terrain.FOREST -> "Forest"
|
||||
Terrain.PASTURE -> "Pasture"
|
||||
Terrain.FIELDS -> "Fields"
|
||||
Terrain.HILLS -> "Hills"
|
||||
Terrain.MOUNTAINS -> "Mountains"
|
||||
Terrain.DESERT -> "Desert"
|
||||
}
|
||||
|
||||
fun devCard(type: DevCardType): String = when (type) {
|
||||
DevCardType.KNIGHT -> "Knight"
|
||||
DevCardType.ROAD_BUILDING -> "Road building"
|
||||
DevCardType.MONOPOLY -> "Monopoly"
|
||||
DevCardType.YEAR_OF_PLENTY -> "Year of plenty"
|
||||
DevCardType.VICTORY_POINT -> "Victory point"
|
||||
}
|
||||
|
||||
fun devCardHelp(type: DevCardType): String = when (type) {
|
||||
DevCardType.KNIGHT -> "Move the robber and steal a card. Three played knights take Largest Army."
|
||||
DevCardType.ROAD_BUILDING -> "Build two roads for free."
|
||||
DevCardType.MONOPOLY -> "Name a resource and take every card of it from the other players."
|
||||
DevCardType.YEAR_OF_PLENTY -> "Take any two resource cards from the bank."
|
||||
DevCardType.VICTORY_POINT -> "One victory point. Kept hidden until you win."
|
||||
}
|
||||
|
||||
/** "3:1" for a generic harbour, "2:1" for a resource one. */
|
||||
fun portRatio(port: Port): String = "${port.ratio}:1"
|
||||
|
||||
fun portName(port: Port): String =
|
||||
port.resource?.let { "${portRatio(port)} ${resource(it).lowercase()}" } ?: "3:1 any"
|
||||
|
||||
// --- Costs ---------------------------------------------------------------
|
||||
|
||||
fun cost(cost: Map<Resource, Int>): String =
|
||||
cost.entries.joinToString(" + ") { (r, n) -> "$n ${resource(r).lowercase()}" }
|
||||
|
||||
val ROAD_COST: String get() = cost(CatanRules.ROAD_COST)
|
||||
val SETTLEMENT_COST: String get() = cost(CatanRules.SETTLEMENT_COST)
|
||||
val CITY_COST: String get() = cost(CatanRules.CITY_COST)
|
||||
val DEV_COST: String get() = cost(CatanRules.DEV_COST)
|
||||
|
||||
// --- Buttons and headings --------------------------------------------------
|
||||
|
||||
const val ROLL = "Roll dice"
|
||||
const val END_TURN = "End turn"
|
||||
const val ROAD = "Road"
|
||||
const val SETTLEMENT = "Settlement"
|
||||
const val CITY = "City"
|
||||
const val BUY_CARD = "Buy card"
|
||||
const val PLAY_CARD = "Play card"
|
||||
const val TRADE = "Trade"
|
||||
const val CANCEL = "Cancel"
|
||||
const val CONFIRM = "Confirm"
|
||||
const val STOP_ROADS = "Done with roads"
|
||||
const val BUILD = "Build"
|
||||
const val COUNTER = "Counter…"
|
||||
const val SEND_COUNTER = "Send counter"
|
||||
const val EVERYONE = "Everyone"
|
||||
const val NO_DEAL = "No deal"
|
||||
|
||||
/**
|
||||
* The empty second line of a button that has no cost, so every button in the
|
||||
* action bar is the same height. A non-breaking space: blank, but a line.
|
||||
*/
|
||||
const val NO_COST = "\u00A0"
|
||||
const val YOUR_HAND = "Your hand"
|
||||
const val CARDS = "cards"
|
||||
const val ACCEPT = "Accept"
|
||||
const val DECLINE = "Decline"
|
||||
const val TRADE_WITH_PLAYER = "Offer to a player"
|
||||
const val TRADE_WITH_BANK = "Trade with the bank"
|
||||
const val YOU_GIVE = "You give"
|
||||
const val YOU_GET = "You get"
|
||||
const val SEND_OFFER = "Send offer"
|
||||
const val NO_LOG = "Nothing has happened yet."
|
||||
const val END_GAME = "End game"
|
||||
const val LOOK_AT_BOARD = "Look at the board"
|
||||
const val LEAVE_GAME = "Leave the game"
|
||||
|
||||
// --- The prompt line ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The one line that tells the player what is going on and what to do.
|
||||
*
|
||||
* [setupVertexChosen] is the setup settlement already picked and waiting
|
||||
* for its road; [building] is the build mode the player has entered, if any.
|
||||
*/
|
||||
fun prompt(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
setupVertexChosen: Boolean,
|
||||
building: BuildKind?,
|
||||
/** Cities & Knights: a knight has been picked to move, and its destination is awaited. */
|
||||
movingKnight: Boolean = false,
|
||||
/** Cities & Knights: a progress card is waiting for a tap on the board. */
|
||||
armed: ProgressType? = null,
|
||||
): String {
|
||||
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
|
||||
val me = seats.me
|
||||
val onClock = CatanRules.currentSeat(state)
|
||||
val mine = onClock == me
|
||||
val turn = seats.label(state.turn)
|
||||
|
||||
return when (state.phase) {
|
||||
CatanPhase.GAME_OVER -> {
|
||||
val winner = state.winner
|
||||
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
|
||||
}
|
||||
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
|
||||
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
|
||||
setupVertexChosen -> "Now tap the road to place beside that settlement."
|
||||
state.phase == CatanPhase.SETUP_SECOND ->
|
||||
"Place your second settlement. It earns resources from the hexes around it."
|
||||
|
||||
else -> "Place a settlement, then a road."
|
||||
}
|
||||
|
||||
CatanPhase.AWAIT_ROLL ->
|
||||
if (mine) "Your turn. Roll the dice." else "$turn is rolling…"
|
||||
|
||||
CatanPhase.DISCARDING -> {
|
||||
val discarder = state.discardSeat
|
||||
when {
|
||||
discarder == me -> "A seven! You hold ${state.resourceCount(me)} cards; " +
|
||||
"discard ${state.resourceCount(me) / 2}."
|
||||
|
||||
discarder != null -> "A seven! ${seats.label(discarder)} must discard half."
|
||||
else -> "A seven! Discarding…"
|
||||
}
|
||||
}
|
||||
|
||||
CatanPhase.ROBBER ->
|
||||
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…"
|
||||
|
||||
CatanPhase.PLAYING -> when {
|
||||
!mine -> "$turn is playing…"
|
||||
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||
else -> "Your turn. Build, trade, or end the turn."
|
||||
}
|
||||
|
||||
CatanPhase.FREE_ROADS ->
|
||||
if (mine) {
|
||||
"Road building: place ${state.pendingFreeRoads} more " +
|
||||
if (state.pendingFreeRoads == 1) "road." else "roads."
|
||||
} else {
|
||||
"$turn is placing free roads…"
|
||||
}
|
||||
|
||||
CatanPhase.CHORE -> "Waiting for ${seats.label(onClock ?: state.turn)}…"
|
||||
|
||||
CatanPhase.TRADE_PENDING -> {
|
||||
val offer = state.pendingTrade
|
||||
when {
|
||||
offer == null -> "Waiting for a reply…"
|
||||
offer.asking == me -> if (offer.to == null) {
|
||||
"${seats.label(offer.by)} offers the table a trade. Accept, decline or counter."
|
||||
} else {
|
||||
"${seats.label(offer.by)} offers you a trade. Accept, decline or counter."
|
||||
}
|
||||
|
||||
offer.by == me -> "Waiting for ${seats.label(offer.asking)} to answer your offer…"
|
||||
else -> "${seats.label(offer.by)}'s offer is with ${seats.label(offer.asking)}."
|
||||
}
|
||||
}
|
||||
|
||||
CatanPhase.TRADE_COUNTERS ->
|
||||
if (mine) {
|
||||
"Nobody accepted, but there are counter-offers. Take one or turn them down."
|
||||
} else {
|
||||
"$turn is weighing counter-offers…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "3 Lumber + 1 Brick" for a flat list of cards. */
|
||||
fun cards(cards: List<Resource>): String =
|
||||
cards.groupingBy { it }.eachCount().entries
|
||||
.sortedBy { it.key.ordinal }
|
||||
.joinToString(" + ") { (r, n) -> "$n ${resource(r)}" }
|
||||
.ifEmpty { "nothing" }
|
||||
|
||||
fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
|
||||
"$by offers ${cards(give)} to $to for ${cards(get)}"
|
||||
|
||||
/** One counter-offer as the offerer sees it: what that seat gives and wants. */
|
||||
fun counterSummary(by: String, give: List<Resource>, get: List<Resource>): String =
|
||||
"$by gives ${cards(give)} for your ${cards(get)}"
|
||||
|
||||
fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
|
||||
val winner = state.winner ?: return "Game over"
|
||||
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
|
||||
}
|
||||
|
||||
// --- Cities & Knights ----------------------------------------------------
|
||||
|
||||
fun good(good: Good): String = when (good) {
|
||||
Good.LUMBER -> "Lumber"
|
||||
Good.BRICK -> "Brick"
|
||||
Good.WOOL -> "Wool"
|
||||
Good.GRAIN -> "Grain"
|
||||
Good.ORE -> "Ore"
|
||||
Good.CLOTH -> "Cloth"
|
||||
Good.COIN -> "Coin"
|
||||
Good.PAPER -> "Paper"
|
||||
}
|
||||
|
||||
/** "2 Lumber + 1 Cloth" for a flat list of goods. */
|
||||
fun goods(cards: List<Good>): String =
|
||||
cards.groupingBy { it }.eachCount().entries
|
||||
.sortedBy { it.key.ordinal }
|
||||
.joinToString(" + ") { (g, n) -> "$n ${good(g)}" }
|
||||
.ifEmpty { "nothing" }
|
||||
|
||||
fun track(track: Track): String = when (track) {
|
||||
Track.TRADE -> "Trade"
|
||||
Track.POLITICS -> "Politics"
|
||||
Track.SCIENCE -> "Science"
|
||||
}
|
||||
|
||||
/** What the third level of a track gives, for the improvement dialog. */
|
||||
fun trackPower(track: Track): String = when (track) {
|
||||
Track.TRADE -> "From level 3, trade commodities with the bank two for one."
|
||||
Track.POLITICS -> "From level 3, knights can be promoted to mighty."
|
||||
Track.SCIENCE -> "From level 3, when the dice pay you nothing, take any resource."
|
||||
}
|
||||
|
||||
fun knightName(level: Int): String = when (level) {
|
||||
1 -> "Basic knight"
|
||||
2 -> "Strong knight"
|
||||
else -> "Mighty knight"
|
||||
}
|
||||
|
||||
fun event(face: EventFace?): String = when (face) {
|
||||
null -> "–"
|
||||
EventFace.SHIP -> "Ship"
|
||||
EventFace.TRADE -> "Trade"
|
||||
EventFace.POLITICS -> "Politics"
|
||||
EventFace.SCIENCE -> "Science"
|
||||
}
|
||||
|
||||
fun progress(kind: ProgressType): String = CatanCk.progressName(kind)
|
||||
|
||||
fun progressHelp(kind: ProgressType): String = when (kind) {
|
||||
ProgressType.ALCHEMIST -> "Before rolling, choose what the two number dice show."
|
||||
ProgressType.CRANE -> "Your next city improvement costs one commodity less."
|
||||
ProgressType.ENGINEER -> "Raise a wall round one of your cities for free."
|
||||
ProgressType.INVENTOR -> "Swap two number tokens (not 2, 6, 8 or 12)."
|
||||
ProgressType.IRRIGATION -> "Take 2 grain for each fields hex beside your settlements and cities."
|
||||
ProgressType.MEDICINE -> "Upgrade a settlement to a city for 2 ore and 1 grain."
|
||||
ProgressType.MINING -> "Take 2 ore for each mountains hex beside your settlements and cities."
|
||||
ProgressType.PRINTER -> "One victory point."
|
||||
ProgressType.ROAD_BUILDING -> "Build two roads for free."
|
||||
ProgressType.SMITH -> "Promote up to two of your knights one level for free."
|
||||
ProgressType.BISHOP -> "Move the robber and take a card from every player beside its new hex."
|
||||
ProgressType.CONSTITUTION -> "One victory point."
|
||||
ProgressType.DESERTER -> "Another player removes a knight; you place a free one of the same strength."
|
||||
ProgressType.DIPLOMAT -> "Remove an open road. If it was yours, build it again anywhere for free."
|
||||
ProgressType.INTRIGUE -> "Drive an opponent's knight off a crossing beside one of your roads."
|
||||
ProgressType.SABOTEUR -> "Every player with as many points as you or more discards half their cards."
|
||||
ProgressType.SPY -> "Look at another player's progress cards and take one."
|
||||
ProgressType.WARLORD -> "Wake all your knights without paying grain."
|
||||
ProgressType.WEDDING -> "Every player with more points gives you 2 cards."
|
||||
ProgressType.COMMERCIAL_HARBOR -> "Offer a resource to each player; each swaps it for a commodity."
|
||||
ProgressType.MASTER_MERCHANT -> "Take 2 cards from a player with more points."
|
||||
ProgressType.MERCHANT -> "Put the merchant beside your settlement: trade that resource 2:1 and score a point."
|
||||
ProgressType.MERCHANT_FLEET -> "This turn, trade one chosen card type with the bank two for one."
|
||||
ProgressType.RESOURCE_MONOPOLY -> "Name a resource. Every other player gives you 2 of it."
|
||||
ProgressType.TRADE_MONOPOLY -> "Name a commodity. Every other player gives you 1 of it."
|
||||
}
|
||||
|
||||
const val IMPROVE = "Improve"
|
||||
const val KNIGHTS = "Knights"
|
||||
const val CARDS_BUTTON = "Progress"
|
||||
const val WALL = "Wall"
|
||||
const val KNIGHT = "Knight"
|
||||
const val STOP_SMITH = "Done promoting"
|
||||
|
||||
val KNIGHT_COST: String get() = cost(CatanCk.KNIGHT_COST)
|
||||
val ACTIVATE_COST: String get() = cost(CatanCk.ACTIVATE_COST)
|
||||
val WALL_COST: String get() = cost(CatanCk.WALL_COST)
|
||||
|
||||
private fun ckPrompt(
|
||||
state: CatanState,
|
||||
seats: CatanSeats,
|
||||
setupVertexChosen: Boolean,
|
||||
building: BuildKind?,
|
||||
movingKnight: Boolean,
|
||||
armed: ProgressType?,
|
||||
): String {
|
||||
val me = seats.me
|
||||
val onClock = CatanRules.currentSeat(state)
|
||||
val mine = onClock == me
|
||||
val turn = seats.label(state.turn)
|
||||
val ck = state.ck!!
|
||||
|
||||
return when (state.phase) {
|
||||
CatanPhase.GAME_OVER -> {
|
||||
val winner = state.winner
|
||||
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
|
||||
}
|
||||
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
|
||||
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
|
||||
setupVertexChosen -> "Now tap the road to place beside that settlement."
|
||||
state.phase == CatanPhase.SETUP_SECOND ->
|
||||
"Place your second piece: it is a city, and earns resources from the hexes around it."
|
||||
|
||||
else -> "Place a settlement, then a road."
|
||||
}
|
||||
|
||||
CatanPhase.AWAIT_ROLL -> when {
|
||||
!mine -> "$turn is rolling…"
|
||||
armed != null -> armedPrompt(armed)
|
||||
movingKnight -> "Tap where the knight should go."
|
||||
else -> "Your turn. Roll the dice, or wake knights and play progress cards first."
|
||||
}
|
||||
|
||||
CatanPhase.ROBBER -> when {
|
||||
!mine -> "$turn is moving the robber…"
|
||||
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
|
||||
else -> "Move the robber. Tap a hex."
|
||||
}
|
||||
|
||||
CatanPhase.PLAYING -> when {
|
||||
!mine -> "$turn is playing…"
|
||||
armed != null -> armedPrompt(armed)
|
||||
movingKnight -> "Tap where the knight should go."
|
||||
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||
building == BuildKind.WALL -> "Tap one of your cities to raise a wall."
|
||||
building == BuildKind.KNIGHT -> "Tap an empty crossing at the end of your road to hire a knight."
|
||||
else -> "Your turn. Build, improve, trade, or end the turn. Tap a knight to use it."
|
||||
}
|
||||
|
||||
CatanPhase.FREE_ROADS ->
|
||||
if (mine) {
|
||||
"Place ${state.pendingFreeRoads} free " +
|
||||
if (state.pendingFreeRoads == 1) "road." else "roads."
|
||||
} else {
|
||||
"$turn is placing free roads…"
|
||||
}
|
||||
|
||||
CatanPhase.TRADE_PENDING -> {
|
||||
val offer = ck.goodsOffer
|
||||
val asking = offer?.asking?.takeIf { it >= 0 }
|
||||
when {
|
||||
offer == null || asking == null -> "Waiting for a reply…"
|
||||
asking == me -> "${seats.label(offer.by)} offers you a trade."
|
||||
offer.by == me -> "Waiting for ${seats.label(asking)} to answer your offer…"
|
||||
else -> "${seats.label(offer.by)} has offered ${seats.label(asking)} a trade."
|
||||
}
|
||||
}
|
||||
|
||||
CatanPhase.TRADE_COUNTERS ->
|
||||
if (mine) {
|
||||
"Nobody accepted, but there are counter-offers. Take one or turn them down."
|
||||
} else {
|
||||
"$turn is weighing counter-offers…"
|
||||
}
|
||||
|
||||
CatanPhase.DISCARDING -> "Discarding…"
|
||||
|
||||
CatanPhase.CHORE -> {
|
||||
val chore = ck.chores.firstOrNull()
|
||||
val who = seats.label(chore?.seat ?: state.turn)
|
||||
if (chore == null || chore.seat != me) {
|
||||
when (chore) {
|
||||
is PillageChore -> "The barbarians have landed. $who must give up a city."
|
||||
is DiscardChore -> "$who must discard ${chore.count} cards."
|
||||
else -> "Waiting for $who…"
|
||||
}
|
||||
} else {
|
||||
when (chore) {
|
||||
is DiscardChore -> "Discard ${chore.count} cards."
|
||||
is GiveChore -> "Give ${chore.count} card${if (chore.count == 1) "" else "s"} to ${seats.lower(chore.to)}."
|
||||
is PillageChore -> "The barbarians won. Tap a city of yours to give up."
|
||||
is PickDeckChore -> "Choose a progress deck to draw from."
|
||||
is DropProgressChore -> "You hold too many progress cards. Give one back."
|
||||
is MetropolisChore -> "Tap the city that will bear the ${track(chore.track).lowercase()} metropolis."
|
||||
is AqueductChore -> "Your aqueduct pays. Take any resource."
|
||||
is RelocateChore -> "Your knight was driven off. Tap where it goes."
|
||||
is RemoveKnightChore -> "Tap one of your knights to remove."
|
||||
is PlaceKnightChore -> "Tap where your free ${knightName(chore.level).lowercase()} stands."
|
||||
is SpyChore -> "Choose a progress card to take."
|
||||
is SmithChore -> "Tap a knight to promote (${chore.left} left), or stop."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun armedPrompt(kind: ProgressType): String = when (kind) {
|
||||
ProgressType.ENGINEER -> "Engineer: tap a city to wall."
|
||||
ProgressType.MEDICINE -> "Medicine: tap a settlement to upgrade."
|
||||
ProgressType.MERCHANT -> "Merchant: tap a hex beside one of your buildings."
|
||||
ProgressType.DIPLOMAT -> "Diplomat: tap an open road to remove."
|
||||
ProgressType.INTRIGUE -> "Intrigue: tap an enemy knight beside your road."
|
||||
ProgressType.INVENTOR -> "Inventor: tap two hexes to swap their tokens."
|
||||
else -> "Tap the board."
|
||||
}
|
||||
|
||||
/** "6 steps away", "1 step away" for the barbarian ship. */
|
||||
fun barbarianSteps(steps: Int): String = "$steps ${if (steps == 1) "step" else "steps"} away"
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
package org.prolibertate.games.update
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Which line of builds an APK belongs to.
|
||||
*
|
||||
* This is a property of the build, never a preference. The two channels are
|
||||
* published by different workflows and have entirely separate version
|
||||
* sequences — a dev build's versionCode is its CI build number, a production
|
||||
* build's is its release build number, and the two are unrelated. There is no
|
||||
* sequences — a dev build's versionCode is its CI run number, a production
|
||||
* build's is its release run number, and the two are unrelated. There is no
|
||||
* meaning to be had from comparing across them, so nothing does.
|
||||
*
|
||||
* They also carry different applicationIds — a dev build installs as
|
||||
* `…pilgrimsofkatan.dev` — so both can sit on one device, each updating
|
||||
* itself down its own line and leaving the other alone.
|
||||
* `…games.dev` — so both can sit on one device, each updating itself down its
|
||||
* own line and leaving the other alone.
|
||||
*/
|
||||
enum class UpdateChannel(val label: String, val blurb: String) {
|
||||
PRODUCTION("Production", "Stable builds released from main"),
|
||||
@@ -23,7 +21,7 @@ enum class UpdateChannel(val label: String, val blurb: String) {
|
||||
/** Marks a tag or a version name as belonging to the dev channel. */
|
||||
const val DEV_SUFFIX = "-dev"
|
||||
|
||||
/** One published build, as read off a repository release. */
|
||||
/** One published build, as read off a channel's `latest.json`. */
|
||||
data class Release(
|
||||
val tag: String,
|
||||
val versionCode: Int,
|
||||
@@ -51,14 +49,14 @@ fun channelOfVersionName(versionName: String): UpdateChannel =
|
||||
* The channel a release belongs to.
|
||||
*
|
||||
* Two independent marks, and either is enough: the `-dev` suffix the workflow
|
||||
* puts on the tag, and the release's own prerelease flag. A release would have
|
||||
* to lose both to be mistaken for the other channel.
|
||||
* puts on the tag, and GitHub's own prerelease flag. A release would have to
|
||||
* lose both to be mistaken for the other channel.
|
||||
*/
|
||||
fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
|
||||
if (tag.endsWith(DEV_SUFFIX) || prerelease) UpdateChannel.DEV else UpdateChannel.PRODUCTION
|
||||
|
||||
/**
|
||||
* The build number out of a `v<series>.<n>` tag, which is what an APK's
|
||||
* The run number out of a `v<series>.<n>` tag, which is what an APK's
|
||||
* versionCode is.
|
||||
*
|
||||
* Only the trailing number is read. The series is a name for people, and
|
||||
@@ -67,37 +65,15 @@ fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
|
||||
fun versionCodeOfTag(tag: String): Int? =
|
||||
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
|
||||
* nothing at all.
|
||||
*
|
||||
* Only an actual update is worth a word. Opening the app is not asking a
|
||||
* question, so a check that finds nothing — or that cannot reach the release
|
||||
* server at all — stays quiet; a phone on a train would otherwise be told off
|
||||
* for it every single launch. The button in Settings *is* asking a question, and
|
||||
* reports every answer including those two.
|
||||
* question, so a check that finds nothing — or that cannot reach the update server at all
|
||||
* — stays quiet; a phone on a train would otherwise be told off for it every
|
||||
* single launch. The button in Settings *is* asking a question, and reports
|
||||
* every answer including those two.
|
||||
*/
|
||||
fun launchOffer(verdict: UpdateVerdict): Release? =
|
||||
(verdict as? UpdateVerdict.Install)?.release
|
||||
|
||||
@@ -8,36 +8,17 @@ import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.prolibertate.games.BuildConfig
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Over-the-air updates from the repository's releases.
|
||||
* Over-the-air updates from plg.wallacenet.ca (Cloudflare Pages).
|
||||
*
|
||||
* The channel is the Gitea instance this app ships from
|
||||
* (`git.wallacenet.ca`), not GitHub. Gitea's release API answers with the
|
||||
* 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.
|
||||
* Release tags are v<series>.<run-number> and the APK's versionCode is that same run
|
||||
* number, so comparing the two is enough to tell whether a newer build exists.
|
||||
* Everything there is public — no token is embedded in the app.
|
||||
*
|
||||
* 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
|
||||
@@ -52,11 +33,6 @@ class Updater(private val activity: Activity) {
|
||||
* this phone is on, and the phone already knows.
|
||||
*/
|
||||
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 {
|
||||
val channel = installedChannel()
|
||||
verdictFor(
|
||||
@@ -70,70 +46,37 @@ class Updater(private val activity: Activity) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Production reads the "latest release", which by definition skips
|
||||
* prereleases — Gitea implements it as the most recent non-prerelease,
|
||||
* 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.
|
||||
* Each channel publishes a `latest.json` next to its APK on Cloudflare
|
||||
* Pages: `/prod/latest.json` and `/dev/latest.json`. The two live in one
|
||||
* site but separate paths, so a build can only ever look down its own line.
|
||||
*/
|
||||
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) {
|
||||
UpdateChannel.PRODUCTION ->
|
||||
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 fetchLatestRelease(channel: UpdateChannel): Release? {
|
||||
val path = if (channel == UpdateChannel.DEV) "dev" else "prod"
|
||||
return getJsonObject("$BASE_URL/$path/latest.json")?.let { toRelease(it) }
|
||||
}
|
||||
|
||||
private fun toRelease(json: JSONObject): Release? {
|
||||
val tag = json.optString("tag_name").ifBlank { return null }
|
||||
val tag = json.optString("tag").ifBlank { return null }
|
||||
val versionCode = versionCodeOfTag(tag) ?: return null
|
||||
val 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
|
||||
|
||||
val apkUrl = json.optString("apkUrl").ifBlank { return null }
|
||||
return Release(
|
||||
tag = tag,
|
||||
versionCode = versionCode,
|
||||
apkUrl = onReportedHost(chosen.getString("browser_download_url"), SERVER_HOST),
|
||||
apkName = chosen.optString("name"),
|
||||
channel = channel,
|
||||
apkUrl = apkUrl,
|
||||
apkName = json.optString("apkName"),
|
||||
channel = channelOfTag(tag, prerelease = false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun openConnection(url: String): HttpURLConnection =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
private fun getJsonObject(url: String): JSONObject? {
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("Accept", "application/json")
|
||||
setRequestProperty("User-Agent", "PilgrimsOfKatan-Updater")
|
||||
setRequestProperty("User-Agent", "ProLibertateGames-Updater")
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 15_000
|
||||
}
|
||||
|
||||
private fun getJsonObject(url: String): JSONObject? {
|
||||
val connection = openConnection(url)
|
||||
try {
|
||||
// 404 means the channel has nothing published yet.
|
||||
if (connection.responseCode != 200) return null
|
||||
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
|
||||
} finally {
|
||||
@@ -141,16 +84,6 @@ class Updater(private val activity: Activity) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getJsonArray(url: String): JSONArray? {
|
||||
val connection = openConnection(url)
|
||||
try {
|
||||
if (connection.responseCode != 200) return null
|
||||
return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read back from the installed APK's own version name. */
|
||||
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
|
||||
|
||||
@@ -202,13 +135,6 @@ class Updater(private val activity: Activity) {
|
||||
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
|
||||
|
||||
private companion object {
|
||||
/** 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"
|
||||
const val BASE_URL = "https://plg.wallacenet.ca"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,20 @@ class CatalogTest {
|
||||
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a preview game opens on a dev build and nowhere else`() {
|
||||
val preview = GameCatalog.all.filter { it.devPreview }
|
||||
assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN))
|
||||
preview.forEach { game ->
|
||||
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
|
||||
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
|
||||
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
|
||||
}
|
||||
// An ordinary finished game opens everywhere.
|
||||
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
|
||||
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hiding unfinished games still leaves something to play in every menu`() {
|
||||
// A menu filtered down to nothing would leave a stranded heading, and a
|
||||
|
||||
@@ -142,4 +142,50 @@ class CatanAiTest {
|
||||
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||
assertNull(after.pendingTrade)
|
||||
}
|
||||
|
||||
private fun tradeTable(offer: TradeOffer, vararg hands: Map<Resource, Int>): CatanState =
|
||||
CatanRules.initialState(config(CatanOptions(), seed = 7L)).copy(
|
||||
phase = CatanPhase.TRADE_PENDING,
|
||||
turn = 0,
|
||||
turnNumber = 1,
|
||||
pendingTrade = offer,
|
||||
resources = hands.toList(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the AI accepts an offer that gives it more than it costs, and declines the reverse`() {
|
||||
val generous = tradeTable(
|
||||
TradeOffer(by = 0, to = null, give = listOf(Resource.ORE, Resource.ORE), get = listOf(Resource.WOOL), asking = 1),
|
||||
hand(Resource.ORE to 2), hand(Resource.WOOL to 2), hand(),
|
||||
)
|
||||
assertEquals(AcceptTrade, CatanAi().chooseMove(generous, 1, CatanRules.legalMoves(generous, 1)))
|
||||
|
||||
val stingy = tradeTable(
|
||||
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = 1),
|
||||
hand(Resource.WOOL to 1), hand(Resource.ORE to 2), hand(),
|
||||
)
|
||||
assertEquals(DeclineTrade, CatanAi().chooseMove(stingy, 1, CatanRules.legalMoves(stingy, 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an AI offerer takes the best counter and refuses a losing one`() {
|
||||
val counters = listOf(
|
||||
TradeCounter(by = 1, give = listOf(Resource.ORE), get = listOf(Resource.LUMBER)),
|
||||
TradeCounter(by = 2, give = listOf(Resource.ORE, Resource.GRAIN), get = listOf(Resource.WOOL)),
|
||||
)
|
||||
val state = tradeTable(
|
||||
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = -1, counters = counters),
|
||||
hand(Resource.LUMBER to 1, Resource.WOOL to 1), hand(Resource.ORE to 1), hand(Resource.ORE to 1, Resource.GRAIN to 1),
|
||||
).copy(phase = CatanPhase.TRADE_COUNTERS)
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
assertEquals(AcceptCounter(2), CatanAi().chooseMove(state, 0, legal))
|
||||
|
||||
val bad = state.copy(
|
||||
pendingTrade = state.pendingTrade!!.copy(
|
||||
counters = listOf(TradeCounter(by = 1, give = listOf(Resource.WOOL), get = listOf(Resource.LUMBER))),
|
||||
),
|
||||
resources = listOf(hand(Resource.LUMBER to 1), hand(Resource.WOOL to 1), hand()),
|
||||
)
|
||||
assertEquals(DismissCounters, CatanAi().chooseMove(bad, 0, CatanRules.legalMoves(bad, 0)))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
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
|
||||
|
||||
/**
|
||||
* Engines playing Cities & Knights against each other. These are the
|
||||
* playtests: every move is checked for legality, the state is round-tripped
|
||||
* through its wire format as it goes, nothing may leave a seat without a move,
|
||||
* and the game has to reach a result.
|
||||
*/
|
||||
class CatanCkSelfPlayTest {
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
/** What one game looked like, for the summary printed at the end. */
|
||||
private data class Report(
|
||||
val seed: Long,
|
||||
val moves: Int,
|
||||
val turns: Int,
|
||||
val attacks: Int,
|
||||
val progressPlayed: Int,
|
||||
val metropolises: Int,
|
||||
val winner: Int?,
|
||||
val points: List<Int>,
|
||||
)
|
||||
|
||||
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 30_000): Pair<CatanState, Report> {
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(options, seed))
|
||||
var moves = 0
|
||||
var attacks = 0
|
||||
var progress = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("seed $seed ran past $cap moves; log tail: ${state.log.takeLast(8)}", moves++ < cap)
|
||||
val seat = CatanRules.currentSeat(state)
|
||||
?: error("seed $seed: nobody on the clock in ${state.phase}; log tail: ${state.log.takeLast(8)}")
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
assertTrue("seed $seed: seat $seat has no legal move in ${state.phase}", legal.isNotEmpty())
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue(
|
||||
"seed $seed: seat $seat chose the illegal move $move in ${state.phase}; " +
|
||||
"log tail: ${state.log.takeLast(5)}",
|
||||
CatanRules.isLegalMove(state, seat, move),
|
||||
)
|
||||
val before = state.ck!!.attacked
|
||||
val wasAttacks = state.log.count { it.contains("The barbarians land") }
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
attacks += state.log.count { it.contains("The barbarians land") } - wasAttacks
|
||||
if (move is PlayProgress) progress++
|
||||
if (!before && state.ck!!.attacked) Unit
|
||||
|
||||
if (moves % 97 == 0) {
|
||||
val back = CatanRules.decodeState(CatanRules.encodeState(state))
|
||||
assertEquals("seed $seed: the state did not survive its wire format", state, back)
|
||||
// A seat's view never carries another seat's progress cards or the decks.
|
||||
for (viewer in 0 until options.playerCount) {
|
||||
val view = CatanRules.viewFor(state, viewer)
|
||||
assertTrue(view.ck!!.decks.values.all { it.isEmpty() })
|
||||
for (other in 0 until options.playerCount) {
|
||||
if (other != viewer) {
|
||||
val spied = (state.ck!!.chores.firstOrNull() as? SpyChore)
|
||||
val allowed = spied != null && spied.seat == viewer && spied.target == other
|
||||
if (!allowed) assertTrue(view.ck!!.progressHands[other].isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
checkInvariants(state, seed)
|
||||
}
|
||||
val ck = state.ck!!
|
||||
return state to Report(
|
||||
seed = seed,
|
||||
moves = moves,
|
||||
turns = state.turnNumber,
|
||||
attacks = attacks,
|
||||
progressPlayed = progress,
|
||||
metropolises = ck.metropolises.size,
|
||||
winner = state.winner,
|
||||
points = CatanRules.publicVictoryPoints(state),
|
||||
)
|
||||
}
|
||||
|
||||
/** Things that must hold in every state of every game. */
|
||||
private fun checkInvariants(state: CatanState, seed: Long) {
|
||||
val ck = state.ck!!
|
||||
val tag = "seed $seed turn ${state.turnNumber}"
|
||||
// Cards are conserved: no good is held in more copies than the bank started with.
|
||||
for (good in Good.entries) {
|
||||
val held = (0 until state.playerCount).sumOf { CatanCk.goodsOf(state, it)[good] ?: 0 }
|
||||
val cap = if (good.isCommodity) BANK_PER_COMMODITY else BANK_PER_RESOURCE
|
||||
assertTrue("$tag: $held ${good} cards exist, the bank only had $cap", held in 0..cap)
|
||||
}
|
||||
for (seat in 0 until state.playerCount) {
|
||||
assertTrue(
|
||||
"$tag: seat $seat holds negative cards",
|
||||
CatanCk.goodsOf(state, seat).values.all { it >= 0 },
|
||||
)
|
||||
for (level in 1..3) {
|
||||
assertTrue(
|
||||
"$tag: seat $seat has more than two level-$level knights",
|
||||
ck.knights.count { it.owner == seat && it.level == level } <= KNIGHTS_PER_LEVEL,
|
||||
)
|
||||
}
|
||||
assertTrue("$tag: too many progress cards in seat $seat's hand", ck.progressHands[seat].size <= PROGRESS_HAND_MAX + 1)
|
||||
assertTrue("$tag: seat $seat has more than three walls", CatanCk.wallsOf(state, seat) <= MAX_WALLS)
|
||||
assertTrue(
|
||||
"$tag: seat $seat has more than five settlements",
|
||||
state.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) } <= MAX_SETTLEMENTS,
|
||||
)
|
||||
assertTrue(
|
||||
"$tag: seat $seat has more than four cities",
|
||||
state.buildings.count { it == CatanBuilding(BuildingType.CITY, seat) } <= MAX_CITIES,
|
||||
)
|
||||
}
|
||||
// A knight stands on a crossing nothing else stands on, and no two share one.
|
||||
assertEquals("$tag: two knights share a crossing", ck.knights.size, ck.knights.map { it.vertex }.toSet().size)
|
||||
for (k in ck.knights) {
|
||||
assertTrue("$tag: a knight stands on a building at ${k.vertex}", state.buildings[k.vertex] == null)
|
||||
}
|
||||
// Metropolises stand on cities of their owners.
|
||||
for ((_, m) in ck.metropolises) {
|
||||
assertEquals(
|
||||
"$tag: a metropolis is not on its owner's city",
|
||||
CatanBuilding(BuildingType.CITY, m.seat),
|
||||
state.buildings[m.vertex],
|
||||
)
|
||||
}
|
||||
// Every progress card exists exactly once, in a deck or a hand, or was played face up.
|
||||
val cards = ck.decks.values.sumOf { it.size } + ck.progressHands.sumOf { it.size } + ck.progressVp.sum()
|
||||
assertEquals("$tag: progress cards were lost or duplicated", 54, cards)
|
||||
assertTrue("$tag: barbarians out of range", ck.barbarians in 1..BARBARIAN_STEPS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three engines play Cities and Knights to a finish`() {
|
||||
val reports = (1L..12L).map { seed ->
|
||||
val (state, report) = playOut(
|
||||
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
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!!) >= 13,
|
||||
)
|
||||
report
|
||||
}
|
||||
reports.forEach { println("CK3 $it") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four engines play Cities and Knights to a finish`() {
|
||||
val reports = (101L..108L).map { seed ->
|
||||
val (state, report) = playOut(
|
||||
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
seed,
|
||||
)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
report
|
||||
}
|
||||
reports.forEach { println("CK4 $it") }
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,89 @@ class CatanRulesTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** Seat 0 owns a settlement at vertex 0 and a two-road chain v0 - w - x; `beyond` continues past x. */
|
||||
private data class Chain(val state: CatanState, val x: Int, val beyond: Int, val spur: Int?)
|
||||
|
||||
private fun chain(): Chain {
|
||||
val e1 = CatanBoard.VERTEX_EDGES[0][0]
|
||||
val w = CatanBoard.otherEnd(e1, 0)
|
||||
val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 }
|
||||
val x = CatanBoard.otherEnd(e2, w)
|
||||
val beyond = CatanBoard.VERTEX_EDGES[x].first { it != e2 }
|
||||
val spur = CatanBoard.VERTEX_EDGES[w].firstOrNull { it != e1 && it != e2 }
|
||||
val state = craft()
|
||||
.copy(resources = hands(hand(Resource.LUMBER to 5, Resource.BRICK to 5)))
|
||||
.withBuilding(0, BuildingType.SETTLEMENT, 0)
|
||||
.withRoad(e1, 0)
|
||||
.withRoad(e2, 0)
|
||||
return Chain(state, x, beyond, spur)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a road cannot be built past an opponent's settlement`() {
|
||||
val (open, x, beyond, spur) = chain()
|
||||
assertTrue("open crossing: the road continues", BuildRoad(beyond) in CatanRules.legalMoves(open, 0))
|
||||
|
||||
val blocked = open.withBuilding(x, BuildingType.SETTLEMENT, 1)
|
||||
assertFalse(
|
||||
"an opponent's settlement on the crossing breaks the network",
|
||||
BuildRoad(beyond) in CatanRules.legalMoves(blocked, 0),
|
||||
)
|
||||
// The same through a city, and while laying free roads from Road Building.
|
||||
val city = open.withBuilding(x, BuildingType.CITY, 1)
|
||||
assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(city, 0))
|
||||
val free = blocked.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||
assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(free, 0))
|
||||
// The rest of the network is untouched: the spur at the other crossing still works.
|
||||
if (spur != null) assertTrue(BuildRoad(spur) in CatanRules.legalMoves(blocked, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `your own settlement on the crossing does not block your road`() {
|
||||
val (open, x, beyond, _) = chain()
|
||||
val own = open.withBuilding(x, BuildingType.SETTLEMENT, 0)
|
||||
assertTrue(BuildRoad(beyond) in CatanRules.legalMoves(own, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a player runs out of pieces`() {
|
||||
val rich = hands(
|
||||
hand(
|
||||
Resource.LUMBER to 9, Resource.BRICK to 9, Resource.WOOL to 9,
|
||||
Resource.GRAIN to 9, Resource.ORE to 9,
|
||||
),
|
||||
)
|
||||
|
||||
// Roads: fifteen, and no sixteenth - in the main phase or from Road Building.
|
||||
var roads = craft().copy(resources = rich).withBuilding(0, BuildingType.SETTLEMENT, 0)
|
||||
repeat(14) {
|
||||
roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance<BuildRoad>().first().edge, 0)
|
||||
}
|
||||
assertTrue("fourteen laid: one more is allowed", CatanRules.legalMoves(roads, 0).any { it is BuildRoad })
|
||||
roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance<BuildRoad>().first().edge, 0)
|
||||
assertEquals(15, roads.roads.count { it == 0 })
|
||||
assertTrue("fifteen laid: none left", CatanRules.legalMoves(roads, 0).none { it is BuildRoad })
|
||||
val free = roads.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||
assertEquals(listOf<CatanMove>(EndFreeRoads), CatanRules.legalMoves(free, 0))
|
||||
|
||||
// Settlements: five.
|
||||
val homes = scattered(7)
|
||||
var settled = craft().copy(resources = rich)
|
||||
for (i in 0 until 4) settled = settled.withBuilding(homes[i], BuildingType.SETTLEMENT, 0)
|
||||
settled = settled.withRoad(CatanBoard.VERTEX_EDGES[homes[5]][0], 0)
|
||||
assertTrue("four standing: a fifth is allowed", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0))
|
||||
settled = settled.withBuilding(homes[4], BuildingType.SETTLEMENT, 0)
|
||||
assertFalse("five standing: no sixth", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0))
|
||||
|
||||
// Cities: four. A raised settlement returns to the supply, so five settlements stays possible.
|
||||
var raised = craft().copy(resources = rich)
|
||||
for (i in 0 until 3) raised = raised.withBuilding(homes[i], BuildingType.CITY, 0)
|
||||
raised = raised.withBuilding(homes[3], BuildingType.SETTLEMENT, 0)
|
||||
assertTrue("three cities: a fourth is allowed", BuildCity(homes[3]) in CatanRules.legalMoves(raised, 0))
|
||||
raised = raised.withBuilding(homes[3], BuildingType.CITY, 0).withBuilding(homes[4], BuildingType.SETTLEMENT, 0)
|
||||
assertFalse("four cities: no fifth", BuildCity(homes[4]) in CatanRules.legalMoves(raised, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settlement needs an open vertex on the network, a city one of yours`() {
|
||||
val base = craft()
|
||||
@@ -437,6 +520,113 @@ class CatanRulesTest {
|
||||
assertEquals(listOf<CatanMove>(DeclineTrade), CatanRules.legalMoves(offered, 1))
|
||||
}
|
||||
|
||||
private val twoLumberForBrick =
|
||||
OfferTrade(to = null, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK))
|
||||
|
||||
private fun openTable() = craft().copy(
|
||||
resources = hands(
|
||||
hand(Resource.LUMBER to 3),
|
||||
hand(Resource.BRICK to 2),
|
||||
hand(Resource.BRICK to 1, Resource.WOOL to 1),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an open offer asks each other seat in turn until one accepts`() {
|
||||
val state = openTable()
|
||||
assertTrue(CatanRules.isLegalMove(state, 0, twoLumberForBrick))
|
||||
val asked1 = CatanRules.applyMove(state, 0, twoLumberForBrick)
|
||||
assertEquals(CatanPhase.TRADE_PENDING, asked1.phase)
|
||||
assertEquals(1, CatanRules.currentSeat(asked1))
|
||||
|
||||
val asked2 = CatanRules.applyMove(asked1, 1, DeclineTrade)
|
||||
assertEquals("a decline passes the offer on", 2, CatanRules.currentSeat(asked2))
|
||||
assertEquals(CatanPhase.TRADE_PENDING, asked2.phase)
|
||||
|
||||
val done = CatanRules.applyMove(asked2, 2, AcceptTrade)
|
||||
assertEquals(CatanPhase.PLAYING, done.phase)
|
||||
assertNull(done.pendingTrade)
|
||||
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), done.resources[0])
|
||||
assertEquals(hand(Resource.LUMBER to 2, Resource.WOOL to 1), done.resources[2])
|
||||
assertEquals("seat 1 never took part", state.resources[1], done.resources[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an open offer nobody wants lapses back to the offerer`() {
|
||||
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
|
||||
table = CatanRules.applyMove(table, 1, DeclineTrade)
|
||||
table = CatanRules.applyMove(table, 2, DeclineTrade)
|
||||
assertEquals(CatanPhase.PLAYING, table.phase)
|
||||
assertNull(table.pendingTrade)
|
||||
assertEquals(0, CatanRules.currentSeat(table))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seats with no cards are not asked`() {
|
||||
val state = openTable().copy(resources = hands(hand(Resource.LUMBER to 3), hand(), hand(Resource.BRICK to 1)))
|
||||
val table = CatanRules.applyMove(state, 0, twoLumberForBrick)
|
||||
assertEquals(2, CatanRules.currentSeat(table))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a counter is kept and the offerer chooses once everyone has answered`() {
|
||||
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
|
||||
// Seat 1 will not give a brick for two lumber but would for three.
|
||||
val counter = CounterTrade(give = listOf(Resource.BRICK), get = listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER))
|
||||
assertTrue(CatanRules.isLegalMove(table, 1, counter))
|
||||
table = CatanRules.applyMove(table, 1, counter)
|
||||
assertEquals(2, CatanRules.currentSeat(table))
|
||||
table = CatanRules.applyMove(table, 2, DeclineTrade)
|
||||
|
||||
assertEquals(CatanPhase.TRADE_COUNTERS, table.phase)
|
||||
assertEquals("the offerer chooses", 0, CatanRules.currentSeat(table))
|
||||
assertEquals(
|
||||
listOf<CatanMove>(AcceptCounter(1), DismissCounters),
|
||||
CatanRules.legalMoves(table, 0),
|
||||
)
|
||||
|
||||
val taken = CatanRules.applyMove(table, 0, AcceptCounter(1))
|
||||
assertEquals(CatanPhase.PLAYING, taken.phase)
|
||||
assertNull(taken.pendingTrade)
|
||||
assertEquals(hand(Resource.BRICK to 1), taken.resources[0])
|
||||
assertEquals(hand(Resource.LUMBER to 3, Resource.BRICK to 1), taken.resources[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `counters can all be turned down`() {
|
||||
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
|
||||
table = CatanRules.applyMove(
|
||||
table, 1,
|
||||
CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER)),
|
||||
)
|
||||
table = CatanRules.applyMove(table, 2, DeclineTrade)
|
||||
val after = CatanRules.applyMove(table, 0, DismissCounters)
|
||||
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||
assertNull(after.pendingTrade)
|
||||
assertEquals(openTable().resources, after.resources)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a counter must differ from the offer and be affordable`() {
|
||||
val table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
|
||||
assertFalse(
|
||||
"the offer as made is an accept, not a counter",
|
||||
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER))),
|
||||
)
|
||||
assertFalse(
|
||||
"cannot promise cards not held",
|
||||
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.ORE), listOf(Resource.LUMBER))),
|
||||
)
|
||||
assertFalse(
|
||||
"cannot ask for cards the offerer lacks",
|
||||
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.ORE))),
|
||||
)
|
||||
assertFalse(
|
||||
"only the seat being asked may counter",
|
||||
CatanRules.isLegalMove(table, 2, CounterTrade(listOf(Resource.WOOL), listOf(Resource.LUMBER))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bank trades four for one and harbours do better`() {
|
||||
val base = craft()
|
||||
@@ -829,6 +1019,10 @@ class CatanRulesTest {
|
||||
OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)),
|
||||
AcceptTrade,
|
||||
DeclineTrade,
|
||||
OfferTrade(null, listOf(Resource.LUMBER), listOf(Resource.ORE)),
|
||||
CounterTrade(listOf(Resource.WOOL), listOf(Resource.GRAIN, Resource.GRAIN)),
|
||||
AcceptCounter(2),
|
||||
DismissCounters,
|
||||
MaritimeTrade(Resource.WOOL, Resource.GRAIN),
|
||||
EndTurn,
|
||||
EndFreeRoads,
|
||||
|
||||
@@ -164,6 +164,9 @@ class ScorekeeperSessionTest {
|
||||
|
||||
await("the guest to be let in") { guest.state.value.connected }
|
||||
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)
|
||||
assertTrue(guest.state.value.sheet.started)
|
||||
assertTrue(host.state.requests.isEmpty())
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.AcceptCounter
|
||||
import org.prolibertate.games.game.catan.AcceptTrade
|
||||
import org.prolibertate.games.game.catan.CounterGoods
|
||||
import org.prolibertate.games.game.catan.DismissCounters
|
||||
import org.prolibertate.games.game.catan.ActivateKnight
|
||||
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.BuildWall
|
||||
import org.prolibertate.games.game.catan.BuyImprovement
|
||||
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.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.ChaseRobber
|
||||
import org.prolibertate.games.game.catan.DeclineTrade
|
||||
import org.prolibertate.games.game.catan.DiscardGoods
|
||||
import org.prolibertate.games.game.catan.DropProgress
|
||||
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||
import org.prolibertate.games.game.catan.EndSmith
|
||||
import org.prolibertate.games.game.catan.EndTurn
|
||||
import org.prolibertate.games.game.catan.GiveGoods
|
||||
import org.prolibertate.games.game.catan.GoodsBankTrade
|
||||
import org.prolibertate.games.game.catan.HireKnight
|
||||
import org.prolibertate.games.game.catan.MoveKnight
|
||||
import org.prolibertate.games.game.catan.MoveRobber
|
||||
import org.prolibertate.games.game.catan.OfferGoods
|
||||
import org.prolibertate.games.game.catan.PickDeck
|
||||
import org.prolibertate.games.game.catan.PillageCity
|
||||
import org.prolibertate.games.game.catan.PlaceKnight
|
||||
import org.prolibertate.games.game.catan.PlaceMetropolis
|
||||
import org.prolibertate.games.game.catan.PlayProgress
|
||||
import org.prolibertate.games.game.catan.PromoteKnight
|
||||
import org.prolibertate.games.game.catan.RelocateKnight
|
||||
import org.prolibertate.games.game.catan.RemoveKnight
|
||||
import org.prolibertate.games.game.catan.RollDice
|
||||
import org.prolibertate.games.game.catan.SetupPlace
|
||||
import org.prolibertate.games.game.catan.SpyTake
|
||||
import org.prolibertate.games.game.catan.TakeAqueduct
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Cities & Knights has no device in this test suite, so the screen's logic is
|
||||
* checked another way: engines play whole games, and at every position the
|
||||
* player on the clock is treated as the local one. For every move the engine says
|
||||
* is legal, [CatanInteraction] (and the dialogs' own filters) must offer a way to
|
||||
* make it — a tap, a button or a dialog choice. A legal move that no control can
|
||||
* produce would be a stuck game on a real phone.
|
||||
*/
|
||||
class CatanCkInteractionTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(players: Int, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(
|
||||
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/** How a move reaches the engine from the screen, or null if nothing can produce it. */
|
||||
private fun reachable(state: CatanState, legal: List<CatanMove>, move: CatanMove): String? {
|
||||
fun tapVertex(vertex: Int, building: BuildKind? = null, moving: Int? = null, armed: ArmedCard? = null): TapOutcome =
|
||||
CatanInteraction.onVertexTap(vertex, legal, building, null, moving, armed)
|
||||
|
||||
return when (move) {
|
||||
is SetupPlace -> {
|
||||
val vertex = CatanInteraction.targets(state, legal, null, null).vertices
|
||||
val edges = CatanInteraction.targets(state, legal, null, move.vertex).edges
|
||||
"setup".takeIf {
|
||||
move.vertex in vertex && move.edge in edges &&
|
||||
CatanInteraction.onVertexTap(move.vertex, legal, null, null) is TapOutcome.PickSetupVertex &&
|
||||
CatanInteraction.onEdgeTap(move.edge, legal, null, move.vertex) == TapOutcome.Submit(move)
|
||||
}
|
||||
}
|
||||
|
||||
is BuildRoad -> {
|
||||
val targets = CatanInteraction.targets(state, legal, BuildKind.ROAD, null)
|
||||
"road".takeIf {
|
||||
move.edge in targets.edges &&
|
||||
CatanInteraction.onEdgeTap(move.edge, legal, BuildKind.ROAD, null) == TapOutcome.Submit(move)
|
||||
}
|
||||
}
|
||||
|
||||
is BuildSettlement -> viaBuild(state, legal, BuildKind.SETTLEMENT, move.vertex, move)
|
||||
is BuildCity -> viaBuild(state, legal, BuildKind.CITY, move.vertex, move)
|
||||
is BuildWall -> viaBuild(state, legal, BuildKind.WALL, move.vertex, move)
|
||||
is HireKnight -> viaBuild(state, legal, BuildKind.KNIGHT, move.vertex, move)
|
||||
|
||||
is MoveRobber -> {
|
||||
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||
val tap = CatanInteraction.onHexTap(hex, legal)
|
||||
"robber".takeIf {
|
||||
hex in targets.hexes &&
|
||||
(tap == TapOutcome.Submit(move) || (tap is TapOutcome.PickRobberVictim && move.victim in tap.victims))
|
||||
}
|
||||
}
|
||||
|
||||
is MoveKnight -> {
|
||||
val targets = CatanInteraction.targets(state, legal, null, null, movingKnight = move.from)
|
||||
"moveKnight".takeIf {
|
||||
move.to in targets.vertices && tapVertex(move.to, moving = move.from) == TapOutcome.Submit(move) &&
|
||||
move.from in CatanInteraction.targets(state, legal, null, null).knights &&
|
||||
CatanInteraction.knightActions(legal, move.from).any { it is MoveKnight }
|
||||
}
|
||||
}
|
||||
|
||||
is ActivateKnight, is ChaseRobber, is PromoteKnight -> {
|
||||
val vertex = when (move) {
|
||||
is ActivateKnight -> move.vertex
|
||||
is ChaseRobber -> move.vertex
|
||||
else -> (move as PromoteKnight).vertex
|
||||
}
|
||||
val smith = legal.any { it is EndSmith }
|
||||
if (move is PromoteKnight && smith) {
|
||||
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||
"smith".takeIf {
|
||||
vertex in targets.vertices && tapVertex(vertex) == TapOutcome.Submit(move)
|
||||
}
|
||||
} else {
|
||||
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||
"knightMenu".takeIf {
|
||||
vertex in targets.knights && tapVertex(vertex) == TapOutcome.PickKnight(vertex) &&
|
||||
move in CatanInteraction.knightActions(legal, vertex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PillageCity -> viaChore(state, legal, move.vertex, move)
|
||||
is PlaceMetropolis -> viaChore(state, legal, move.vertex, move)
|
||||
is RelocateKnight -> viaChore(state, legal, move.to, move)
|
||||
is RemoveKnight -> viaChore(state, legal, move.vertex, move)
|
||||
is PlaceKnight -> viaChore(state, legal, move.vertex, move)
|
||||
|
||||
is PlayProgress -> viaCard(state, legal, move)
|
||||
|
||||
is BuyImprovement -> "improve".takeIf { move.track in CatanInteraction.improvable(legal) }
|
||||
|
||||
is GoodsBankTrade ->
|
||||
"bankTrade".takeIf { move.get in CatanInteraction.goodsBankTrades(legal)[move.give].orEmpty() }
|
||||
|
||||
RollDice(), is RollDice -> "roll".takeIf { CatanInteraction.canRoll(legal) }
|
||||
EndTurn -> "endTurn".takeIf { CatanInteraction.canEndTurn(legal) }
|
||||
EndFreeRoads -> "endRoads".takeIf { CatanInteraction.canEndFreeRoads(legal) }
|
||||
EndSmith -> "endSmith".takeIf { CatanInteraction.canEndSmith(legal) }
|
||||
AcceptTrade, DeclineTrade -> "tradeReply".takeIf { CatanInteraction.tradeResponses(legal) != (false to false) }
|
||||
|
||||
// Answered in a dialog that offers exactly the moves in the legal list.
|
||||
is PickDeck, is DropProgress, is TakeAqueduct, is SpyTake -> "choreDialog"
|
||||
|
||||
// Structural: the discard and give dialogs build these and ask the engine.
|
||||
is DiscardGoods, is GiveGoods -> "goodsDialog"
|
||||
is OfferGoods, is CounterGoods -> "tradeDialog"
|
||||
is AcceptCounter, DismissCounters -> "counterDialog"
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun viaBuild(state: CatanState, legal: List<CatanMove>, kind: BuildKind, vertex: Int, move: CatanMove): String? {
|
||||
val targets = CatanInteraction.targets(state, legal, kind, null)
|
||||
return "build $kind".takeIf {
|
||||
vertex in targets.vertices &&
|
||||
CatanInteraction.onVertexTap(vertex, legal, kind, null) == TapOutcome.Submit(move) &&
|
||||
kind in CatanInteraction.canBuild(legal)
|
||||
}
|
||||
}
|
||||
|
||||
private fun viaChore(state: CatanState, legal: List<CatanMove>, vertex: Int, move: CatanMove): String? {
|
||||
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||
return "chore".takeIf {
|
||||
vertex in targets.vertices &&
|
||||
CatanInteraction.onVertexTap(vertex, legal, null, null) == TapOutcome.Submit(move)
|
||||
}
|
||||
}
|
||||
|
||||
private fun viaCard(state: CatanState, legal: List<CatanMove>, move: PlayProgress): String? {
|
||||
val seat = CatanRules.currentSeat(state) ?: return null
|
||||
val entry = CatanInteraction.progressHand(state, seat, legal).firstOrNull { it.card.id == move.card }
|
||||
?: return null
|
||||
val kind = entry.card.kind
|
||||
if (move !in entry.plays) return null
|
||||
if (kind !in CatanInteraction.BOARD_CARDS) return "cardDialog"
|
||||
|
||||
val armed = ArmedCard(kind, entry.plays)
|
||||
val targets = CatanInteraction.targets(state, legal, null, null, armed = armed)
|
||||
return when (kind) {
|
||||
org.prolibertate.games.game.catan.ProgressType.DIPLOMAT ->
|
||||
"armed".takeIf {
|
||||
move.edge in targets.edges &&
|
||||
CatanInteraction.onEdgeTap(move.edge!!, legal, null, null, armed) == TapOutcome.Submit(move)
|
||||
}
|
||||
|
||||
org.prolibertate.games.game.catan.ProgressType.MERCHANT ->
|
||||
"armed".takeIf {
|
||||
move.hexA in targets.hexes &&
|
||||
CatanInteraction.onHexTap(move.hexA!!, legal, armed) == TapOutcome.Submit(move)
|
||||
}
|
||||
|
||||
org.prolibertate.games.game.catan.ProgressType.INVENTOR -> {
|
||||
val first = CatanInteraction.onHexTap(move.hexA!!, legal, armed)
|
||||
if (first != TapOutcome.PickFirstHex(move.hexA!!)) return null
|
||||
val second = armed.copy(firstHex = move.hexA)
|
||||
val partners = CatanInteraction.targets(state, legal, null, null, armed = second).hexes
|
||||
"armed".takeIf {
|
||||
move.hexB in partners &&
|
||||
CatanInteraction.onHexTap(move.hexB!!, legal, second) == TapOutcome.Submit(move)
|
||||
}
|
||||
}
|
||||
|
||||
else ->
|
||||
"armed".takeIf {
|
||||
move.vertex in targets.vertices &&
|
||||
CatanInteraction.onVertexTap(move.vertex!!, legal, null, null, null, armed) == TapOutcome.Submit(move)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playAndCheck(players: Int, seed: Long): Map<String, Int> {
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(players, seed))
|
||||
val seen = mutableMapOf<String, Int>()
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
|
||||
val seat = CatanRules.currentSeat(state) ?: break
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
for (move in legal) {
|
||||
val how = reachable(state, legal, move)
|
||||
assertTrue(
|
||||
"seed $seed: no control produces $move in ${state.phase} " +
|
||||
"(chore ${state.ck?.chores?.firstOrNull()}); log tail: ${state.log.takeLast(3)}",
|
||||
how != null,
|
||||
)
|
||||
seen.merge(how!!, 1, Int::plus)
|
||||
}
|
||||
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every legal move in a full game has a control that produces it`() {
|
||||
val total = mutableMapOf<String, Int>()
|
||||
listOf(3 to 21L, 4 to 22L, 3 to 23L, 4 to 24L).forEach { (players, seed) ->
|
||||
playAndCheck(players, seed).forEach { (k, v) -> total.merge(k, v, Int::plus) }
|
||||
}
|
||||
println("CK UI coverage: ${total.toSortedMap()}")
|
||||
// The ways the screen builds, moves and answers all got exercised.
|
||||
listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn")
|
||||
.forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sleeping knight with nothing to do can still be tapped for its menu`() {
|
||||
val base = CatanRules.initialState(config(3, 5L))
|
||||
.copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
|
||||
val state = base.copy(
|
||||
ck = base.ck!!.copy(knights = listOf(org.prolibertate.games.game.catan.Knight(10, 0, 1))),
|
||||
)
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
assertTrue("no grain, so nothing to do", CatanInteraction.knightActions(legal, 10).isEmpty())
|
||||
|
||||
val targets = CatanInteraction.targets(state, legal, null, null, localSeat = 0)
|
||||
assertTrue(10 in targets.idleKnights)
|
||||
assertTrue("no tap ring for a knight with nothing to do", 10 !in targets.knights)
|
||||
assertTrue(10 in targets.tappableVertices)
|
||||
assertEquals(
|
||||
TapOutcome.PickKnight(10),
|
||||
CatanInteraction.onVertexTap(10, legal, null, null, ownKnights = setOf(10)),
|
||||
)
|
||||
// On somebody else's turn the legal list is empty and nothing is offered.
|
||||
assertTrue(CatanInteraction.targets(state, emptyList(), null, null, localSeat = 0).idleKnights.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.ProgressType
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.ui.catanDefaultPoints
|
||||
import org.prolibertate.games.ui.catanPointsChoices
|
||||
import org.prolibertate.games.ui.catanSwitchRules
|
||||
|
||||
class CatanCkTextTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
@Test
|
||||
fun `every position of a Cities and Knights game has a prompt for every seat`() {
|
||||
val config = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(
|
||||
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
),
|
||||
seed = 31L,
|
||||
)
|
||||
var state = CatanRules.initialState(config)
|
||||
val ai = CatanAi()
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
|
||||
for (me in 0 until 3) {
|
||||
val seats = CatanSeats(me, listOf("A", "B", "C"))
|
||||
val text = CatanText.prompt(state, seats, setupVertexChosen = false, building = null)
|
||||
assertTrue("blank prompt in ${state.phase} for seat $me", text.isNotBlank())
|
||||
}
|
||||
val seat = CatanRules.currentSeat(state) ?: break
|
||||
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every progress card has a name and a description`() {
|
||||
ProgressType.entries.forEach {
|
||||
assertTrue(CatanText.progress(it).isNotBlank())
|
||||
assertTrue("no help for $it", CatanText.progressHelp(it).length > 10)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
|
||||
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
|
||||
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
|
||||
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||
|
||||
val base = CatanOptions()
|
||||
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
|
||||
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
|
||||
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
|
||||
|
||||
// A short game picked on purpose is kept when both menus have it.
|
||||
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||
assertEquals(8, short.pointsToWin)
|
||||
assertEquals(3, ck.playerCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.BuildingType
|
||||
import org.prolibertate.games.game.catan.CatanBuilding
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** Shared fixtures for the Katan UI-logic tests: real islands, bent to taste. */
|
||||
internal object CatanFixtures {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
fun config(players: Int = 3, seed: Long = 7L) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(CatanOptions(playerCount = players)),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/** A fresh table, on the first setup placement. */
|
||||
fun start(players: Int = 3, seed: Long = 7L): CatanState =
|
||||
CatanRules.initialState(config(players, seed))
|
||||
|
||||
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
|
||||
fun playing(
|
||||
turn: Int = 0,
|
||||
players: Int = 3,
|
||||
adjust: CatanState.() -> CatanState = { this },
|
||||
): CatanState = start(players)
|
||||
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
|
||||
.adjust()
|
||||
|
||||
fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||
|
||||
fun CatanState.withHand(seat: Int, hand: Map<Resource, Int>): CatanState =
|
||||
copy(resources = resources.toMutableList().also { it[seat] = hand })
|
||||
|
||||
fun CatanState.withBuilding(vertex: Int, type: BuildingType, owner: Int): CatanState =
|
||||
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
|
||||
|
||||
fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
|
||||
copy(roads = roads.toMutableList().also { it[edge] = owner })
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class CatanGeometryTest {
|
||||
|
||||
private val sizes = listOf(360f to 800f, 800f to 360f, 500f to 500f, 1000f to 1000f / CatanGeometry.ASPECT)
|
||||
|
||||
private fun dist(a: Pt, b: Pt): Float = sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
|
||||
|
||||
@Test
|
||||
fun `the whole island fits inside the box at every shape`() {
|
||||
for ((w, h) in sizes) {
|
||||
val layout = CatanGeometry.fit(w, h)
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
val p = layout.vertex(v)
|
||||
assertTrue("vertex $v x=${p.x} outside 0..$w", p.x in 0f..w)
|
||||
assertTrue("vertex $v y=${p.y} outside 0..$h", p.y in 0f..h)
|
||||
}
|
||||
for (e in CatanBoard.PORT_POSITIONS) {
|
||||
val p = layout.portAnchor(e)
|
||||
assertTrue("harbour marker x=${p.x} outside 0..$w at ${w}x$h", p.x in 0f..w)
|
||||
assertTrue("harbour marker y=${p.y} outside 0..$h at ${w}x$h", p.y in 0f..h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the layout is as large as the box allows`() {
|
||||
// Tall and narrow is limited by width, short and wide by height; either
|
||||
// way the island should use most of the limiting side.
|
||||
val tall = CatanGeometry.fit(360f, 800f)
|
||||
val wide = CatanGeometry.fit(800f, 360f)
|
||||
assertTrue(tall.radius > 20f)
|
||||
assertTrue(wide.radius > 20f)
|
||||
val xs = (0 until CatanBoard.VERTEX_COUNT).map { tall.vertex(it).x }
|
||||
assertTrue("island should span most of the width", xs.max() - xs.min() > 360f * 0.7f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every edge is one hex radius long and every hex corner is one radius from its centre`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
for (e in 0 until CatanBoard.EDGE_COUNT) {
|
||||
val (a, b) = layout.edgeEnds(e)
|
||||
assertEquals("edge $e length", layout.radius, dist(a, b), layout.radius * 0.01f)
|
||||
}
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
val centre = layout.hexCenter(h)
|
||||
for (corner in layout.hexCorners(h)) {
|
||||
assertEquals("hex $h corner", layout.radius, dist(centre, corner), layout.radius * 0.01f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all fifty-four vertices sit at distinct places`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val places = (0 until CatanBoard.VERTEX_COUNT).map {
|
||||
val p = layout.vertex(it)
|
||||
Math.round(p.x) to Math.round(p.y)
|
||||
}.toSet()
|
||||
assertEquals(CatanBoard.VERTEX_COUNT, places.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap on a vertex finds that vertex, and a tap far away finds nothing`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val all = (0 until CatanBoard.VERTEX_COUNT).toList()
|
||||
for (v in all) {
|
||||
val p = layout.vertex(v)
|
||||
assertEquals(v, layout.nearestVertex(p, layout.radius * 0.5f, all))
|
||||
// A slightly clumsy tap still lands on it.
|
||||
assertEquals(v, layout.nearestVertex(Pt(p.x + layout.radius * 0.2f, p.y - layout.radius * 0.15f), layout.radius * 0.55f, all))
|
||||
}
|
||||
assertNull(layout.nearestVertex(Pt(-100f, -100f), layout.radius * 0.5f, all))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hit testing only considers the candidates it is given`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val at = layout.vertex(5)
|
||||
assertNull(layout.nearestVertex(at, layout.radius * 0.3f, listOf(6)))
|
||||
assertEquals(6, layout.nearestVertex(layout.vertex(6), layout.radius * 0.3f, listOf(6)))
|
||||
assertEquals(3, layout.nearestEdge(layout.edgeMidpoint(3), 5f, listOf(3, 4)))
|
||||
assertEquals(7, layout.nearestHex(layout.hexCenter(7), 5f, listOf(7, 8)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the nearest candidate wins when several are in reach`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val a = 10
|
||||
val b = CatanBoard.VERTEX_NEIGHBORS[a].first()
|
||||
val pa = layout.vertex(a)
|
||||
val pb = layout.vertex(b)
|
||||
// A point two-thirds of the way toward b is nearer b, though both are in reach.
|
||||
val at = Pt(pa.x + (pb.x - pa.x) * 0.66f, pa.y + (pb.y - pa.y) * 0.66f)
|
||||
assertEquals(b, layout.nearestVertex(at, layout.radius * 2f, listOf(a, b)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `harbour markers sit outside the coast`() {
|
||||
val layout = CatanGeometry.fit(500f, 500f)
|
||||
val centre = Pt(500f / 2f, 500f / 2f)
|
||||
for (e in CatanBoard.PORT_POSITIONS) {
|
||||
val anchor = layout.portAnchor(e)
|
||||
val mid = layout.edgeMidpoint(e)
|
||||
assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre))
|
||||
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * PORT_OFFSET) < 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
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.BuildingType
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.DevCard
|
||||
import org.prolibertate.games.game.catan.DevCardType
|
||||
import org.prolibertate.games.game.catan.Discard
|
||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||
import org.prolibertate.games.game.catan.MoveRobber
|
||||
import org.prolibertate.games.game.catan.PlayKnight
|
||||
import org.prolibertate.games.game.catan.PlayMonopoly
|
||||
import org.prolibertate.games.game.catan.PlayYearOfPlenty
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.SetupPlace
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.withBuilding
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.withRoad
|
||||
|
||||
class CatanInteractionTest {
|
||||
|
||||
private val rich = hand(
|
||||
Resource.LUMBER to 5, Resource.BRICK to 5, Resource.WOOL to 5,
|
||||
Resource.GRAIN to 5, Resource.ORE to 5,
|
||||
)
|
||||
|
||||
// --- Setup ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `setup first offers every settlement spot, then only the roads beside the chosen one`() {
|
||||
val state = start()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val setup = legal.filterIsInstance<SetupPlace>()
|
||||
|
||||
val first = CatanInteraction.targets(state, legal, null, null)
|
||||
assertEquals(setup.map { it.vertex }.toSet(), first.vertices)
|
||||
assertTrue(first.edges.isEmpty() && first.hexes.isEmpty())
|
||||
|
||||
val vertex = first.vertices.first()
|
||||
val second = CatanInteraction.targets(state, legal, null, vertex)
|
||||
assertTrue(second.vertices.isEmpty())
|
||||
assertEquals(setup.filter { it.vertex == vertex }.map { it.edge }.toSet(), second.edges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a setup placement is two taps - the settlement, then its road`() {
|
||||
val state = start()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
|
||||
val edge = CatanInteraction.targets(state, legal, null, vertex).edges.first()
|
||||
|
||||
assertEquals(TapOutcome.PickSetupVertex(vertex), CatanInteraction.onVertexTap(vertex, legal, null, null))
|
||||
assertEquals(TapOutcome.Submit(SetupPlace(vertex, edge)), CatanInteraction.onEdgeTap(edge, legal, null, vertex))
|
||||
// A road tapped before any settlement is chosen means nothing.
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a road that does not touch the chosen settlement is refused`() {
|
||||
val state = start()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
|
||||
val beside = CatanBoard.VERTEX_EDGES[vertex].toSet()
|
||||
val far = (0 until CatanBoard.EDGE_COUNT).first { it !in beside }
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(far, legal, null, vertex))
|
||||
}
|
||||
|
||||
// --- Building -------------------------------------------------------------
|
||||
|
||||
/** Seat 0 owns a settlement at vertex 0 and a road two edges long leading away from it. */
|
||||
private fun established(): Triple<org.prolibertate.games.game.catan.CatanState, Int, Int> {
|
||||
val e1 = CatanBoard.VERTEX_EDGES[0][0]
|
||||
val w = CatanBoard.otherEnd(e1, 0)
|
||||
val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 }
|
||||
val x = CatanBoard.otherEnd(e2, w)
|
||||
val state = playing {
|
||||
withHand(0, rich).withBuilding(0, BuildingType.SETTLEMENT, 0).withRoad(e1, 0).withRoad(e2, 0)
|
||||
}
|
||||
return Triple(state, x, e2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing on the board is tappable until a build mode is chosen`() {
|
||||
val (state, _, _) = established()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
assertTrue(CatanInteraction.targets(state, legal, null, null).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build modes target exactly what the engine allows`() {
|
||||
val (state, x, _) = established()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
|
||||
assertEquals(
|
||||
legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet(),
|
||||
CatanInteraction.targets(state, legal, BuildKind.ROAD, null).edges,
|
||||
)
|
||||
assertTrue(x in CatanInteraction.targets(state, legal, BuildKind.SETTLEMENT, null).vertices)
|
||||
assertEquals(setOf(0), CatanInteraction.targets(state, legal, BuildKind.CITY, null).vertices)
|
||||
assertEquals(setOf(BuildKind.ROAD, BuildKind.SETTLEMENT, BuildKind.CITY), CatanInteraction.canBuild(legal))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap builds only when the matching mode is armed and the spot is legal`() {
|
||||
val (state, x, _) = established()
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val road = legal.filterIsInstance<BuildRoad>().first().edge
|
||||
|
||||
assertEquals(TapOutcome.Submit(BuildSettlement(x)), CatanInteraction.onVertexTap(x, legal, BuildKind.SETTLEMENT, null))
|
||||
assertEquals(TapOutcome.Submit(BuildCity(0)), CatanInteraction.onVertexTap(0, legal, BuildKind.CITY, null))
|
||||
assertEquals(TapOutcome.Submit(BuildRoad(road)), CatanInteraction.onEdgeTap(road, legal, BuildKind.ROAD, null))
|
||||
|
||||
// No mode armed: nothing happens.
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, null, null))
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(road, legal, null, null))
|
||||
// A settlement on an occupied crossing is not offered.
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(0, legal, BuildKind.SETTLEMENT, null))
|
||||
// The wrong mode for the spot.
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, BuildKind.CITY, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without the cards, nothing can be built`() {
|
||||
val (state, _, _) = established()
|
||||
val broke = state.withHand(0, hand())
|
||||
val legal = CatanRules.legalMoves(broke, 0)
|
||||
assertTrue(CatanInteraction.canBuild(legal).isEmpty())
|
||||
assertFalse(CatanInteraction.canBuyDevCard(legal))
|
||||
assertTrue(CatanInteraction.canEndTurn(legal))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `free roads from a road building card need no build mode`() {
|
||||
val (state, _, _) = established()
|
||||
val free = state.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||
val legal = CatanRules.legalMoves(free, 0)
|
||||
val targets = CatanInteraction.targets(free, legal, null, null)
|
||||
assertTrue(targets.edges.isNotEmpty())
|
||||
val edge = targets.edges.first()
|
||||
assertEquals(TapOutcome.Submit(BuildRoad(edge)), CatanInteraction.onEdgeTap(edge, legal, null, null))
|
||||
assertTrue(CatanInteraction.canEndFreeRoads(legal))
|
||||
}
|
||||
|
||||
// --- The robber ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the robber may go to any hex but the one it is on`() {
|
||||
val state = playing { copy(phase = CatanPhase.ROBBER) }
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val targets = CatanInteraction.targets(state, legal, null, null)
|
||||
assertEquals((0 until CatanBoard.HEX_COUNT).filter { it != state.robber }.toSet(), targets.hexes)
|
||||
|
||||
val hex = targets.hexes.first()
|
||||
// Nobody has built anything, so there is nobody to rob: the move goes straight through.
|
||||
assertEquals(
|
||||
TapOutcome.Submit(MoveRobber(CatanBoard.HEXES[hex], null)),
|
||||
CatanInteraction.onHexTap(hex, legal),
|
||||
)
|
||||
assertEquals(TapOutcome.Ignored, CatanInteraction.onHexTap(state.robber, legal))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hex with two possible victims asks which`() {
|
||||
val base = playing { copy(phase = CatanPhase.ROBBER) }
|
||||
val hex = (0 until CatanBoard.HEX_COUNT).first { it != base.robber }
|
||||
val corners = CatanBoard.HEX_CORNERS[hex]
|
||||
val state = base
|
||||
.withBuilding(corners[0], BuildingType.SETTLEMENT, 1)
|
||||
.withBuilding(corners[3], BuildingType.SETTLEMENT, 2)
|
||||
.withHand(1, hand(Resource.ORE to 1))
|
||||
.withHand(2, hand(Resource.GRAIN to 1))
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
|
||||
val outcome = CatanInteraction.onHexTap(hex, legal)
|
||||
assertTrue(outcome is TapOutcome.PickRobberVictim)
|
||||
outcome as TapOutcome.PickRobberVictim
|
||||
assertEquals(CatanBoard.HEXES[hex], outcome.hex)
|
||||
assertEquals(setOf(1, 2), outcome.victims.toSet())
|
||||
}
|
||||
|
||||
// --- Discarding -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seven costs half the hand, rounded down`() {
|
||||
assertEquals(4, CatanInteraction.discardQuota(9))
|
||||
assertEquals(4, CatanInteraction.discardQuota(8))
|
||||
assertEquals(3, CatanInteraction.discardQuota(7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discard is found only when it names exactly an allowed set of cards`() {
|
||||
val state = playing {
|
||||
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
|
||||
.withHand(0, hand(Resource.LUMBER to 3, Resource.BRICK to 3, Resource.WOOL to 3))
|
||||
}
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
assertTrue(legal.all { it is Discard })
|
||||
|
||||
val good = listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.WOOL)
|
||||
assertNotNull(CatanInteraction.discardMove(legal, good))
|
||||
// Order does not matter.
|
||||
assertNotNull(CatanInteraction.discardMove(legal, good.reversed()))
|
||||
// Too few, and more than are held.
|
||||
assertNull(CatanInteraction.discardMove(legal, good.take(3)))
|
||||
assertNull(CatanInteraction.discardMove(legal, List(4) { Resource.LUMBER }))
|
||||
}
|
||||
|
||||
// --- Trading ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `bank trades list what can be given and what it can buy`() {
|
||||
val state = playing { withHand(0, hand(Resource.GRAIN to 4)) }
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
assertTrue(legal.any { it is MaritimeTrade })
|
||||
|
||||
val trades = CatanInteraction.bankTrades(legal)
|
||||
assertEquals(setOf(Resource.GRAIN), trades.keys)
|
||||
assertEquals(Resource.entries.filter { it != Resource.GRAIN }, trades.getValue(Resource.GRAIN))
|
||||
assertEquals(4, CatanInteraction.bankRatio(state, 0, Resource.GRAIN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offer is allowed only to another player, for something different, from cards held`() {
|
||||
val state = playing { withHand(0, hand(Resource.LUMBER to 2)) }
|
||||
val lumber = listOf(Resource.LUMBER)
|
||||
val grain = listOf(Resource.GRAIN)
|
||||
|
||||
assertTrue(CatanInteraction.offerAllowed(state, 0, 1, lumber, grain))
|
||||
assertFalse("to oneself", CatanInteraction.offerAllowed(state, 0, 0, lumber, grain))
|
||||
assertFalse("nothing offered", CatanInteraction.offerAllowed(state, 0, 1, emptyList(), grain))
|
||||
assertFalse("nothing asked", CatanInteraction.offerAllowed(state, 0, 1, lumber, emptyList()))
|
||||
assertFalse("like for like", CatanInteraction.offerAllowed(state, 0, 1, lumber, lumber))
|
||||
assertFalse("more than held", CatanInteraction.offerAllowed(state, 0, 1, List(3) { Resource.LUMBER }, grain))
|
||||
assertFalse("not their turn", CatanInteraction.offerAllowed(state, 1, 0, lumber, grain))
|
||||
}
|
||||
|
||||
// --- Development cards -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a development card is playable unless it was bought this turn or is a victory point`() {
|
||||
val state = playing {
|
||||
copy(
|
||||
devHands = listOf(
|
||||
listOf(
|
||||
DevCard(1, DevCardType.KNIGHT, boughtTurn = 0),
|
||||
DevCard(2, DevCardType.VICTORY_POINT, boughtTurn = 0),
|
||||
DevCard(3, DevCardType.KNIGHT, boughtTurn = 1),
|
||||
),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val legal = CatanRules.legalMoves(state, 0)
|
||||
val hand = CatanInteraction.devHand(state, 0, legal)
|
||||
|
||||
// Sorted by type then id: the two knights, then the victory point.
|
||||
assertEquals(listOf(1, 3, 2), hand.map { it.card.id })
|
||||
assertEquals(listOf(true, false, false), hand.map { it.playable })
|
||||
assertEquals(listOf<Any>(PlayKnight(1)), hand.first().plays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monopoly offers one play per resource`() {
|
||||
val plays = Resource.entries.map { PlayMonopoly(9, it) }
|
||||
val choices = CatanInteraction.monopolyChoices(plays)
|
||||
assertEquals(Resource.entries.toSet(), choices.keys)
|
||||
assertEquals(PlayMonopoly(9, Resource.ORE), choices.getValue(Resource.ORE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `year of plenty narrows to the cards that still complete a take`() {
|
||||
val plays = listOf(
|
||||
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.BRICK)),
|
||||
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.LUMBER)),
|
||||
PlayYearOfPlenty(4, listOf(Resource.ORE)),
|
||||
)
|
||||
assertEquals(
|
||||
setOf(Resource.LUMBER, Resource.BRICK, Resource.ORE),
|
||||
CatanInteraction.plentyAvailable(plays, emptyList()),
|
||||
)
|
||||
assertEquals(
|
||||
setOf(Resource.LUMBER, Resource.BRICK),
|
||||
CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER)),
|
||||
)
|
||||
assertEquals(setOf(Resource.LUMBER), CatanInteraction.plentyAvailable(plays, listOf(Resource.BRICK)))
|
||||
assertTrue(CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER, Resource.LUMBER)).isEmpty())
|
||||
|
||||
assertEquals(plays[0], CatanInteraction.plentyMove(plays, listOf(Resource.BRICK, Resource.LUMBER)))
|
||||
assertEquals(plays[2], CatanInteraction.plentyMove(plays, listOf(Resource.ORE)))
|
||||
assertNull(CatanInteraction.plentyMove(plays, listOf(Resource.GRAIN)))
|
||||
}
|
||||
|
||||
// --- Trade replies ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the addressee of an offer is given accept and decline`() {
|
||||
val base = playing {
|
||||
withHand(0, hand(Resource.LUMBER to 1)).withHand(1, hand(Resource.GRAIN to 1))
|
||||
}
|
||||
val offer = org.prolibertate.games.game.catan.TradeOffer(
|
||||
by = 0, to = 1, give = listOf(Resource.LUMBER), get = listOf(Resource.GRAIN),
|
||||
)
|
||||
val pending = base.copy(phase = CatanPhase.TRADE_PENDING, pendingTrade = offer)
|
||||
val legal = CatanRules.legalMoves(pending, 1)
|
||||
assertEquals(true to true, CatanInteraction.tradeResponses(legal))
|
||||
|
||||
// If the addressee no longer holds what was asked, only decline remains.
|
||||
val short = pending.withHand(1, hand())
|
||||
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
|
||||
}
|
||||
|
||||
// --- Rolled-hex highlight --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `rolled hexes are those carrying the number, minus the robber's`() {
|
||||
val base = playing(turn = 0)
|
||||
val number = base.numbers.first { it != 0 && it != 7 }
|
||||
val state = base.copy(lastDice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6))
|
||||
val all = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] == number }
|
||||
assertTrue(all.isNotEmpty())
|
||||
assertEquals(all.filter { it != state.robber }.toSet(), CatanInteraction.rolledHexes(state))
|
||||
val blocked = state.copy(robber = all.first())
|
||||
assertFalse(all.first() in CatanInteraction.rolledHexes(blocked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is highlighted before this turn's roll, on a seven, or in setup`() {
|
||||
val rolled = playing(turn = 0).copy(lastDice = listOf(3, 3))
|
||||
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.AWAIT_ROLL)).isEmpty())
|
||||
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = listOf(3, 4))).isEmpty())
|
||||
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.SETUP_FIRST)).isEmpty())
|
||||
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = emptyList())).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.Port
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
|
||||
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
|
||||
|
||||
class CatanTextTest {
|
||||
|
||||
private val names = listOf("Ann", "Bo", "Cy")
|
||||
|
||||
@Test
|
||||
fun `seats are named, with You for the local player and a number when a name is unknown`() {
|
||||
val seats = CatanSeats(me = 1, names = names)
|
||||
assertEquals("You", seats.label(1))
|
||||
assertEquals("Ann", seats.label(0))
|
||||
assertEquals("you", seats.lower(1))
|
||||
assertEquals("Seat 4", seats.label(3))
|
||||
assertEquals("Seat 1", CatanSeats(me = 1).label(0))
|
||||
assertEquals("Seat 1", CatanSeats(me = 1, names = listOf(" ")).label(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup prompts say what to place next`() {
|
||||
val state = start()
|
||||
val mine = CatanSeats(0, names)
|
||||
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = false, building = null).contains("Place a settlement"))
|
||||
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = true, building = null).contains("road"))
|
||||
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).startsWith("Ann is placing"))
|
||||
val second = state.copy(phase = CatanPhase.SETUP_SECOND, setupIndex = 3)
|
||||
assertTrue(CatanText.prompt(second, CatanSeats(2, names), false, null).contains("second settlement"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the turn prompts follow the phase`() {
|
||||
val roll = playing().copy(phase = CatanPhase.AWAIT_ROLL)
|
||||
assertTrue(CatanText.prompt(roll, CatanSeats(0, names), false, null).contains("Roll the dice"))
|
||||
assertEquals("Ann is rolling…", CatanText.prompt(roll, CatanSeats(1, names), false, null))
|
||||
|
||||
val turn = playing()
|
||||
assertTrue(CatanText.prompt(turn, CatanSeats(0, names), false, null).contains("Your turn"))
|
||||
assertEquals("Tap an edge to build a road.", CatanText.prompt(turn, CatanSeats(0, names), false, BuildKind.ROAD))
|
||||
assertEquals("Bo is playing…", CatanText.prompt(playing(turn = 1), CatanSeats(0, names), false, null))
|
||||
|
||||
val robber = playing().copy(phase = CatanPhase.ROBBER)
|
||||
assertTrue(CatanText.prompt(robber, CatanSeats(0, names), false, null).contains("robber"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the discard prompt names the quota`() {
|
||||
val state = playing {
|
||||
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
|
||||
.withHand(0, hand(Resource.LUMBER to 9))
|
||||
}
|
||||
assertTrue(CatanText.prompt(state, CatanSeats(0, names), false, null).contains("discard 4"))
|
||||
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).contains("Ann must discard half"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `road building counts down in the singular and the plural`() {
|
||||
val two = playing().copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||
val one = two.copy(pendingFreeRoads = 1)
|
||||
assertTrue(CatanText.prompt(two, CatanSeats(0, names), false, null).endsWith("2 more roads."))
|
||||
assertTrue(CatanText.prompt(one, CatanSeats(0, names), false, null).endsWith("1 more road."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the end of the game names the winner`() {
|
||||
val over = playing().copy(phase = CatanPhase.GAME_OVER, winner = 1)
|
||||
assertEquals("Bo won the game.", CatanText.prompt(over, CatanSeats(0, names), false, null))
|
||||
assertEquals("You won!", CatanText.winnerHeadline(over, CatanSeats(1, names)))
|
||||
assertEquals("Bo won", CatanText.winnerHeadline(over, CatanSeats(0, names)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cards read as a sorted tally`() {
|
||||
assertEquals(
|
||||
"2 Lumber + 1 Grain",
|
||||
CatanText.cards(listOf(Resource.GRAIN, Resource.LUMBER, Resource.LUMBER)),
|
||||
)
|
||||
assertEquals("nothing", CatanText.cards(emptyList()))
|
||||
assertEquals(
|
||||
"Ann offers 1 Lumber to you for 2 Ore",
|
||||
CatanText.tradeSummary("Ann", "you", listOf(Resource.LUMBER), listOf(Resource.ORE, Resource.ORE)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `costs and harbours read as the rules state them`() {
|
||||
assertEquals("1 lumber + 1 brick", CatanText.ROAD_COST)
|
||||
assertEquals("2 grain + 3 ore", CatanText.CITY_COST)
|
||||
assertEquals("2:1 ore", CatanText.portName(Port(edge = 0, resource = Resource.ORE, ratio = 2)))
|
||||
assertEquals("3:1 any", CatanText.portName(Port(edge = 0, resource = null, ratio = 3)))
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class UpdatePolicyTest {
|
||||
tag = tag,
|
||||
versionCode = versionCode,
|
||||
apkUrl = "https://example.invalid/$tag.apk",
|
||||
apkName = "PilgrimsOfKatan-$tag.apk",
|
||||
apkName = "ProLibertateGames-$tag.apk",
|
||||
channel = channel,
|
||||
)
|
||||
|
||||
@@ -53,28 +53,6 @@ class UpdatePolicyTest {
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -151,7 +129,7 @@ class UpdatePolicyTest {
|
||||
assertNull("an up-to-date app has no business interrupting", launchOffer(UpdateVerdict.UpToDate))
|
||||
assertNull(
|
||||
"a phone with no network has nothing to apologise for",
|
||||
launchOffer(UpdateVerdict.Refused("Unable to resolve host git.wallacenet.ca")),
|
||||
launchOffer(UpdateVerdict.Refused("Unable to resolve host api.github.com")),
|
||||
)
|
||||
// Including the refusal that this whole change exists to produce: being
|
||||
// shown the other channel's build is not a thing to be told about.
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
# CI, releases, and OTA — decisions and how to build
|
||||
|
||||
Status as of the Pilgrims of Katan bootstrap (WALA-118).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **The release channel is this instance's own releases.** The app ships from
|
||||
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>, and the in-app updater
|
||||
reads that repo's releases over Gitea's REST API. GitHub is not a channel for
|
||||
this project; the only GitHub URLs left are the `upstream` remote and the
|
||||
attribution link in the README.
|
||||
|
||||
The repo is **public on purpose**: the updater downloads anonymously, with no
|
||||
token embedded in the APK. A private repo would make every check fail
|
||||
(the API answers 404 to anonymous callers), not leak anything — but it would
|
||||
also stop updates dead.
|
||||
|
||||
2. **CI lives in Actions-compatible workflows in `.github/workflows/`.**
|
||||
Gitea Actions runs workflows from `.github/workflows/` (or `.gitea/workflows/`),
|
||||
so these files serve both a GitHub-style runner and the Gitea instance.
|
||||
Publishing goes through `scripts/publish-release.sh` — Gitea's REST API, the
|
||||
same routes the app's updater reads — rather than the `gh` CLI, which a runner
|
||||
image need not carry.
|
||||
|
||||
| Workflow | Trigger | Publishes |
|
||||
| --- | --- | --- |
|
||||
| `ci.yml` | push to `dev`, PRs into `dev`/`main`, manual | dev prerelease, from `dev` only |
|
||||
| `release.yml` | push to `main`, manual | production release, from `main` only |
|
||||
|
||||
Pull requests build and check but never publish. Prereleases are marked so
|
||||
the "latest release" endpoint — which skips them — can never hand a dev build
|
||||
to a production install.
|
||||
|
||||
3. **A runner has to be registered before any of that executes.** As of the
|
||||
bootstrap the instance has **no Actions runner**, so the workflows are
|
||||
correct but idle: a Gitea runner (Docker or host) must be registered against
|
||||
the instance and able to resolve `actions/checkout`,
|
||||
`actions/setup-java`, `android-actions/setup-android` and
|
||||
`actions/upload-artifact`. Until then the manual build below and the manual
|
||||
publish script are the working paths.
|
||||
|
||||
4. **The local build is the guaranteed path.** Workflows need a runner that may
|
||||
not exist yet; `./gradlew assembleDebug` + `./gradlew testDebugUnitTest`
|
||||
work on any machine with JDK 17 and the Android SDK (compileSdk 34), and CI's
|
||||
green means exactly these two commands plus lint.
|
||||
|
||||
## Build numbers, tags, and why updates never downgrade
|
||||
|
||||
An APK is **`<series>.<build number>`**, the release tag is **`v<series>.<build
|
||||
number>`**, and the build number is what Android sees as `versionCode`:
|
||||
|
||||
```
|
||||
build number = CI run number + plgVersionRunOffset (gradle.properties)
|
||||
```
|
||||
|
||||
- `plgVersionSeries` (currently `1.4`) is the human-facing series, set by hand
|
||||
in `gradle.properties`.
|
||||
- `plgVersionRunOffset` (currently `1000`) is a **permanent floor**, also set by
|
||||
hand in `gradle.properties`, set once at bootstrap and never changed.
|
||||
|
||||
Why the offset exists: Gitea Actions restarts run numbers at 1 for each
|
||||
workflow, so without it the first release of a fresh instance would be build
|
||||
`1` — the number an unstamped local build already carries — and any reset of
|
||||
the counter would hand Android a *lower* `versionCode`, which it refuses as a
|
||||
downgrade. With the offset, every stamped build starts at 1001 and only ever
|
||||
climbs, because the run number is monotonic and the offset never moves. The
|
||||
gradle build and both workflows read the same two properties, so the tag and
|
||||
the APK inside it cannot name different versions.
|
||||
|
||||
The series is a name for people: bumping it changes nothing about ordering,
|
||||
because `versionCode` never includes the series.
|
||||
|
||||
Unstamped builds (local, or a PR build) are `versionCode 1`, below every
|
||||
release — the honest answer for a build nobody stamped.
|
||||
|
||||
## Manual build (works today, no runner needed)
|
||||
|
||||
```sh
|
||||
# JDK 17, Android SDK with platform 34 + build-tools 34
|
||||
export JAVA_HOME=... # a JDK 17 install
|
||||
export ANDROID_HOME=... # SDK root (or set sdk.dir in local.properties)
|
||||
|
||||
./gradlew assembleDebug # dev-channel app (default: ca.wallacenet.pilgrimsofkatan.dev)
|
||||
PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet.pilgrimsofkatan)
|
||||
./gradlew testDebugUnitTest # JVM unit tests
|
||||
./gradlew lintDebug # Android lint (CI runs this too)
|
||||
```
|
||||
|
||||
The two variants install side by side: different applicationId, different
|
||||
launcher name (`Pro Libertate Games DEV` vs `Pro Libertate Games`), different
|
||||
tartan sett.
|
||||
|
||||
## Manual release (works today, no runner needed)
|
||||
|
||||
```sh
|
||||
GITEA_TOKEN=… ./scripts/publish-release.sh v1.4.1001 app/build/outputs/apk/debug/app-debug.apk release "$(git rev-parse HEAD)"
|
||||
```
|
||||
|
||||
The script takes `<tag> <apk> <prerelease|release> [target-sha]`, creates the
|
||||
release through `POST /api/v1/repos/{owner}/{repo}/releases`, attaches the APK
|
||||
through `POST …/releases/{id}/assets` — which is the upload route Gitea's
|
||||
Swagger declares for this instance (1.27.3) — and then reads the release back
|
||||
and corrects its state if it did not stick. That last step is not decoration:
|
||||
this instance has been observed to accept `draft: false` at create time and
|
||||
store the release as a draft after the attachment lands, and a draft is
|
||||
invisible to the updater, so the script verifies rather than assumes. If the
|
||||
tag already exists it publishes into the existing release instead of failing,
|
||||
so a retry is safe.
|
||||
|
||||
## What the in-app updater reads
|
||||
|
||||
`app/src/main/java/org/prolibertate/games/update/Updater.kt`:
|
||||
|
||||
| Channel | Endpoint |
|
||||
| --- | --- |
|
||||
| production | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases/latest` |
|
||||
| dev | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases?limit=30` |
|
||||
|
||||
- `/releases/latest` is documented by Gitea as *the most recent non-prerelease,
|
||||
non-draft release* — the same rule GitHub implements, so the production
|
||||
channel keeps skipping dev builds.
|
||||
- The dev path takes the highest `versionCode` among the prereleases rather
|
||||
than whatever came first on the page, so it does not depend on server
|
||||
ordering.
|
||||
- The JSON shape is the one the code already parsed from GitHub: `tag_name`,
|
||||
`prerelease`, `draft`, and assets carrying `browser_download_url`. Gitea says
|
||||
`limit` where GitHub says `per_page`.
|
||||
- Each asset must be named so it contains its tag —
|
||||
`PilgrimsOfKatan-v1.4.1001.apk` for tag `v1.4.1001` — because the updater
|
||||
picks the versioned APK out of the release's assets by that rule.
|
||||
|
||||
`BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision: it is
|
||||
`true`, and setting it `false` makes every check report that and fetch nothing.
|
||||
`UpdatePolicy.kt` — channel rules, tag parsing, verdicts — is pure and stays
|
||||
under test either way.
|
||||
|
||||
## Keystore
|
||||
|
||||
`app/debug.keystore` is committed deliberately and must never be rotated:
|
||||
every build — local, CI, release — signs with it, so an OTA update installs
|
||||
over the previous build instead of being rejected for a signature mismatch.
|
||||
+5
-2
@@ -24,7 +24,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
|
||||
| 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) |
|
||||
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) |
|
||||
| Klondike | — (TODO) |
|
||||
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
|
||||
| Morris | [RULES-morris.md](RULES-morris.md) |
|
||||
@@ -40,7 +40,10 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
|
||||
|
||||
## Other docs
|
||||
|
||||
- [CI.md](CI.md) — build, CI, releases, OTA decisions.
|
||||
- Build, CI, releases and OTA are described in the CI section of the root
|
||||
[README](../README.md).
|
||||
- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle,
|
||||
re-copy or re-lay-out it without touching the rules.
|
||||
- `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,222 @@
|
||||
# Katan: Cities & Knights — implemented rules
|
||||
|
||||
This is the second rule set of Katan (see [RULES-catan.md](RULES-catan.md) for the
|
||||
base game). It is chosen on the setup screen — **Rules: Base game / Cities &
|
||||
Knights** — and stored as `CatanOptions.ruleSet` (`CatanRuleSet.CITIES_AND_KNIGHTS`).
|
||||
A table plays one or the other; the choice reaches guests with the rest of the
|
||||
options.
|
||||
|
||||
The expansion is a whole rule set rather than a toggle, because it replaces rules
|
||||
(no development cards, no Largest Army, a different seven) as well as adding them.
|
||||
Everything it needs lives behind `CatanState.ck`, which is `null` in the base game,
|
||||
so a base game's state and rules are untouched. The code is:
|
||||
|
||||
| File | Holds |
|
||||
| --- | --- |
|
||||
| `CatanCkModel.kt` | the data: commodities, improvement tracks, progress cards, knights, the chore queue, every new move |
|
||||
| `CatanCk.kt` | the rules: legal moves, applying them, the roll, the barbarians, scoring |
|
||||
| `CatanCkAi.kt` | the computer opponent for this rule set |
|
||||
| `CatanRules.kt` | the single entry point; calls into `CatanCk` whenever `state.ck != null` |
|
||||
|
||||
Behaviour is pinned by `CatanCkRulesTest` (each rule on a crafted position) and
|
||||
`CatanCkSelfPlayTest` (whole games, engine against engine). Three or four players,
|
||||
as in the base game.
|
||||
|
||||
## Verification
|
||||
|
||||
The rules were checked against the in-game almanac of Catan Universe (rules text
|
||||
only: costs, card wordings, the barbarian and improvement rules). Where the almanac
|
||||
is silent or ambiguous the reading chosen is listed under *Choices made here* below.
|
||||
|
||||
## What changes from the base game
|
||||
|
||||
| | Base game | Cities & Knights |
|
||||
| --- | --- | --- |
|
||||
| Points to win | 10 | **13** (setup offers 8 / 10 / 13 / 15) |
|
||||
| Development cards | 25-card deck | **none** — knights and progress cards replace them |
|
||||
| Largest Army | 5 *(see RULES-catan.md)* | **none** |
|
||||
| Longest Road | 5 *(see RULES-catan.md)* | **2** |
|
||||
| Second setup piece | settlement | **city** (resources only, no commodities) |
|
||||
| Dice | two | **two number dice + the event die** |
|
||||
| A seven | discard half over 7; robber moves | discard half over **7 + 2 per wall**; the robber **only moves after the first barbarian attack** |
|
||||
| Cities | 2 of a resource | forest: 1 lumber + **1 paper**; pasture: 1 wool + **1 cloth**; mountains: 1 ore + **1 coin**; hills / fields: 2 of the resource |
|
||||
|
||||
## The dice
|
||||
|
||||
The yellow die and the red die are added for production, as in the base game. The
|
||||
**event die** has three ships and one gate each of yellow (trade), blue (politics)
|
||||
and green (science). It is resolved **before** production:
|
||||
|
||||
- **Ship** — the barbarians come one step nearer (they start 7 steps out).
|
||||
- **Gate** — every player with at least one level of improvement in that colour,
|
||||
**in turn order from the roller**, whose **red die is no higher than their level**
|
||||
in that track, draws a progress card of that colour.
|
||||
|
||||
`RollDice(forced, event)` stages both for tests, like the base game's forced roll.
|
||||
An **Alchemist** played before the roll sets the two number dice (the event die is
|
||||
still rolled).
|
||||
|
||||
## Commodities
|
||||
|
||||
Three commodities — **cloth, coin, paper** — join the five resources. The bank holds
|
||||
12 of each (19 of each resource). Hands, discards, thefts and trades all treat them as
|
||||
cards; progress cards are not cards for any of these purposes.
|
||||
|
||||
When the bank cannot meet the demand for a card type: with **one** seat owed it,
|
||||
that seat takes what is left; with **two or more** owed, nobody receives it.
|
||||
|
||||
## The barbarians
|
||||
|
||||
The strip shows how many steps away the ship is. On the seventh ship it **lands**:
|
||||
|
||||
- Barbarian strength is the **number of cities on the board** (a metropolis is a city).
|
||||
- Catan's strength is the sum of the strengths of every **active** knight (basic 1,
|
||||
strong 2, mighty 3).
|
||||
- If Catan's strength is **at least** the barbarians', Catan holds: the player with
|
||||
the strictly highest contribution takes a **Defender of Catan** card (one point);
|
||||
if several tie for highest, **each draws a progress card from a deck of their
|
||||
choice** instead.
|
||||
- Otherwise Catan falls: among the players who own a city that is not a metropolis,
|
||||
those who contributed **least** each lose one city, **to their own choice**. A lost
|
||||
city becomes a settlement (or, if the seat has no settlement piece left, is removed),
|
||||
and its wall goes. **A metropolis can never be pillaged.**
|
||||
- Either way every knight stands down, the ship returns to seven steps, and the
|
||||
**robber wakes** — until now it has not been on the board.
|
||||
|
||||
## Knights
|
||||
|
||||
Each seat has **two knights of each strength** (six in all).
|
||||
|
||||
| Action | Cost | Rule |
|
||||
| --- | --- | --- |
|
||||
| Hire | 1 ore + 1 wool | a **basic** knight on an empty crossing at the end of one of your roads; only if one of your two basic knights is free |
|
||||
| Activate | 1 grain | a sleeping knight wakes; only active knights count against the barbarians |
|
||||
| Promote | 1 ore + 1 wool | one level, once per knight per turn; a **mighty** knight needs **level three of politics** (the fortress); the next level's pieces must be free |
|
||||
| Move | — | an active knight moves along **its own roads** to an empty crossing, or onto a **strictly weaker** foreign knight, driving it off; it then rests |
|
||||
| Chase the robber | — | an active knight on a crossing of the robber's hex sends the robber off (only once the robber is awake); it then rests, and the robber move follows |
|
||||
|
||||
- **A knight cannot act on the turn it woke.** It can be woken again after it acts.
|
||||
- A driven-off knight is moved by **its owner** along its own roads to any empty
|
||||
crossing it can reach; if there is none, it is lost.
|
||||
- A knight is a wall, like an opposing building: roads cannot be laid past an enemy
|
||||
knight, a settlement cannot be built on a knight, and the knight cuts **Longest Road**.
|
||||
- Knights can act (wake, move, chase) **before the roll** as well as after.
|
||||
|
||||
## City walls
|
||||
|
||||
2 brick, on a city without a wall, **three per player**. Each wall raises the hand
|
||||
limit on a seven by two (so a seven discards when you hold more than `7 + 2 × walls`).
|
||||
|
||||
## City improvements
|
||||
|
||||
Three tracks, five levels each, bought with the track's commodity: **trade (cloth,
|
||||
yellow), politics (coin, blue), science (paper, green)**. Level *n* costs *n* of the
|
||||
commodity. You must own a city to improve.
|
||||
|
||||
| Track | Level 3 gives |
|
||||
| --- | --- |
|
||||
| Trade | commodities trade with the bank **2:1** |
|
||||
| Politics | knights may be promoted to **mighty** |
|
||||
| Science | the **aqueduct**: when the dice pay you nothing, take any resource |
|
||||
|
||||
The level names (Trading Post … Grand Bazaar, Town Hall … Palace, Abbey … University)
|
||||
are this app's own.
|
||||
|
||||
### Metropolises
|
||||
|
||||
The first player to reach **level four** in a track takes that track's **metropolis**
|
||||
(a marker on one of their cities: +2 points; a city carries at most one). Reaching
|
||||
**level five** takes it from a holder who is still on four. A player with several
|
||||
cities chooses which; with one it is placed at once; a player with no free city
|
||||
**waits**, and claims it when a city is built.
|
||||
|
||||
## Progress cards
|
||||
|
||||
54 cards: 18 in each of three decks. A card played returns to the **bottom** of its
|
||||
deck. A hand holds **four**; a fifth must be given back at once. They can be played at
|
||||
any point of your own turn (before or after the roll), except where noted.
|
||||
|
||||
**Science (green)** — Alchemist ×2 *(before the roll only)*, Crane ×2, Engineer, Inventor ×2,
|
||||
Irrigation ×2, Medicine ×2, Mining ×2, Printer, Road Building ×2, Smith ×2.
|
||||
|
||||
**Politics (blue)** — Bishop ×2 *(after the first attack)*, Constitution, Deserter ×2,
|
||||
Diplomat ×2, Intrigue ×2, Saboteur ×2, Spy ×3, Warlord ×2, Wedding ×2.
|
||||
|
||||
**Trade (yellow)** — Commercial Harbor ×2, Master Merchant ×2, Merchant ×6,
|
||||
Merchant Fleet ×2, Resource Monopoly ×4, Trade Monopoly ×2.
|
||||
|
||||
**Printer** and **Constitution** are one point each and are **laid face up the moment
|
||||
they are drawn**; they never sit in a hand.
|
||||
|
||||
How each plays is shown on the card in the app (`CatanText.progressHelp`); the engine's
|
||||
behaviour is in `CatanCk.applyProgress`. Points worth knowing:
|
||||
|
||||
- **Saboteur** hits every other player with **as many points as you or more**, who each
|
||||
discard half their cards (rounded down). **Wedding** takes two cards (or what they
|
||||
have) from each player with **more** points. **Master Merchant** needs a victim with
|
||||
more points, and takes two cards you choose.
|
||||
- **Spy**: you see the target's progress cards and take one; nobody else sees them.
|
||||
- **Merchant**: placed beside one of your buildings; you trade that hex's resource 2:1
|
||||
and hold a victory point until someone else plays a Merchant.
|
||||
- **Deserter**: the victim removes a knight of *their* choice; you place a free one of the
|
||||
same strength (or a basic if yours is not free), awake or asleep as theirs was.
|
||||
- **Diplomat**: removes a road with a loose end (an end touching no other road, building
|
||||
or knight); if it was yours you may rebuild it anywhere for free.
|
||||
- **Inventor** swaps two tokens, never a 2, 6, 8 or 12.
|
||||
|
||||
## The chore queue
|
||||
|
||||
Anything that needs an answer from somebody — a discard, a city to lose, a progress card
|
||||
to hand over, a metropolis site, a knight to place — is queued as a **chore**
|
||||
(`CatanPhase.CHORE`, `CkState.chores`). The game waits on the seat the first chore names
|
||||
(not always the player whose turn it is), then resumes where `CkState.back` says. This is
|
||||
how a seven, a barbarian landing, a wedding or a spy are all one mechanism, and why
|
||||
the AI and the network driver needed no new concepts.
|
||||
|
||||
The two free-form chores — **discarding** and **handing over** cards — are structural
|
||||
like the base game's trade offers: the engine checks them in `isLegalMove` and the legal
|
||||
list carries one example, so a seat is never without a move.
|
||||
|
||||
## Scoring and winning
|
||||
|
||||
Settlement 1, city 2, **metropolis +2**, **Longest Road 2**, **Defender of Catan 1** each,
|
||||
**Printer / Constitution 1** each, **the Merchant 1**. Nothing is hidden. Win at 13 (default),
|
||||
checked after every move and when a turn arrives.
|
||||
|
||||
## Choices made here, and why
|
||||
|
||||
- **The red die must be no higher than the improvement level** to draw a progress card
|
||||
(level 1 draws on a 1, level 5 on 1–5). The almanac says the red die must match a
|
||||
number on one of your improvements and that the number rises with the level; this is
|
||||
the reading that makes both true.
|
||||
- **One resource is offered to every player for a Commercial Harbor.** The printed card
|
||||
lets you offer a different one to each.
|
||||
- **The merchant cannot go on the desert**, which produces nothing to trade.
|
||||
- **Improvements need a city** to be built.
|
||||
- **A 3:1 harbour trades commodities too**; a 2:1 harbour trades only its own resource.
|
||||
- **A knight that cannot find a refuge is lost**, as the almanac says of an intrigue.
|
||||
- **A claim on a metropolis with no city to carry it waits** and is made when a city is
|
||||
built.
|
||||
- **Domestic trade** works as in the base game — an offer to one seat or to the whole
|
||||
table, asked in seat order, each seat accepting, declining or countering — and may
|
||||
include commodities (`OfferGoods`, `CounterGoods`; accepting and dismissing counters
|
||||
reuse the base game's `AcceptCounter` and `DismissCounters`).
|
||||
- **No Seafarers, no 5–6 player extension**, no scenario or variant of any kind.
|
||||
|
||||
## The computer opponent
|
||||
|
||||
`CatanCkAi` plays a safe, unambitious game: it settles and raises cities as the base AI
|
||||
does, spends commodities on improvements as soon as it can (leaning to politics), hires
|
||||
and wakes knights as the barbarians draw near, chases the robber off hexes it needs, plays
|
||||
the progress cards whose value is obvious, and answers every chore with the card it values
|
||||
least. It never plans a knight's route, never plays an Alchemist, Inventor, Diplomat,
|
||||
Deserter or Intrigue, and makes no domestic offers (though it answers them). Every engine
|
||||
game in `CatanCkSelfPlayTest` ends in a win at thirteen points.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- The screen is built and its logic is tested, but **not yet verified on two real
|
||||
devices**; the catalogue entry stays a dev-channel preview.
|
||||
- No animation for the event die, the barbarian landing or a knight's move.
|
||||
- The Commercial Harbor offers a single resource type (see above).
|
||||
- The AI is not a strong opponent and makes no claim to be.
|
||||
+59
-18
@@ -12,15 +12,27 @@ 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`.
|
||||
|
||||
## Rule sets
|
||||
|
||||
A table plays one of two rule sets, chosen on the setup screen
|
||||
(`CatanOptions.ruleSet`):
|
||||
|
||||
- **Base game** — everything in this document.
|
||||
- **Cities & Knights** — the expansion, documented in
|
||||
[RULES-catan-ck.md](RULES-catan-ck.md). It reuses this document's board, setup,
|
||||
building, robber and trade rules and changes or replaces the rest; its state lives
|
||||
behind `CatanState.ck`, which is `null` in the base game.
|
||||
|
||||
## Table
|
||||
|
||||
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).
|
||||
- **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.
|
||||
The setup screen exposes all three (players: 3 or 4; game to 5, 8, 10 or 12 points in the
|
||||
base game, 8, 10, 13 or 15 in Cities & Knights, whose published target is 13).
|
||||
|
||||
## Board
|
||||
|
||||
@@ -123,17 +135,32 @@ is the same code path.
|
||||
|
||||
### 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 addressed to **one** seat or, with `to = null`, to **everyone**, and
|
||||
names two card multisets: what the offerer gives, what they want.
|
||||
|
||||
Seats are asked **one at a time**, in seat order after the offerer (seats holding
|
||||
no cards are skipped; an addressed offer asks only its addressee). The seat being
|
||||
asked may:
|
||||
|
||||
- **accept** — the trade happens at once and the offer closes (first accept wins);
|
||||
- **decline** — the offer moves on to the next seat;
|
||||
- **counter** (`CounterTrade`) — propose different terms: what they would give and
|
||||
what they want from the offerer. A counter must differ from the offer as made
|
||||
(that is an accept), and both sides must hold the cards. The offer moves on.
|
||||
|
||||
When every seat has answered and nobody accepted: with no counters the offer
|
||||
simply lapses; with counters the phase becomes `TRADE_COUNTERS` and the offerer
|
||||
either **takes one** (`AcceptCounter(seat)`, still checked for affordability) or
|
||||
**turns them all down** (`DismissCounters`).
|
||||
|
||||
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.
|
||||
A seat may **accept** only if it holds what was asked and the offerer still holds
|
||||
what was promised; otherwise only decline (or counter) is offered. A refusal
|
||||
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
|
||||
@@ -160,7 +187,19 @@ its edge.
|
||||
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).
|
||||
seat's network: it must start from a crossing where the seat has a building, or
|
||||
where one of its roads ends.
|
||||
|
||||
**An opponent's settlement or city on a crossing breaks the network there.** A
|
||||
road cannot be laid past it, even from a crossing where one of your own roads
|
||||
ends — only a crossing you own a building on is a way through. The same rule
|
||||
applies to the free roads of a Road Building card, and it is the same wall that
|
||||
cuts Longest Road (below).
|
||||
|
||||
**Pieces are limited**: each player has **15 roads, 5 settlements and 4 cities**.
|
||||
A player who has laid them all cannot build another of that kind. Raising a
|
||||
settlement into a city returns the settlement to the supply, so the five
|
||||
settlements remain available to be laid again.
|
||||
|
||||
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
|
||||
@@ -281,20 +320,22 @@ timing rules; Longest Road and Largest Army worth five; victory at ten.
|
||||
- **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.
|
||||
- **Offers may be open to the whole table** and can be countered; the first accept wins (see Trading).
|
||||
|
||||
## 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.
|
||||
- **Longest Road and Largest Army are worth 5 each here.** The published base game awards
|
||||
**2** each (Catan Universe's almanac says so for Largest Army, and the printed rules
|
||||
for both). Cities & Knights scores Longest Road at 2, as printed. Left unchanged in
|
||||
the base game so far; it is a one-constant change in `publicVictoryPoints`.
|
||||
- **A bank short of a resource pays nobody that roll**, even when only one player is owed
|
||||
it. The published ruling lets a lone claimant take what is left; Cities & Knights does.
|
||||
- **No timer or cancel on offers.** An offer waits for each asked seat in turn; the offerer cannot withdraw it mid-round, and a player who never answers stalls the table.
|
||||
- **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them.
|
||||
- **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 screen is built, not yet verified on two devices.** The Katan screen and
|
||||
setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a
|
||||
dev-channel preview until two-device play has been verified.
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Katan UI — how it is built, and how to change it
|
||||
|
||||
The Katan screen (`app/src/main/java/org/prolibertate/games/ui/game/catan/`) is
|
||||
built in layers so that **look, wording, layout and interaction can each be
|
||||
changed on their own**. If you are an agent asked to restyle, retheme,
|
||||
re-copy or re-lay-out Katan, start here and touch only the layer you were asked
|
||||
about.
|
||||
|
||||
The rules live elsewhere and are not part of the UI: `game/catan/` is a pure
|
||||
state machine (see [RULES-catan.md](RULES-catan.md)). **Nothing in `ui/game/catan/`
|
||||
decides what is legal.** The engine's legal-move list is the single source of
|
||||
truth, so the UI can only ever offer a move the rules would accept.
|
||||
|
||||
## The layers
|
||||
|
||||
| Layer | File | Owns | Change it to… |
|
||||
| --- | --- | --- | --- |
|
||||
| **Style** | `CatanStyle.kt` | Every colour, proportion and the light/dark palettes | Re-skin: new terrain colours, seat colours, piece sizes, token look |
|
||||
| **Copy** | `CatanText.kt` | Every sentence, button label and name shown | Reword, localise, rename resources |
|
||||
| **Geometry** | `CatanGeometry.kt` | Engine lattice → pixels, and pixel → nearest vertex/edge/hex | Change board scale, margin, harbour placement, tap reach (rarely) |
|
||||
| **Interaction** | `CatanInteraction.kt` | What is tappable, what a tap means, which buttons are live | Change *how* something is selected (never *whether* it is legal) |
|
||||
| **Board drawing** | `CatanBoardView.kt` | The `Canvas`: tiles, tokens, ports, robber, roads, pieces, highlights | Change how a piece is drawn (art, shapes, animation) |
|
||||
| **Panels** | `CatanPanels.kt` | Resource tiles, hand, player cards, dice, action bar, log | Restyle or rearrange widgets |
|
||||
| **Dialogs** | `CatanDialogs.kt` | Discard, robber victim, play card, trade, trade reply, game over | Change a dialog's flow or look |
|
||||
| **Screen** | `CatanScreen.kt` | Wiring to `MatchController`, portrait/landscape layout, which dialog shows | Change the layout or add a new element |
|
||||
| **Previews** | `CatanPreviews.kt` | Android Studio previews of real positions | Iterate on the look without a match |
|
||||
|
||||
Everything outside this folder that mentions Katan is one-line wiring:
|
||||
`PlayScreen.kt` (builds the `MatchController`), `GameSetupScreen.kt` (players and
|
||||
points-to-win options), `Catalog.kt` (the catalogue entry).
|
||||
|
||||
## Recipes
|
||||
|
||||
**Change colours or sizes.** Edit `CatanStyle.kt`. `LightStyle` is the reference
|
||||
palette; `DarkStyle` is derived from it. Metrics (`roadWidth`, `settlementSize`,
|
||||
`tokenRadius`, …) are fractions of the hex radius, so they scale with the screen.
|
||||
To try a whole new theme, provide it at the root:
|
||||
`CompositionLocalProvider(LocalCatanStyle provides myStyle) { … }`.
|
||||
|
||||
**Change wording or translate.** Edit `CatanText.kt`. Composables never build a
|
||||
sentence themselves. `prompt(...)` is the one-line "what is happening / what do I
|
||||
do" text; it is a pure function of the state, with tests in `CatanTextTest`.
|
||||
|
||||
**Redraw a piece** (settlement, road, robber, port, token). Edit the matching
|
||||
`draw…` function in `CatanBoardView.kt`. Positions come from `BoardLayout`, so you
|
||||
only decide the shape. Keep the hit target generous (see Accessibility).
|
||||
|
||||
**Rearrange the screen.** Edit `CatanScreen.kt`. It builds four independent
|
||||
pieces (`board`, `players`, `promptRow`, `handAndActions`) and arranges them
|
||||
differently in portrait and landscape. Moving or dropping a piece does not affect
|
||||
the others.
|
||||
|
||||
**Add a widget.** Make it a stateless composable in `CatanPanels.kt` (values in,
|
||||
lambdas out). Do not give it the `MatchController`.
|
||||
|
||||
**Change how a selection works** (for example a one-tap build instead of a
|
||||
build-mode toggle). Edit `CatanInteraction.kt` and its tests, then adjust the
|
||||
screen's UI state. The legal-move list stays the gate.
|
||||
|
||||
## Rules of the road
|
||||
|
||||
1. **Never put a rule in a composable.** If a button's enabled state or a tap's
|
||||
result depends on game logic, it goes in `CatanInteraction` (pure, tested) and
|
||||
is derived from the engine's `legal` list or `CatanRules`.
|
||||
2. **Never hard-code a colour or a string** outside `CatanStyle` / `CatanText`.
|
||||
3. **Stateless by default.** Panels and dialogs take values and report events. The
|
||||
only state they may own is the transient selection they are helping the player
|
||||
make (cards picked in a trade, for example).
|
||||
4. **Hidden information.** The local view comes from `CatanRules.viewFor`: other
|
||||
players' development cards and the deck are empty on a client. Do not show the
|
||||
size of the deck; it is not known to a client. Card *counts* (`resourceCount`,
|
||||
`devHandCounts`) are public.
|
||||
5. **The state is immutable.** Never mutate it; submit a move.
|
||||
6. **The layout must not move.** Everything outside the board has a *fixed
|
||||
height*, and the board takes whatever is left, so the island's scale is the
|
||||
same all game. Concretely: a control is *enabled or disabled*, never
|
||||
*present or absent* (the action bar always shows every button); text that can
|
||||
vary in length is pinned to a fixed number of lines (`minLines = maxLines`);
|
||||
a line that is sometimes empty is still drawn (a non-breaking space), as the
|
||||
award line on a player card is. A change that makes any non-board element
|
||||
taller or shorter at runtime will rescale the board under the player's thumb.
|
||||
|
||||
## Interaction model (what the player actually does)
|
||||
|
||||
| Phase | The player… |
|
||||
| --- | --- |
|
||||
| Setup | taps a highlighted crossing (settlement), then a highlighted edge beside it (road). *Cancel* re-picks the settlement |
|
||||
| Roll | presses **Roll dice** |
|
||||
| Seven | **Discard** dialog (forced); then taps a hex to move the robber; a victim dialog appears only if more than one player can be robbed |
|
||||
| Main turn | arms **Road / Settlement / City** and taps a highlighted spot; **Buy card**; **Play card**; **Trade**; **End turn** |
|
||||
| Road Building | taps highlighted edges, or **Done with roads** |
|
||||
| Offered a trade | **Accept / Decline / Counter…** dialog (forced); *Counter…* opens a picker pre-filled with the mirror of the offer |
|
||||
| Counter-offers received | **Counter-offers** dialog: *Accept* beside the one to take, or *No deal* |
|
||||
| Game over | result dialog |
|
||||
|
||||
Trade offers are free-form (two card multisets), so they are validated with
|
||||
`CatanRules.isLegalMove`, not looked up in the legal list.
|
||||
|
||||
## Testing
|
||||
|
||||
Gradle does not run in the agent sandbox; **CI is the compile and test check**
|
||||
(dispatch `ci.yml` on your branch). What is covered:
|
||||
|
||||
- `CatanGeometryTest` — the island fits any box, edges and corners are one radius,
|
||||
hit-testing finds the right vertex/edge/hex.
|
||||
- `CatanInteractionTest` — targets and tap outcomes for setup, building, free
|
||||
roads, the robber (including the victim choice), discards, bank trades, offers,
|
||||
development cards and trade replies, all against real engine states.
|
||||
- `CatanTextTest` — the prompts and tallies.
|
||||
|
||||
Drawing and dialogs are not unit-tested; use the previews in `CatanPreviews.kt`
|
||||
and play a game on a dev build.
|
||||
|
||||
## Accessibility notes
|
||||
|
||||
- Tap targets: vertices and edges accept a tap within `tapReach` (0.7) hex radii,
|
||||
about 23 dp on a phone. If you shrink the board, keep that physical size.
|
||||
- Seat colours are blue, orange, white and purple, each outlined in near-black so
|
||||
they read on any terrain. Colour is the only piece identity today; adding a
|
||||
shape or letter per seat would help colour-blind players (a good first task).
|
||||
- The board has a content description but no per-hex semantics; a screen-reader
|
||||
mode would need one.
|
||||
|
||||
## Cities & Knights on the screen
|
||||
|
||||
The same layers carry the expansion; the screen only adds pieces, and **none of them
|
||||
decides what is legal** — each reads the engine's legal-move list.
|
||||
|
||||
| Layer | What it adds |
|
||||
| --- | --- |
|
||||
| `CatanStyle.kt` | commodity colours, a colour per improvement track, the wall and barbarian colours, the awake-knight halo |
|
||||
| `CatanText.kt` | goods, track, knight and progress-card names and help text; the prompts for every chore |
|
||||
| `CatanInteraction.kt` | `BuildKind.WALL` / `KNIGHT`; chore taps; the moving-knight and armed-card modes; `knightActions`, `progressHand`, `goodsBankTrades` |
|
||||
| `CatanBoardView.kt` | shields for knights (strength dots; faded when asleep, white halo when awake), dashed walls, a pennant for each metropolis, the merchant coin, a faint robber until the first attack |
|
||||
| `CatanPanels.kt` | commodity tiles in the hand, the barbarian strip, the yellow / red / event dice, the extra player-card lines, the Improve and Progress buttons |
|
||||
| `CatanCkDialogs.kt` | goods picker (discard, wedding, harbour), improvements, progress cards, the knight menu, deck / aqueduct / spy / drop choices, goods trade |
|
||||
| `CatanScreen.kt` | the visual state: knight menu, moving knight, armed card, and which chore dialog shows |
|
||||
|
||||
**Interaction model.** Tap one of your own knights (it gets a yellow ring when it can do
|
||||
something) for its menu: wake, promote, move or chase the robber. *Move* arms the board
|
||||
with the crossings the knight can reach. A progress card that needs a place on the board
|
||||
(Engineer, Medicine, Merchant, Diplomat, Intrigue, Inventor) closes the dialog and
|
||||
**arms** the board; the next tap plays it. Chores that are answered with a place — a city
|
||||
to lose, a metropolis site, a driven knight's refuge, a free knight — light the crossings
|
||||
on the board and the prompt line says what to tap; the others open a dialog that cannot
|
||||
be dismissed, because the game is waiting on it.
|
||||
|
||||
**Testing.** `CatanCkInteractionTest` plays whole games and checks that for *every* legal
|
||||
move at *every* position some control (a tap, a button or a dialog choice) produces it, so
|
||||
a legal move with no way to make it — a stuck phone — fails the build.
|
||||
|
||||
## Status and known gaps
|
||||
|
||||
The screen is **playable and feature-complete for the engine's rules**, but is
|
||||
**unverified on two real devices**, so the catalogue entry is `devPreview`
|
||||
(openable on a dev build, hidden from production). To release it: verify
|
||||
two-device play, then in `Catalog.kt` set `available = true` and remove
|
||||
`devPreview = true`, and update `CatalogTest`.
|
||||
|
||||
Not done yet, in rough priority order:
|
||||
|
||||
1. Two-device verification (host + guest over the LAN) and a full game on a phone
|
||||
and a tablet.
|
||||
2. Feedback on events: dice-roll animation, a highlight on hexes that produced,
|
||||
resource-gain flourishes, haptics. The state carries the dice and a text log;
|
||||
it does not say which hexes paid out, so that needs either a diff of two states
|
||||
or an engine addition.
|
||||
3. Art: terrain textures, resource and harbour icons, a real robber pawn.
|
||||
4. A timer or cancel button on offers (the engine has neither).
|
||||
5. A board that shows the *last* move (a faint highlight of what just changed).
|
||||
6. Landscape polish on large tablets; the right-hand panel scrolls but is plain.
|
||||
+14
-7
@@ -1,5 +1,12 @@
|
||||
# Project-wide Gradle settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# C1-only JIT (-XX:TieredStopAtLevel=1): the Gitea runner's JVMs have been
|
||||
# crashing intermittently (SIGSEGV in HotSpot's compiler, bad Kotlin compiler
|
||||
# codegen) on Zulu, Corretto and Temurin alike. Dropping C2 is the test for
|
||||
# whether the optimising JIT is the trigger; it costs some build speed. The
|
||||
# Kotlin daemon needs its own copy: setting its options stops it inheriting
|
||||
# org.gradle.jvmargs.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:TieredStopAtLevel=1
|
||||
kotlin.daemon.jvm.options=-Xmx2048m,-XX:TieredStopAtLevel=1
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
|
||||
@@ -24,10 +31,10 @@ android.nonTransitiveRClass=true
|
||||
# when this changes.
|
||||
plgVersionSeries=1.4
|
||||
|
||||
# The permanent floor under every stamped build number: versionCode (and the
|
||||
# release tag, and versionName) are `run number + this`. Gitea Actions starts
|
||||
# run numbers at 1 for each workflow, so without it the first Gitea-era release
|
||||
# would be build 1 — the same number an unstamped local build already carries —
|
||||
# and any later reset would make Android reject the next build as a downgrade.
|
||||
# Set once, at bootstrap, and never changed again: see docs/CI.md.
|
||||
# The permanent floor under every stamped build number: versionCode, versionName
|
||||
# and the release tag are all `run number + this`. Run numbers restart at 1 on a
|
||||
# new CI host (Gitea Actions), and Android refuses any versionCode lower than the
|
||||
# one installed, so without a floor the first build after a move would look like
|
||||
# a downgrade. 1000 sits above every number GitHub reached (prod 30, dev ~172).
|
||||
# Set once and never changed.
|
||||
plgVersionRunOffset=1000
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Publish a built APK to this repository's Gitea releases — the channel the
|
||||
# in-app updater reads (docs/CI.md).
|
||||
#
|
||||
# The workflows call this, and so can a person: it is the whole release step,
|
||||
# runnable by hand on any machine with curl, which matters because a Gitea
|
||||
# Actions runner has to exist before the workflows can run at all.
|
||||
#
|
||||
# scripts/publish-release.sh <tag> <apk> <prerelease|release> [target-sha]
|
||||
#
|
||||
# Environment:
|
||||
# GITEA_TOKEN required — a token that may create releases on the repo.
|
||||
# GITEA_API API base, default https://git.wallacenet.ca/api/v1
|
||||
# GITEA_REPO owner/name, default devadmin/PilgrimsOfKatan
|
||||
#
|
||||
# The four arguments can also arrive as RELEASE_TAG, RELEASE_APK,
|
||||
# RELEASE_KIND and RELEASE_TARGET, which is how the workflows pass them.
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' >&2
|
||||
}
|
||||
|
||||
tag="${1:-${RELEASE_TAG:-}}"
|
||||
apk="${2:-${RELEASE_APK:-}}"
|
||||
kind="${3:-${RELEASE_KIND:-}}"
|
||||
target="${4:-${RELEASE_TARGET:-${GITHUB_SHA:-HEAD}}}"
|
||||
|
||||
case "$kind" in
|
||||
prerelease|release) ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
[ -n "$tag" ] || { usage; exit 2; }
|
||||
[ -f "$apk" ] || { echo "publish-release: no such APK: ${apk:-<unset>}" >&2; exit 2; }
|
||||
[ -n "${GITEA_TOKEN:-}" ] || { echo "publish-release: GITEA_TOKEN is not set" >&2; exit 2; }
|
||||
|
||||
api="${GITEA_API:-${GITHUB_API_URL:-https://git.wallacenet.ca/api/v1}}/repos/${GITEA_REPO:-${GITHUB_REPOSITORY:-devadmin/PilgrimsOfKatan}}"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
# Named for its tag, not for whatever the build happened to call it: the
|
||||
# updater picks the versioned APK out of a release's assets by finding the one
|
||||
# whose name contains the tag, so an asset called app-debug.apk would only be
|
||||
# found by the fallback rule.
|
||||
apk_name="PilgrimsOfKatan-${tag}.apk"
|
||||
|
||||
# The tag and the file name are both derived from gradle.properties and a run
|
||||
# number, so neither can carry a quote or a backslash into this JSON.
|
||||
if [ "$kind" = "prerelease" ]; then
|
||||
prerelease=true
|
||||
note="Automated dev-channel build of ${target}. Newer than production and less tested — switch to the Dev update channel in Settings to be offered these. Published as a prerelease so production-channel installs never see it."
|
||||
else
|
||||
prerelease=false
|
||||
note="Production-channel build of ${target}. Download ${apk_name} below and install it on an Android device (you may need to allow installs from unknown apps). It is signed with the repo's fixed keystore, which is what lets the in-app updater install over it."
|
||||
fi
|
||||
|
||||
cat > "$tmp/release.json" <<JSON
|
||||
{"tag_name":"${tag}","target_commitish":"${target}","name":"Pilgrims of Katan ${tag}","body":"${note}","prerelease":${prerelease},"draft":false}
|
||||
JSON
|
||||
|
||||
code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @"$tmp/release.json" \
|
||||
"$api/releases")"
|
||||
|
||||
# 422 is the usual answer when the tag already exists — a re-run of a
|
||||
# workflow, or a second attempt by hand. Publishing into the release that is
|
||||
# already there is the right thing to do, not a failure.
|
||||
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
|
||||
code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"$api/releases/tags/${tag}")"
|
||||
fi
|
||||
|
||||
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
|
||||
echo "publish-release: creating release ${tag} failed (HTTP ${code})" >&2
|
||||
cat "$tmp/create.json" >&2
|
||||
echo >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gitea answers compact JSON, and the release's own id is the first "id" in
|
||||
# it — the author's object comes later in the document.
|
||||
id="$(grep -o '"id":[0-9]*' "$tmp/create.json" | head -n1 | tr -dc '0-9' || true)"
|
||||
[ -n "$id" ] || { echo "publish-release: no release id in response" >&2; cat "$tmp/create.json" >&2; echo >&2; exit 1; }
|
||||
|
||||
# Attach the APK first, then check the stored state: uploading has been
|
||||
# observed to leave a freshly created release as a draft, whatever the create
|
||||
# response said, so the check has to be the last thing that happens rather
|
||||
# than a step in the middle.
|
||||
code="$(curl -sS -o "$tmp/asset.json" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${apk};filename=${apk_name}" \
|
||||
"$api/releases/${id}/assets")"
|
||||
|
||||
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
|
||||
echo "publish-release: attaching ${apk_name} to release ${id} failed (HTTP ${code})" >&2
|
||||
cat "$tmp/asset.json" >&2
|
||||
echo >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read back what the database actually stored before declaring success. A draft
|
||||
# is invisible to the updater — exactly what a channel must not silently become
|
||||
# — and this instance has been seen to report one as published at create time
|
||||
# and store it as a draft by the time it is read.
|
||||
curl -sS -o "$tmp/stored.json" -H "Authorization: token ${GITEA_TOKEN}" "$api/releases/${id}"
|
||||
if grep -q '"draft":true' "$tmp/stored.json" ||
|
||||
! grep -q "\"prerelease\":${prerelease}" "$tmp/stored.json"; then
|
||||
state="$(curl -sS -o "$tmp/fix.json" -w '%{http_code}' -X PATCH \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||
"$api/releases/${id}")"
|
||||
if [ "$state" != "200" ]; then
|
||||
echo "publish-release: release ${tag} is stored with the wrong state (HTTP ${state})" >&2
|
||||
cat "$tmp/fix.json" >&2
|
||||
echo >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "publish-release: corrected the stored state of ${tag} (it was saved as a draft)"
|
||||
fi
|
||||
|
||||
echo "publish-release: ${tag} -> release ${id} on ${api#*/repos/}, asset ${apk_name}"
|
||||
+1
-1
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "PilgrimsOfKatan"
|
||||
rootProject.name = "ProLibertateGames"
|
||||
include(":app")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Not found</title></head>
|
||||
<body><h1>404</h1><p>Not found. <a href="/">Pro Libertate Games downloads</a></p></body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
/*/latest.json
|
||||
Cache-Control: no-cache
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/*.apk
|
||||
Content-Type: application/vnd.android.package-archive
|
||||
Cache-Control: public, max-age=300
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pro Libertate Games</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||
.card { border: 1px solid #8884; border-radius: .5rem; padding: 1rem; margin: 1rem 0; }
|
||||
a.dl { display: inline-block; padding: .5rem 1rem; border-radius: .4rem; background: #2563eb; color: #fff; text-decoration: none; }
|
||||
small { opacity: .7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Pro Libertate Games</h1>
|
||||
<p>Android builds. Allow <em>Install unknown apps</em> for your browser to sideload.</p>
|
||||
<div class="card" id="production"><h2>Production</h2><p>Loading…</p></div>
|
||||
<div class="card" id="dev"><h2>Dev</h2><p>Loading…</p></div>
|
||||
<script>
|
||||
for (const [channel, path] of [["production", "prod"], ["dev", "dev"]]) {
|
||||
const box = document.getElementById(channel);
|
||||
const title = box.querySelector("h2").outerHTML;
|
||||
fetch(`/${path}/latest.json`, { cache: "no-cache" })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(j => {
|
||||
box.innerHTML = `${title}<p><a class="dl" href="${j.apkUrl}">Download ${j.tag}</a></p>` +
|
||||
`<small>Always newest: <a href="/${path}/ProLibertateGames.apk">/${path}/ProLibertateGames.apk</a></small>`;
|
||||
})
|
||||
.catch(() => { box.innerHTML = `${title}<p>No build published yet.</p>`; });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user