Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e9bb2feaf | ||
|
|
cc33dffd9a | ||
|
|
6c311ad304 | ||
|
|
f8c04cec88 | ||
|
|
97728daf0a | ||
|
|
a30b1dfcd0 | ||
|
|
961ac96689 | ||
|
|
e1c325825f | ||
|
|
f967c31368 | ||
|
|
4ed110e68c | ||
|
|
b818997699 | ||
|
|
aeda2924f4 | ||
|
|
2e234c7f20 | ||
|
|
f4f53a93b6 | ||
|
|
bce9b68ece | ||
|
|
68cd799f89 | ||
|
|
6c549a8b76 | ||
|
|
ebab7a8415 | ||
|
|
a0b15b3140 | ||
|
|
8990dde363 | ||
|
|
c2b21a07f5 | ||
|
|
e98afbfd8d | ||
|
|
2c90de66d1 | ||
|
|
f93eba88e5 | ||
|
|
36ab518309 | ||
|
|
77cc6c5a05 | ||
|
|
166f85734b | ||
|
|
25e38c4bf8 | ||
|
|
6bf7522042 | ||
|
|
7942adeaf7 | ||
|
|
401ebe0118 | ||
|
|
3c0469fc81 | ||
|
|
5d36b7bd62 | ||
|
|
b5b4afa731 | ||
|
|
f88099f4e2 | ||
|
|
488f600f15 | ||
|
|
9b938ffd8b | ||
|
|
ccf8d5a1ba | ||
|
|
a00df43a44 | ||
|
|
fae031d2b5 | ||
|
|
d568ff7750 | ||
|
|
2e66567643 | ||
|
|
a8ff9863a6 | ||
|
|
bd12a7a70b | ||
|
|
e370aa0ceb | ||
|
|
59a6ffc940 | ||
|
|
98506c05b7 | ||
|
|
f9b793d56e | ||
|
|
c8368416c4 | ||
|
|
e344d1d817 | ||
|
|
d88083679d | ||
|
|
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 | ||
|
|
3a930f5423 |
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
|
||||
+88
-34
@@ -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
|
||||
@@ -74,6 +80,19 @@ jobs:
|
||||
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()
|
||||
@@ -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="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
|
||||
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease --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}"
|
||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||
# 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/release/app-release.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: ProLibertateGames-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,109 @@
|
||||
# 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/ProLibertateGames>.
|
||||
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.prolibertategames` (+ `.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/ProLibertateGames/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 expansions
|
||||
(toggles, `CatanOptions.expansions`; none on is the base game): the base game (`docs/RULES-catan.md`), Cities & Knights
|
||||
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
|
||||
`CatanState.ck`, which is null in the base game) and Seafarers
|
||||
(`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind
|
||||
`CatanState.sea`). The base and Seafarers rules read the board from `state.topo`, not from
|
||||
`CatanBoard` directly, because a Seafarers map is not the 19-hex island
|
||||
(Cities & Knights is always the standard island and still uses `CatanBoard`). The
|
||||
Catan Universe reverse engineering that shaped the landscape HUD and trade board
|
||||
is in `docs/RE-catan-universe.md`. 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/ProLibertateGames>. 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.prolibertategames.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.prolibertategames`, 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.prolibertategames.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.prolibertategames` | `ca.wallacenet.prolibertategames.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**
|
||||
@@ -326,6 +372,10 @@ game (index: [docs/README.md](docs/README.md)):
|
||||
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
||||
the game is out of print and the rulebook could not be obtained, so the
|
||||
document says which parts are attested and which were derived
|
||||
- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the
|
||||
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
|
||||
|
||||
@@ -348,11 +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, 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
|
||||
```
|
||||
|
||||
|
||||
+14
-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.prolibertategames.dev" else "ca.wallacenet.prolibertategames"
|
||||
// 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 {
|
||||
@@ -123,6 +104,11 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Signed with the same key as the debug builds, so a release installs
|
||||
// over what is already on a device; a different key would make the
|
||||
// updater's installs fail with a signature mismatch.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -10,11 +10,14 @@ import org.prolibertate.games.net.LobbyController
|
||||
import org.prolibertate.games.net.ScorekeeperSession
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||
import org.prolibertate.games.ui.AppEnv
|
||||
import org.prolibertate.games.ui.AppRoot
|
||||
import org.prolibertate.games.ui.theme.ProLibertateTheme
|
||||
import org.prolibertate.games.update.Updater
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import org.prolibertate.games.game.engine.SavedGameStore
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@@ -40,6 +43,8 @@ class MainActivity : ComponentActivity() {
|
||||
deviceId = peerId,
|
||||
),
|
||||
peerId = peerId,
|
||||
saves = SavedGameStore(File(filesDir, "saved-games")),
|
||||
yahtzeeStats = YahtzeeStatsRepository(applicationContext),
|
||||
)
|
||||
|
||||
setContent {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -93,6 +106,7 @@ object GameCatalog {
|
||||
const val TAYU = "tayu"
|
||||
const val MORRIS = "morris"
|
||||
const val PIRATES = "pirates"
|
||||
const val MEOWDOKU = "meowdoku"
|
||||
|
||||
/**
|
||||
* The island game the collection is named for. Listed under Board Games
|
||||
@@ -347,11 +361,19 @@ 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.
|
||||
available = false,
|
||||
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = MEOWDOKU,
|
||||
title = "Meowdoku",
|
||||
menus = setOf(GameMenu.BOARD, GameMenu.SOLITAIRE),
|
||||
minPlayers = 1,
|
||||
maxPlayers = 1,
|
||||
teamBased = false,
|
||||
blurb = "One cat in every row, column and colour, and no two touching. " +
|
||||
"Wrong taps cost a heart; the board only ever accepts a cat that belongs.",
|
||||
available = true,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -22,8 +25,19 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* out and its first step returned; the rest follows on the moves after, because
|
||||
* the same calculation reaches the same turn.
|
||||
*/
|
||||
class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
GameAi<BackgammonState, BackgammonMove> {
|
||||
class BackgammonAi(
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val level: BackgammonLevel? = null,
|
||||
) : GameAi<BackgammonState, BackgammonMove> {
|
||||
|
||||
/**
|
||||
* The turn an easy seat has settled on, so that a deliberate slip is made
|
||||
* once per roll rather than re-rolled on every checker (which would stitch
|
||||
* halves of different turns together).
|
||||
*/
|
||||
private var plan: List<BackgammonMove> = emptyList()
|
||||
private var planned = 0
|
||||
private var planRoll = -1
|
||||
|
||||
override fun chooseMove(
|
||||
state: BackgammonState,
|
||||
@@ -31,48 +45,104 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
legal: List<BackgammonMove>,
|
||||
): BackgammonMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
val profile = profiles.of(seat)
|
||||
val base = level ?: state.options.level
|
||||
val setting = when (profile.level) {
|
||||
AiLevel.MEDIUM -> base
|
||||
// Easy never looks at the dice to come.
|
||||
AiLevel.EASY -> if (base == BackgammonLevel.STRONG) BackgammonLevel.CLUB else base
|
||||
AiLevel.HARD -> BackgammonLevel.STRONG
|
||||
}
|
||||
val tuning = tuningFor(profile)
|
||||
|
||||
val random = Random(state.rollsMade * 31L + state.moveLog.size)
|
||||
|
||||
if (setting == BackgammonLevel.CASUAL) {
|
||||
// Not thoughtless: it still prefers to hit, to come in off the bar
|
||||
// and to bear off, which is most of what a beginner knows.
|
||||
val scored = legal.map { move -> move to rough(state, seat, move) }
|
||||
val scored = legal.map { move -> move to rough(state, seat, move, profile) }
|
||||
val best = scored.maxOf { it.second }
|
||||
val equal = scored.filter { it.second == best }
|
||||
return equal[random.nextInt(equal.size)].first
|
||||
}
|
||||
|
||||
val easy = profile.level == AiLevel.EASY
|
||||
if (easy && planRoll == state.rollsMade) {
|
||||
val next = plan.getOrNull(planned)
|
||||
if (next != null && next in legal) {
|
||||
planned++
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
val sequences = BackgammonRules.deepestSequences(state, seat)
|
||||
if (sequences.isEmpty()) return legal.first()
|
||||
|
||||
var best = sequences.first()
|
||||
var bestScore = -Double.MAX_VALUE
|
||||
val scored = ArrayList<Pair<List<BackgammonMove>, Double>>(sequences.size)
|
||||
for (sequence in sequences) {
|
||||
val after = BackgammonRules.playSequence(state, seat, sequence)
|
||||
val score = if (setting == BackgammonLevel.STRONG) {
|
||||
// What the position is worth once the dice have had their say.
|
||||
evaluate(after, seat) - averageReply(after, other(seat))
|
||||
} else {
|
||||
evaluate(after, seat)
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = sequence
|
||||
}
|
||||
scored += sequence to evaluate(after, seat, tuning)
|
||||
}
|
||||
return best.first()
|
||||
|
||||
if (setting == BackgammonLevel.STRONG) {
|
||||
// What the position is worth once the dice have had their say. A hard
|
||||
// seat only spends the twenty-one-roll average on the turns that
|
||||
// already look best, which keeps a phone's thinking time bounded.
|
||||
val finalists = if (profile.level == AiLevel.HARD && scored.size > FINALISTS) {
|
||||
scored.sortedByDescending { it.second }.take(FINALISTS)
|
||||
} else {
|
||||
scored
|
||||
}
|
||||
val rescored = finalists.map { (sequence, _) ->
|
||||
val after = BackgammonRules.playSequence(state, seat, sequence)
|
||||
sequence to (evaluate(after, seat, tuning) - averageReply(after, other(seat)))
|
||||
}
|
||||
return chooseFrom(rescored, profile, state, legal)
|
||||
}
|
||||
return chooseFrom(scored, profile, state, legal)
|
||||
}
|
||||
|
||||
/** Best sequence first move; an easy seat sometimes takes a worse (but better-half) turn. */
|
||||
private fun chooseFrom(
|
||||
scored: List<Pair<List<BackgammonMove>, Double>>,
|
||||
profile: AiProfile,
|
||||
state: BackgammonState,
|
||||
legal: List<BackgammonMove>,
|
||||
): BackgammonMove {
|
||||
var best = scored.first()
|
||||
for (entry in scored) if (entry.second > best.second) best = entry
|
||||
var chosen = best.first
|
||||
if (profile.level == AiLevel.EASY) {
|
||||
val ranked = scored.sortedByDescending { it.second }.map { it.first }
|
||||
chosen = profile.pick(ranked)
|
||||
plan = chosen
|
||||
planned = 1
|
||||
planRoll = state.rollsMade
|
||||
}
|
||||
val first = chosen.first()
|
||||
return if (first in legal) first else legal.first()
|
||||
}
|
||||
|
||||
private fun tuningFor(profile: AiProfile): Tuning {
|
||||
val style = profile.style
|
||||
var blot = 1.0 - 0.3 * style.boldness
|
||||
if (profile.level == AiLevel.EASY) blot *= 0.6 // overlooks some of the danger
|
||||
return Tuning(blot, style.boldness, style.spite)
|
||||
}
|
||||
|
||||
/** What a seat's temperament does to the evaluation; [NEUTRAL] is the standard player. */
|
||||
private class Tuning(val blot: Double, val bold: Double, val spite: Double)
|
||||
|
||||
/** A quick opinion of one checker, for the level that does not think. */
|
||||
private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove): Int {
|
||||
private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove, profile: AiProfile): Int {
|
||||
val landing = BackgammonRules.destinationOf(seat, move.from, move.die)
|
||||
var score = 0
|
||||
if (move.from == BAR) score += 50
|
||||
if (landing == null) score += 40
|
||||
if (landing != null && state.countOn(landing, other(seat)) == 1) score += 60
|
||||
if (landing != null && state.countOn(landing, other(seat)) == 1) {
|
||||
score += 60 + ((profile.style.boldness + profile.style.spite) * 20).toInt()
|
||||
}
|
||||
if (landing != null && state.countOn(landing, seat) >= 1) score += 15
|
||||
return score
|
||||
}
|
||||
@@ -98,8 +168,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
val rolled = state.copy(turn = opponent, dice = dice, roll = listOf(high, low))
|
||||
val replies = BackgammonRules.deepestSequences(rolled, opponent)
|
||||
val bestReply = replies.maxOfOrNull { sequence ->
|
||||
evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent)
|
||||
} ?: evaluate(rolled, opponent)
|
||||
evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent, NEUTRAL)
|
||||
} ?: evaluate(rolled, opponent, NEUTRAL)
|
||||
total += chance * bestReply
|
||||
weight += chance
|
||||
}
|
||||
@@ -117,7 +187,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
* checkers are, and a made point in your own home board is worth having
|
||||
* because it is one more number your opponent cannot come in on.
|
||||
*/
|
||||
private fun evaluate(state: BackgammonState, seat: Int): Double {
|
||||
private fun evaluate(state: BackgammonState, seat: Int, tuning: Tuning): Double {
|
||||
val enemy = other(seat)
|
||||
|
||||
if (state.phase == BackgammonPhase.GAME_OVER) {
|
||||
@@ -135,6 +205,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
score -= state.off[enemy] * BORNE_OFF
|
||||
score -= state.bar[seat] * ON_BAR
|
||||
score += state.bar[enemy] * ON_BAR
|
||||
// A gambler and a spoiler both like having an opponent on the bar.
|
||||
score += state.bar[enemy] * (tuning.bold * 4.0 + tuning.spite * 8.0)
|
||||
|
||||
for (point in 0 until POINTS) {
|
||||
val mine = state.countOn(point, seat)
|
||||
@@ -142,6 +214,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
|
||||
if (mine >= 2) {
|
||||
score += POINT_MADE
|
||||
// A cautious seat likes covered points a little more, a gambler less.
|
||||
score -= tuning.bold * 1.5
|
||||
// A point in your own home board is a door shut in the face of
|
||||
// anyone you send to the bar.
|
||||
if (point in homeOf(seat)) score += HOME_POINT
|
||||
@@ -151,7 +225,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
// A blot, weighted by how easily it can be reached: something
|
||||
// six away is in real danger, something twenty away is not.
|
||||
val distance = shortestEnemyDistance(state, seat, point)
|
||||
score -= when {
|
||||
score -= tuning.blot * when {
|
||||
distance <= 0 -> 0.0
|
||||
distance <= 6 -> BLOT_NEAR
|
||||
distance <= 12 -> BLOT_FAR
|
||||
@@ -160,7 +234,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
}
|
||||
}
|
||||
|
||||
score += primeLength(state, seat) * PRIME
|
||||
score += primeLength(state, seat) * (PRIME + tuning.spite * 3.0)
|
||||
return score
|
||||
}
|
||||
|
||||
@@ -199,6 +273,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
|
||||
|
||||
private companion object {
|
||||
const val WIN = 10_000.0
|
||||
const val FINALISTS = 8
|
||||
val NEUTRAL = Tuning(1.0, 0.0, 0.0)
|
||||
|
||||
const val PIP = 1.0
|
||||
const val BORNE_OFF = 12.0
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic Catan opponent.
|
||||
*
|
||||
* It plays a legal, mildly strategic game: it settles on the best-producing
|
||||
* intersections it can reach, raises cities, chases Largest Army with its
|
||||
* knights, spends its progress cards when they complete something, trades
|
||||
* its surplus to the bank or the table to close a one-card gap, and robs
|
||||
* whoever is winning.
|
||||
*
|
||||
* Honesty is a design constraint, not an accident: this runs on the host,
|
||||
* where the authoritative state is fully visible. It reads only public
|
||||
* information — terrain, numbers, buildings, roads, resource counts,
|
||||
* dev-card counts, knights played — plus its own hand, and never looks
|
||||
* into the deck, another seat's development cards, or the seed beyond the
|
||||
* roll the rules derive from it.
|
||||
*
|
||||
* Each seat's [AiProfiles] entry shapes how it plays. The default profile
|
||||
* (medium, balanced) is the AI described above. Easy settles for worse
|
||||
* spots, trades and builds, now and then ends its turn early, and takes
|
||||
* slightly bad trades. Hard weighs setup spots by balance and port synergy,
|
||||
* values cards by what its next build is missing, races for Longest Road,
|
||||
* keeps the robber off its own hexes and off the table's non-leaders, and
|
||||
* is choosier about whom it trades with. Style nudges the tradeoffs: bold
|
||||
* seats expand, cautious seats spend down before a seven can bite, spiteful
|
||||
* seats go after the leader.
|
||||
*/
|
||||
class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<CatanState, CatanMove> {
|
||||
|
||||
/** Plays the Cities & Knights tables; this class keeps the base game. */
|
||||
private val ck = CatanCkAi(this, profiles)
|
||||
|
||||
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
val profile = profiles.of(seat)
|
||||
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>(), profile)
|
||||
|
||||
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
|
||||
|
||||
// Gold: take what the seat wants most.
|
||||
CatanPhase.GOLD -> chooseGold(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>(), profile)
|
||||
|
||||
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>(), profile)
|
||||
|
||||
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||
?: error("Nothing to choose in phase ${state.phase}")
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Setup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
internal fun chooseSetup(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
places: List<SetupPlace>,
|
||||
profile: AiProfile = AiProfile.DEFAULT,
|
||||
): SetupPlace {
|
||||
require(places.isNotEmpty()) { "No legal settlement placement" }
|
||||
// A bold seat cares more where its road leads; a cautious one less.
|
||||
val direction = 0.25 + 0.1 * profile.style.boldness
|
||||
return when (profile.level) {
|
||||
// Easy looks only at the spot itself, and sometimes settles for the runner-up.
|
||||
AiLevel.EASY -> profile.pick(places.sortedByDescending { spotValue(state, it.vertex) })
|
||||
|
||||
AiLevel.HARD -> places.maxByOrNull { place ->
|
||||
hardSpot(state, seat, place.vertex) + direction * placeDirection(state, place)
|
||||
} ?: places.first()
|
||||
|
||||
AiLevel.MEDIUM -> places.maxByOrNull { place ->
|
||||
spotValue(state, place.vertex) + direction * placeDirection(state, place)
|
||||
} ?: places.first()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A spot as a seat that plans would see it: the plain production value,
|
||||
* plus resources the seat does not yet draw, less piling onto one it
|
||||
* already has plenty of, and ports worth what the seat will produce to
|
||||
* trade through them.
|
||||
*/
|
||||
internal fun hardSpot(state: CatanState, seat: Int, vertex: Int): Double {
|
||||
if (state.buildings[vertex] != null) return -1.0
|
||||
val topo = state.topo
|
||||
val owned = HashMap<Resource, Double>()
|
||||
for (v in state.buildings.indices) {
|
||||
val b = state.buildings[v] ?: continue
|
||||
if (b.owner != seat) continue
|
||||
val mult = if (b.type == BuildingType.CITY) 2.0 else 1.0
|
||||
for (h in topo.VERTEX_HEXES[v]) {
|
||||
val res = state.terrains[h].produces ?: continue
|
||||
owned[res] = (owned[res] ?: 0.0) + mult * CatanBoard.numberOdds(state.numbers[h]) / 36.0
|
||||
}
|
||||
}
|
||||
val here = HashMap<Resource, Double>()
|
||||
for (h in topo.VERTEX_HEXES[vertex]) {
|
||||
val res = state.terrains[h].produces ?: continue
|
||||
here[res] = (here[res] ?: 0.0) + CatanBoard.numberOdds(state.numbers[h]) / 36.0
|
||||
}
|
||||
var value = spotValue(state, vertex)
|
||||
value += 0.15 * here.keys.count { (owned[it] ?: 0.0) <= 0.0 }
|
||||
for ((res, p) in here) {
|
||||
if ((owned[res] ?: 0.0) > 0.15) value -= 0.3 * p
|
||||
}
|
||||
val total = owned.values.sum() + here.values.sum()
|
||||
for (e in topo.VERTEX_EDGES[vertex]) {
|
||||
val port = state.ports.firstOrNull { it.edge == e } ?: continue
|
||||
value += if (port.resource == null) {
|
||||
0.05 + 0.15 * total
|
||||
} else {
|
||||
0.9 * ((owned[port.resource] ?: 0.0) + (here[port.resource] ?: 0.0))
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the setup road points: the best settlement spot two edges out
|
||||
* through the far end of the road. The vertex next to the new
|
||||
* settlement can never hold one (the distance rule), so what a road is
|
||||
* worth is what it reaches past it.
|
||||
*/
|
||||
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
|
||||
val topo = state.topo
|
||||
val far = topo.otherEnd(place.edge, place.vertex)
|
||||
return topo.VERTEX_NEIGHBORS[far]
|
||||
.filter { it != place.vertex && it !in topo.VERTEX_NEIGHBORS[place.vertex] }
|
||||
.maxOfOrNull { spotValue(state, it) }
|
||||
?: 0.0
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Dice, discards and the robber
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseDiscard(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
discards: List<Discard>,
|
||||
profile: AiProfile,
|
||||
): Discard {
|
||||
require(discards.isNotEmpty()) { "No legal discard" }
|
||||
// Throw away what it wants least; grain and ore are the city, wool
|
||||
// is mostly a dev-card tax.
|
||||
return when (profile.level) {
|
||||
AiLevel.EASY -> profile.pick(discards.sortedBy { d -> d.cards.sumOf { want(it) } })
|
||||
|
||||
// Keep the hand that is closest to paying for something.
|
||||
AiLevel.HARD -> {
|
||||
val hand = state.resources[seat]
|
||||
discards.maxByOrNull { d ->
|
||||
val left = hand.toMutableMap()
|
||||
for (c in d.cards) left[c] = (left[c] ?: 0) - 1
|
||||
progress(left) - 0.01 * d.cards.sumOf { want(it) }
|
||||
} ?: discards.first()
|
||||
}
|
||||
|
||||
AiLevel.MEDIUM -> discards.minByOrNull { discard ->
|
||||
discard.cards.sumOf { want(it) }
|
||||
} ?: discards.first()
|
||||
}
|
||||
}
|
||||
|
||||
/** How close a hand is to the nearest of the things worth buying, a card count over a price, plus one if it pays. */
|
||||
private fun progress(hand: Map<Resource, Int>): Double = SHOPPING.maxOf { cost ->
|
||||
val have = cost.entries.sumOf { (r, n) -> minOf(maxOf(hand[r] ?: 0, 0), n) }
|
||||
have.toDouble() / cost.values.sum() + if (covers(hand, cost)) 1.0 else 0.0
|
||||
}
|
||||
|
||||
private fun chooseGold(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
|
||||
val picks = legal.filterIsInstance<PickGold>()
|
||||
if (picks.isEmpty()) return legal.first()
|
||||
return when (profile.level) {
|
||||
AiLevel.EASY -> profile.pick(picks.sortedByDescending { p -> p.cards.sumOf { want(it) } })
|
||||
AiLevel.HARD -> picks.maxByOrNull { p -> p.cards.sumOf { need(state, seat, it) } } ?: picks.first()
|
||||
AiLevel.MEDIUM -> picks.maxByOrNull { pick -> pick.cards.sumOf { want(it) } } ?: legal.first()
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseRobber(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
moves: List<MoveRobber>,
|
||||
profile: AiProfile,
|
||||
): MoveRobber {
|
||||
require(moves.isNotEmpty()) { "No legal robber move" }
|
||||
val points = CatanRules.publicVictoryPoints(state)
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val spite = profile.style.spite
|
||||
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
|
||||
val victimWeight = 0.3 + 0.1 * profile.style.boldness
|
||||
fun score(move: MoveRobber): Double {
|
||||
val hex = state.topo.hexIndex.getValue(move.hex)
|
||||
var score = 0.0
|
||||
for (v in state.topo.HEX_CORNERS[hex]) {
|
||||
val building = state.buildings[v] ?: continue
|
||||
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||
val raw = worth * CatanBoard.numberOdds(state.numbers[hex])
|
||||
if (building.owner == seat) {
|
||||
// A hard seat will not sit the robber on its own income.
|
||||
if (hard) score -= 0.8 * raw / 36.0
|
||||
continue
|
||||
}
|
||||
score += raw / 36.0
|
||||
if (building.owner == leader) score += spite * raw / 36.0
|
||||
}
|
||||
if (move.victim != null) {
|
||||
val cards = state.resourceCount(move.victim)
|
||||
if (!hard || cards > 0) {
|
||||
score += 0.15 * cards + victimWeight * points[move.victim]
|
||||
if (move.victim == leader) score += 0.4 * spite
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
return if (profile.level == AiLevel.EASY) {
|
||||
profile.pick(moves.sortedByDescending { score(it) })
|
||||
} else {
|
||||
moves.maxByOrNull { score(it) } ?: moves.first()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The main phase
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
|
||||
val easy = profile.level == AiLevel.EASY
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val boldness = profile.style.boldness
|
||||
val endTurn = legal.lastOrNull { it is EndTurn } ?: legal.first()
|
||||
|
||||
// Easy loses the thread now and then and just passes.
|
||||
if (easy && profile.slips() && profile.random.nextBoolean()) return endTurn
|
||||
|
||||
val cities = legal.filterIsInstance<BuildCity>()
|
||||
val settlements = legal.filterIsInstance<BuildSettlement>()
|
||||
if (hard && (cities.isNotEmpty() || settlements.isNotEmpty())) {
|
||||
// A city doubles what the seat has; a settlement spreads it and
|
||||
// takes a spot nobody else will. Bold seats lean to the spread.
|
||||
val city = cities.maxByOrNull { spotValue(state, it.vertex) }
|
||||
val settlement = settlements.maxByOrNull { hardSpot(state, seat, it.vertex) }
|
||||
val cityScore = if (city != null) spotValue(state, city.vertex) + 0.15 else Double.NEGATIVE_INFINITY
|
||||
val settleScore = if (settlement != null) {
|
||||
hardSpot(state, seat, settlement.vertex) + 0.3 + 0.1 * boldness
|
||||
} else {
|
||||
Double.NEGATIVE_INFINITY
|
||||
}
|
||||
if (city != null && cityScore >= settleScore) return city
|
||||
if (settlement != null) return settlement
|
||||
} else {
|
||||
// A city doubles the best production the seat already owns.
|
||||
pickBest(cities, profile) { spotValue(state, it.vertex) }?.let { return it }
|
||||
|
||||
pickBest(settlements, profile) { spotValue(state, it.vertex) }?.let { return it }
|
||||
}
|
||||
|
||||
// Seafarers: a ship toward an island nobody has landed on is worth more than a card.
|
||||
legal.filterIsInstance<BuildShip>()
|
||||
.filter { shipValue(state, seat, it.edge) > 0.5 }
|
||||
.maxByOrNull { shipValue(state, seat, it.edge) }
|
||||
?.let { return it }
|
||||
|
||||
// A knight is worth playing whenever one is playable: it blocks an
|
||||
// opponent's best hex and marches toward Largest Army.
|
||||
if (!(easy && profile.slips())) {
|
||||
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
|
||||
}
|
||||
|
||||
if (!(easy && profile.slips())) {
|
||||
yearOfPlenty(state, seat, legal, profile)?.let { return it }
|
||||
|
||||
monopoly(state, legal)?.let { return it }
|
||||
}
|
||||
|
||||
// Road Building is worth two free segments whenever there is
|
||||
// somewhere to put them.
|
||||
if (legal.filterIsInstance<PlayRoadBuilding>().isNotEmpty() &&
|
||||
legal.filterIsInstance<BuildRoad>().size >= 2
|
||||
) {
|
||||
return legal.filterIsInstance<PlayRoadBuilding>().first()
|
||||
}
|
||||
|
||||
// Hard seat in a Longest Road race puts the road down before spending on cards.
|
||||
val roadThreshold = 0.1 - 0.04 * boldness
|
||||
if (hard && roadRace(state, seat)) {
|
||||
legal.filterIsInstance<BuildRoad>()
|
||||
.maxByOrNull { roadValue(state, it) }
|
||||
?.let { return it }
|
||||
}
|
||||
|
||||
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
|
||||
|
||||
// Seafarers: a ship that heads for a settlement spot worth having.
|
||||
legal.filterIsInstance<BuildShip>()
|
||||
.filter { shipValue(state, seat, it.edge) > 0.15 }
|
||||
.maxByOrNull { shipValue(state, seat, it.edge) }
|
||||
?.let { return it }
|
||||
|
||||
// A paid road only when it reaches toward a spot worth settling.
|
||||
legal.filterIsInstance<BuildRoad>()
|
||||
.filter { roadValue(state, it) > roadThreshold }
|
||||
.maxByOrNull { roadValue(state, it) }
|
||||
?.let { return it }
|
||||
|
||||
// A bank trade only when it completes a build this turn; trading
|
||||
// four for one on spec is how hands evaporate.
|
||||
legal.filterIsInstance<MaritimeTrade>()
|
||||
.filter { completesBuild(state, seat, it) }
|
||||
.maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 }
|
||||
?.let { return it }
|
||||
|
||||
if (hard) {
|
||||
// Pile of one card, short of another: convert before it is stuck in hand.
|
||||
legal.filterIsInstance<MaritimeTrade>()
|
||||
.filter { surplusTrade(state, seat, it) }
|
||||
.minByOrNull { CatanRules.bestPortRatio(state, seat, it.give) }
|
||||
?.let { return it }
|
||||
}
|
||||
|
||||
// Stay under the seven: a cautious (or careful) seat sells down a big hand.
|
||||
if ((hard || boldness < 0) && state.resourceCount(seat) > DISCARD_LIMIT) {
|
||||
legal.filterIsInstance<MaritimeTrade>()
|
||||
.maxByOrNull { t ->
|
||||
(state.resources[seat][t.give] ?: 0) * 10.0 -
|
||||
CatanRules.bestPortRatio(state, seat, t.give) * 3.0 + need(state, seat, t.get)
|
||||
}
|
||||
?.let { return it }
|
||||
}
|
||||
|
||||
if (!(easy && profile.slips())) {
|
||||
offer(state, seat, profile)?.let { return it }
|
||||
}
|
||||
|
||||
return endTurn
|
||||
}
|
||||
|
||||
/** Picks the best of [items] by [score]; easy sometimes takes a near miss. */
|
||||
private fun <T> pickBest(items: List<T>, profile: AiProfile, score: (T) -> Double): T? {
|
||||
if (items.isEmpty()) return null
|
||||
if (profile.level != AiLevel.EASY) return items.maxByOrNull(score)
|
||||
return profile.pick(items.sortedByDescending(score))
|
||||
}
|
||||
|
||||
/** The cheapest things worth saving for, as the shopping list a hard seat weighs hands against. */
|
||||
private val SHOPPING = listOf(
|
||||
CatanRules.CITY_COST,
|
||||
CatanRules.SETTLEMENT_COST,
|
||||
CatanRules.DEV_COST,
|
||||
CatanRules.ROAD_COST,
|
||||
)
|
||||
|
||||
/** The shopping list entry the hand is closest to paying for, among a city, a settlement and a card. */
|
||||
private fun nearestTarget(hand: Map<Resource, Int>): Map<Resource, Int> =
|
||||
listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST, CatanRules.DEV_COST)
|
||||
.minByOrNull { cost -> cost.entries.sumOf { (r, n) -> maxOf(0, n - (hand[r] ?: 0)) } }
|
||||
?: CatanRules.CITY_COST
|
||||
|
||||
/** What one card of [res] is worth to this seat right now: more if the next build lacks it, less if it is piled up. */
|
||||
private fun need(state: CatanState, seat: Int, res: Resource): Double {
|
||||
val hand = state.resources[seat]
|
||||
var v = want(res)
|
||||
if ((hand[res] ?: 0) < (nearestTarget(hand)[res] ?: 0)) v += 0.6
|
||||
if ((hand[res] ?: 0) >= 4) v -= 0.4
|
||||
return v
|
||||
}
|
||||
|
||||
/** Whether this bank trade turns a pile (beyond any price) into a card the next build lacks. */
|
||||
private fun surplusTrade(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
|
||||
val hand = state.resources[seat]
|
||||
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
|
||||
val keep = SHOPPING.maxOf { it[move.give] ?: 0 }
|
||||
val target = nearestTarget(hand)
|
||||
return (hand[move.give] ?: 0) - keep >= ratio &&
|
||||
(hand[move.get] ?: 0) < (target[move.get] ?: 0)
|
||||
}
|
||||
|
||||
/** Whether [seat] is in the hunt for Longest Road: close enough to the holder, or the road is unclaimed. */
|
||||
private fun roadRace(state: CatanState, seat: Int): Boolean {
|
||||
val holder = state.longestRoadOwner
|
||||
if (holder == seat) return false
|
||||
val mine = CatanRules.longestRoadFor(state, seat)
|
||||
if (mine < 3) return false
|
||||
return holder == null || mine + 1 >= CatanRules.longestRoadFor(state, holder)
|
||||
}
|
||||
|
||||
/** The opponent with the most public points, if any. */
|
||||
private fun leaderOf(state: CatanState, seat: Int): Int? {
|
||||
val points = CatanRules.publicVictoryPoints(state)
|
||||
return (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
|
||||
}
|
||||
|
||||
/** Year of Plenty: two of whatever the nearest build is shortest of. */
|
||||
private fun yearOfPlenty(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): PlayYearOfPlenty? {
|
||||
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
|
||||
if (takes.isEmpty()) return null
|
||||
val shortage = shortage(state) ?: return null
|
||||
if (profile.level == AiLevel.HARD) {
|
||||
return takes.maxByOrNull { take -> take.take.sumOf { need(state, seat, it) } }
|
||||
}
|
||||
return takes.maxByOrNull { take ->
|
||||
take.take.count { it == shortage } * 2 + take.take.size
|
||||
}
|
||||
}
|
||||
|
||||
/** Monopoly: declare the resource everyone else is hoarding, of the ones wanted. */
|
||||
private fun monopoly(state: CatanState, legal: List<CatanMove>): PlayMonopoly? {
|
||||
val plays = legal.filterIsInstance<PlayMonopoly>()
|
||||
if (plays.isEmpty()) return null
|
||||
val best = plays.maxByOrNull {
|
||||
19 - CatanRules.bankSupply(state, it.resource) + want(it.resource)
|
||||
} ?: return null
|
||||
// Only worth the card when the table is actually holding a pile.
|
||||
return if (19 - CatanRules.bankSupply(state, best.resource) >= 10) best else null
|
||||
}
|
||||
|
||||
/** What the seat is shortest of for its next city, else its next settlement. */
|
||||
private fun shortage(state: CatanState): Resource? {
|
||||
val seat = state.turn
|
||||
val targets = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
|
||||
for (cost in targets) {
|
||||
val missing = cost.entries.filter { (res, need) ->
|
||||
(state.resources[seat][res] ?: 0) < need
|
||||
}
|
||||
if (missing.isNotEmpty()) {
|
||||
return missing.maxByOrNull { (res, need) -> need - (state.resources[seat][res] ?: 0) }?.key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun completesBuild(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
|
||||
val hand = state.resources[seat].toMutableMap()
|
||||
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
|
||||
hand[move.give] = (hand[move.give] ?: 0) - ratio
|
||||
hand[move.get] = (hand[move.get] ?: 0) + 1
|
||||
return covers(hand, CatanRules.CITY_COST) || covers(hand, CatanRules.SETTLEMENT_COST)
|
||||
}
|
||||
|
||||
private fun covers(hand: Map<Resource, Int>, cost: Map<Resource, Int>): Boolean =
|
||||
cost.all { (res, need) -> (hand[res] ?: 0) >= need }
|
||||
|
||||
/**
|
||||
* A domestic offer for exactly the card that closes a build, paid for
|
||||
* with two of the surplus — offered to whoever visibly holds the most
|
||||
* of what is missing. Constructed rather than enumerated (see
|
||||
* [OfferTrade]), so it is checked against the rules before it is spoken.
|
||||
*
|
||||
* At most one offer a turn: a declined offer changes nothing, so
|
||||
* re-offering it would only bounce between the same two seats forever.
|
||||
*/
|
||||
private fun offer(state: CatanState, seat: Int, profile: AiProfile): OfferTrade? {
|
||||
if (offeredThisTurn(state, seat)) return null
|
||||
val seatHand = state.resources[seat]
|
||||
val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
|
||||
for (cost in builds) {
|
||||
val missing = cost.entries.filter { (res, need) -> (seatHand[res] ?: 0) < need }
|
||||
if (missing.size != 1) continue
|
||||
val need = missing.first().key
|
||||
val surplus = Resource.entries
|
||||
.filter { it != need }
|
||||
.filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) }
|
||||
.sortedByDescending { seatHand[it] ?: 0 }
|
||||
if (surplus.isEmpty()) continue
|
||||
val holders = (0 until state.playerCount)
|
||||
.filter { it != seat }
|
||||
.filter { (state.resources[it][need] ?: 0) > 0 }
|
||||
val target = (if (profile.level == AiLevel.HARD) {
|
||||
// Do not feed the leader when someone else can make the swap.
|
||||
val points = CatanRules.publicVictoryPoints(state)
|
||||
holders.maxByOrNull { (state.resources[it][need] ?: 0) - 0.5 * points[it] }
|
||||
} else {
|
||||
holders.maxByOrNull { state.resources[it][need] ?: 0 }
|
||||
}) ?: continue
|
||||
val give = listOf(surplus.first(), surplus.first())
|
||||
val trade = OfferTrade(to = target, give = give, get = listOf(need))
|
||||
if (CatanRules.isLegalMove(state, seat, trade)) return trade
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [seat] has already put an offer on the table this turn, read
|
||||
* from the public log: the lines after this seat's last roll are this
|
||||
* turn's, and an offer can only be spoken on the offerer's own turn.
|
||||
*/
|
||||
private fun offeredThisTurn(state: CatanState, seat: Int): Boolean {
|
||||
val sinceRoll = state.log
|
||||
.drop(state.log.indexOfLast { it.startsWith("Seat $seat rolls") } + 1)
|
||||
return sinceRoll.any { it.startsWith("Seat $seat offers") }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Free roads and trade responses
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
|
||||
val roads = legal.filterIsInstance<BuildRoad>().filter { roadValue(state, it) > 0.05 }
|
||||
val road = if (profile.level == AiLevel.EASY) {
|
||||
profile.pick(roads.sortedByDescending { roadValue(state, it) }.ifEmpty { return freeRoadFallback(legal) })
|
||||
} else {
|
||||
roads.maxByOrNull { roadValue(state, it) }
|
||||
}
|
||||
return road ?: freeRoadFallback(legal)
|
||||
}
|
||||
|
||||
private fun freeRoadFallback(legal: List<CatanMove>): CatanMove =
|
||||
legal.firstOrNull { it is EndFreeRoads }
|
||||
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
|
||||
?: legal.first()
|
||||
|
||||
/**
|
||||
* How much extra the incoming cards must be worth to take a swap: nothing
|
||||
* for the standard seat, a hard or spiteful seat wants more from the
|
||||
* table's leader, and an easy seat is not too fussy.
|
||||
*/
|
||||
private fun tradeMargin(state: CatanState, seat: Int, other: Int, profile: AiProfile): Double {
|
||||
var margin = 0.0
|
||||
if (profile.level == AiLevel.EASY) margin -= 0.6
|
||||
val leader = leaderOf(state, seat)
|
||||
if (leader != null && other == leader) {
|
||||
if (profile.level == AiLevel.HARD) margin += 0.4
|
||||
margin += 0.5 * profile.style.spite
|
||||
}
|
||||
return margin
|
||||
}
|
||||
|
||||
private fun tradeValue(state: CatanState, seat: Int, res: Resource, profile: AiProfile): Double =
|
||||
if (profile.level == AiLevel.HARD) need(state, seat, res) else want(res)
|
||||
|
||||
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
|
||||
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 { tradeValue(state, seat, it, profile) }
|
||||
val outgoing = offer.get.sumOf { tradeValue(state, seat, it, profile) }
|
||||
return if (incoming > outgoing + tradeMargin(state, seat, offer.by, profile)) {
|
||||
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, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
|
||||
val offer = state.pendingTrade ?: return legal.first()
|
||||
fun gain(c: TradeCounter) = c.give.sumOf { tradeValue(state, seat, it, profile) } -
|
||||
c.get.sumOf { tradeValue(state, seat, it, profile) }
|
||||
val best = legal.filterIsInstance<AcceptCounter>()
|
||||
.map { choice -> choice to offer.counters.first { it.by == choice.seat } }
|
||||
.maxByOrNull { (_, c) -> gain(c) }
|
||||
if (best != null) {
|
||||
val (choice, c) = best
|
||||
if (c.give.sumOf { tradeValue(state, seat, it, profile) } >
|
||||
c.get.sumOf { tradeValue(state, seat, it, profile) } + tradeMargin(state, seat, c.by, profile)
|
||||
) {
|
||||
return choice
|
||||
}
|
||||
}
|
||||
return legal.first { it is DismissCounters }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Valuations
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What a seat would think of building on [vertex]: production and ports. */
|
||||
internal fun spotValue(state: CatanState, vertex: Int): Double {
|
||||
if (state.buildings[vertex] != null) return -1.0
|
||||
var value = 0.0
|
||||
val kinds = mutableSetOf<Resource>()
|
||||
for (h in state.topo.VERTEX_HEXES[vertex]) {
|
||||
val resource = state.terrains[h].produces ?: continue
|
||||
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
|
||||
kinds += resource
|
||||
}
|
||||
value += 0.12 * kinds.size // variety keeps a build plan alive
|
||||
for (e in state.topo.VERTEX_EDGES[vertex]) {
|
||||
val port = state.ports.firstOrNull { it.edge == e } ?: continue
|
||||
value += if (port.resource == null) 0.1 else 0.2
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* What a road on this edge leads to: the best settlement spot it brings
|
||||
* within reach, discounted for being one step further out.
|
||||
*/
|
||||
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
|
||||
var best = 0.0
|
||||
val (from, to) = state.topo.EDGE_VERTICES[move.edge]
|
||||
for (endpoint in listOf(from, to)) {
|
||||
for (near in state.topo.VERTEX_NEIGHBORS[endpoint]) {
|
||||
best = maxOf(best, spotValue(state, near) * 0.8)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* What a ship on [edge] is heading for: the best open settlement spot reachable by sea from
|
||||
* its far end (the end the fleet does not already hold), discounted by how far off it is,
|
||||
* with a bonus for an island nobody has landed on.
|
||||
*/
|
||||
internal fun shipValue(state: CatanState, seat: Int, edge: Int): Double {
|
||||
val topo = state.topo
|
||||
val sea = state.sea ?: return 0.0
|
||||
val (a, b) = topo.EDGE_VERTICES[edge]
|
||||
fun held(v: Int): Boolean =
|
||||
state.buildings[v]?.owner == seat || topo.VERTEX_EDGES[v].any { sea.ships[it] == seat }
|
||||
val front = listOf(a, b).filter { !held(it) }.ifEmpty { return 0.0 }
|
||||
var best = 0.0
|
||||
val seen = mutableSetOf<Int>()
|
||||
var frontier = front
|
||||
var depth = 0
|
||||
while (frontier.isNotEmpty() && depth <= 5) {
|
||||
val next = mutableListOf<Int>()
|
||||
for (v in frontier) {
|
||||
if (!seen.add(v)) continue
|
||||
if (CatanRules.vertexOpenForSettlement(state, v)) {
|
||||
val island = topo.vertexIsland[v]
|
||||
val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER
|
||||
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
|
||||
best = maxOf(best, value)
|
||||
}
|
||||
for (e in topo.VERTEX_EDGES[v]) {
|
||||
if (!topo.edgeSea[e] || sea.ships[e] != NO_PLAYER) continue
|
||||
next += topo.otherEnd(e, v)
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
depth++
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** How much the AI wants to hold one card of [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
|
||||
Resource.BRICK -> 0.95
|
||||
Resource.WOOL -> 0.85 // abundant and only half of a dev card
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** A seven makes a seat holding more than this discard half. */
|
||||
const val DISCARD_LIMIT = 7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.atan2
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The geometry of one board: its hexes, the shared vertices and edges between
|
||||
* them, and which hexes are land.
|
||||
*
|
||||
* Everything is derived once, deterministically, from axial coordinates —
|
||||
* there is no randomness and no state here; the layout of a particular
|
||||
* game (which terrain and number sits on which hex) is generated elsewhere
|
||||
* ([CatanBoard.generate], [SeaBoards.generate]), and the live state lives in
|
||||
* [CatanState].
|
||||
*
|
||||
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
|
||||
* position is kept on an exact integer lattice so vertices shared by
|
||||
* neighbouring hexes are recognised by position instead of by floating
|
||||
* point: the centre of hex (q, r) is at lattice point (2q + r, 3r), and its
|
||||
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
|
||||
* in half-units of the hex grid; rendering scales and flips them (lattice
|
||||
* +Y is toward the top of the board).
|
||||
*
|
||||
* The standard island ([CatanBoard.base]) is the 19-hex hexagon. A Seafarers
|
||||
* map is a larger frame whose hexes are mostly sea; [land] says which.
|
||||
*/
|
||||
class Topology(
|
||||
/** Every hex of the frame, land and sea alike. */
|
||||
val HEXES: List<HexCoord>,
|
||||
/** Whether each hex is land. The base island is all land; Seafarers maps are mostly sea. */
|
||||
val land: List<Boolean> = List(HEXES.size) { true },
|
||||
/** The standard 19-hex island: checks its counts and uses the fixed nine-harbour frame. */
|
||||
private val standardFrame: Boolean = false,
|
||||
/** Harbours wanted on a non-standard map. */
|
||||
harbours: Int = 0,
|
||||
) {
|
||||
|
||||
val HEX_COUNT: Int = HEXES.size
|
||||
|
||||
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Vertices and edges
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Lattice position of each vertex, by vertex id. */
|
||||
val VERTEX_POS: List<Pair<Int, Int>>
|
||||
|
||||
val VERTEX_COUNT: Int
|
||||
|
||||
/** Vertex ids of hex h's six corners, corner 0 first (see [CORNER_OFFSETS]). */
|
||||
val HEX_CORNERS: List<List<Int>>
|
||||
|
||||
/** Both endpoints of each edge, as vertex ids, low id first. */
|
||||
val EDGE_VERTICES: List<Pair<Int, Int>>
|
||||
|
||||
val EDGE_COUNT: Int
|
||||
|
||||
/** Edges touching each vertex. */
|
||||
val VERTEX_EDGES: List<List<Int>>
|
||||
|
||||
/** Vertices one edge away from each vertex: the distance rule's horizon. */
|
||||
val VERTEX_NEIGHBORS: List<List<Int>>
|
||||
|
||||
/** Hexes touching each vertex. */
|
||||
val VERTEX_HEXES: List<List<Int>>
|
||||
|
||||
/** Hexes touching each edge: two for interior edges, one on the coast. */
|
||||
val EDGE_HEXES: List<List<Int>>
|
||||
|
||||
/** Edges adjacent to each edge (sharing a vertex). */
|
||||
val EDGE_NEIGHBORS: List<List<Int>>
|
||||
|
||||
/** Hexes adjacent to each hex, in [NEIGHBOR_STEPS] order. */
|
||||
val HEX_NEIGHBORS: List<List<Int>>
|
||||
|
||||
/** The coastal edges (those with only one hex) in ring order. */
|
||||
val COASTAL: List<Int>
|
||||
|
||||
/** The harbour positions: nine on the standard frame. */
|
||||
val PORT_POSITIONS: List<Int>
|
||||
|
||||
// --- Land and sea (all land on the standard island) --------------------
|
||||
|
||||
/** A settlement may stand here: at least one of its hexes is land. */
|
||||
val vertexLand: List<Boolean>
|
||||
|
||||
/** A road may lie here: at least one of its hexes is land. */
|
||||
val edgeLand: List<Boolean>
|
||||
|
||||
/** A ship may sail here: at least one of its hexes is sea. */
|
||||
val edgeSea: List<Boolean>
|
||||
|
||||
/** Which island each hex is on (a connected group of land hexes), or -1 for sea. */
|
||||
val islandOf: List<Int>
|
||||
|
||||
val islandCount: Int
|
||||
|
||||
/** Which island each vertex touches, or -1 for open water. */
|
||||
val vertexIsland: List<Int>
|
||||
|
||||
init {
|
||||
// Vertices: assign ids in first-seen order over hexes then corners.
|
||||
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
|
||||
val vertexPos = mutableListOf<Pair<Int, Int>>()
|
||||
val hexCorners = HEXES.map { hex ->
|
||||
val cx = 2 * hex.q + hex.r
|
||||
val cy = 3 * hex.r
|
||||
CORNER_OFFSETS.map { (dx, dy) ->
|
||||
val pos = (cx + dx) to (cy + dy)
|
||||
vertexIds.getOrPut(pos) {
|
||||
vertexPos += pos
|
||||
vertexPos.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
VERTEX_POS = vertexPos
|
||||
VERTEX_COUNT = vertexPos.size
|
||||
HEX_CORNERS = hexCorners
|
||||
|
||||
// Edges between a hex's consecutive corners, deduplicated by the
|
||||
// pair of vertex ids.
|
||||
val edgeIds = mutableMapOf<Pair<Int, Int>, Int>()
|
||||
val edgeVertices = mutableListOf<Pair<Int, Int>>()
|
||||
hexCorners.forEach { corners ->
|
||||
for (k in 0..5) {
|
||||
val a = corners[k]
|
||||
val b = corners[(k + 1) % 6]
|
||||
val key = minOf(a, b) to maxOf(a, b)
|
||||
edgeIds.getOrPut(key) {
|
||||
edgeVertices += key
|
||||
edgeVertices.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
EDGE_VERTICES = edgeVertices
|
||||
EDGE_COUNT = edgeVertices.size
|
||||
|
||||
val vertexEdges = List(VERTEX_COUNT) { mutableListOf<Int>() }
|
||||
val vertexHexes = List(VERTEX_COUNT) { mutableSetOf<Int>() }
|
||||
val edgeHexes = List(EDGE_COUNT) { mutableSetOf<Int>() }
|
||||
hexCorners.forEachIndexed { h, corners ->
|
||||
corners.forEach { v -> vertexHexes[v] += h }
|
||||
for (k in 0..5) {
|
||||
val a = corners[k]
|
||||
val b = corners[(k + 1) % 6]
|
||||
val key = minOf(a, b) to maxOf(a, b)
|
||||
val e = edgeIds.getValue(key)
|
||||
edgeHexes[e] += h
|
||||
vertexEdges[a] += e
|
||||
vertexEdges[b] += e
|
||||
}
|
||||
}
|
||||
// Distinct: an interior edge is walked once from each of the two hexes
|
||||
// it separates, so without this every shared edge would be counted
|
||||
// twice and an interior vertex would appear to have six edges.
|
||||
VERTEX_EDGES = vertexEdges.map { it.distinct() }
|
||||
VERTEX_HEXES = vertexHexes.map { it.sorted() }
|
||||
EDGE_HEXES = edgeHexes.map { it.sorted() }
|
||||
VERTEX_NEIGHBORS = List(VERTEX_COUNT) { v ->
|
||||
VERTEX_EDGES[v].map { otherEnd(it, v) }.distinct().sorted()
|
||||
}
|
||||
EDGE_NEIGHBORS = List(EDGE_COUNT) { e ->
|
||||
val (a, b) = EDGE_VERTICES[e]
|
||||
(VERTEX_EDGES[a] + VERTEX_EDGES[b]).distinct().filter { it != e }.sorted()
|
||||
}
|
||||
HEX_NEIGHBORS = HEXES.map { hex ->
|
||||
NEIGHBOR_STEPS.mapNotNull { (dq, dr) ->
|
||||
hexIndex[HexCoord(hex.q + dq, hex.r + dr)]
|
||||
}
|
||||
}
|
||||
|
||||
// The coast in walking order: sort the coastal edges by the angle of
|
||||
// their midpoint around the board centre, which is the lattice
|
||||
// origin. The standard board is a hexagon, so this yields one closed ring.
|
||||
COASTAL = EDGE_HEXES.withIndex()
|
||||
.filter { it.value.size == 1 }
|
||||
.map { (e, _) -> e }
|
||||
.sortedBy { e -> angleOf(midpoint(e)) }
|
||||
|
||||
vertexLand = List(VERTEX_COUNT) { v -> VERTEX_HEXES[v].any { land[it] } }
|
||||
edgeLand = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { land[it] } }
|
||||
edgeSea = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { !land[it] } }
|
||||
|
||||
// Islands: connected groups of land hexes.
|
||||
val island = IntArray(HEX_COUNT) { -1 }
|
||||
var islands = 0
|
||||
for (h in 0 until HEX_COUNT) {
|
||||
if (!land[h] || island[h] != -1) continue
|
||||
val stack = ArrayDeque(listOf(h))
|
||||
island[h] = islands
|
||||
while (stack.isNotEmpty()) {
|
||||
val at = stack.removeLast()
|
||||
for (n in HEX_NEIGHBORS[at]) {
|
||||
if (land[n] && island[n] == -1) {
|
||||
island[n] = islands
|
||||
stack.addLast(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
islands++
|
||||
}
|
||||
islandOf = island.toList()
|
||||
islandCount = islands
|
||||
vertexIsland = List(VERTEX_COUNT) { v ->
|
||||
VERTEX_HEXES[v].firstOrNull { land[it] }?.let { island[it] } ?: -1
|
||||
}
|
||||
|
||||
PORT_POSITIONS = if (standardFrame) {
|
||||
// Harbours sit roughly every third or fourth coastal edge, like the
|
||||
// physical frame: nine positions, steps summing to the whole ring.
|
||||
buildList {
|
||||
var at = 0
|
||||
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
|
||||
add(COASTAL[at % COASTAL.size])
|
||||
at += step
|
||||
}
|
||||
}.distinct()
|
||||
} else {
|
||||
seaPorts(harbours)
|
||||
}
|
||||
|
||||
if (standardFrame) {
|
||||
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
|
||||
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
|
||||
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
|
||||
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
|
||||
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
|
||||
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harbour sites on a map with islands: edges where land meets sea, taken
|
||||
* round the map by angle about the land's centre, [count] of them as evenly
|
||||
* spaced as the coast allows, never two sharing a corner.
|
||||
*/
|
||||
private fun seaPorts(count: Int): List<Int> {
|
||||
if (count <= 0) return emptyList()
|
||||
val shore = (0 until EDGE_COUNT).filter { e ->
|
||||
EDGE_HEXES[e].size == 2 && EDGE_HEXES[e].any { land[it] } && EDGE_HEXES[e].any { !land[it] }
|
||||
}
|
||||
if (shore.isEmpty()) return emptyList()
|
||||
val landCentre = HEXES.indices.filter { land[it] }.let { hs ->
|
||||
val pts = hs.map { hexCenter(it) }
|
||||
(pts.sumOf { it.first } / pts.size.toDouble()) to (pts.sumOf { it.second } / pts.size.toDouble())
|
||||
}
|
||||
val ring = shore.sortedBy { e ->
|
||||
val m = midpoint(e)
|
||||
atan2(m.second - landCentre.second, m.first - landCentre.first)
|
||||
}
|
||||
val picked = mutableListOf<Int>()
|
||||
val used = mutableSetOf<Int>()
|
||||
val step = ring.size.toDouble() / count
|
||||
for (i in 0 until count) {
|
||||
val start = (i * step).toInt()
|
||||
// The first edge at or after the ideal spot whose corners are free.
|
||||
val e = (0 until ring.size).map { ring[(start + it) % ring.size] }.firstOrNull { cand ->
|
||||
val (a, b) = EDGE_VERTICES[cand]
|
||||
a !in used && b !in used && cand !in picked
|
||||
} ?: continue
|
||||
picked += e
|
||||
val (a, b) = EDGE_VERTICES[e]
|
||||
used += a
|
||||
used += b
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
/** The other endpoint of [edge], given one [vertex] on it. */
|
||||
fun otherEnd(edge: Int, vertex: Int): Int {
|
||||
val (a, b) = EDGE_VERTICES[edge]
|
||||
return if (vertex == a) b else a
|
||||
}
|
||||
|
||||
/** Lattice position of hex h's centre. */
|
||||
fun hexCenter(h: Int): Pair<Int, Int> {
|
||||
val hex = HEXES[h]
|
||||
return (2 * hex.q + hex.r) to (3 * hex.r)
|
||||
}
|
||||
|
||||
/** Whether two hexes share an edge. */
|
||||
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
|
||||
|
||||
/** Lattice extent of the whole frame: min x, max x, min y, max y. */
|
||||
val lattice: IntArray by lazy {
|
||||
intArrayOf(
|
||||
VERTEX_POS.minOf { it.first }, VERTEX_POS.maxOf { it.first },
|
||||
VERTEX_POS.minOf { it.second }, VERTEX_POS.maxOf { it.second },
|
||||
)
|
||||
}
|
||||
|
||||
private fun midpoint(edge: Int): Pair<Double, Double> {
|
||||
val (a, b) = EDGE_VERTICES[edge]
|
||||
val pa = VERTEX_POS[a]
|
||||
val pb = VERTEX_POS[b]
|
||||
return ((pa.first + pb.first) / 2.0) to ((pa.second + pb.second) / 2.0)
|
||||
}
|
||||
|
||||
private fun angleOf(point: Pair<Double, Double>): Double =
|
||||
atan2(point.second, point.first)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
|
||||
* corner 0 is the top corner, then clockwise.
|
||||
*/
|
||||
private val CORNER_OFFSETS = listOf(
|
||||
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
|
||||
)
|
||||
|
||||
/** Unit steps to a hex's six neighbours, matching the corner order. */
|
||||
private val NEIGHBOR_STEPS = listOf(
|
||||
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
|
||||
)
|
||||
|
||||
/** Hex-grid distance between two axial positions. */
|
||||
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
|
||||
val dq = q1 - q2
|
||||
val dr = r1 - r2
|
||||
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
|
||||
}
|
||||
|
||||
/**
|
||||
* How often a number is expected to come up, in thirty-sixths. A 6 or an
|
||||
* 8 comes up five times in thirty-six, a 2 or a 12 once.
|
||||
*/
|
||||
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard island: the 19 hexes, the 54 shared vertices, the 72 shared
|
||||
* edges, the coast and the nine harbour positions. Every table below is the
|
||||
* standard frame's [Topology]; the rules read the topology of the *state* they
|
||||
* are working on ([CatanState.topo]), which is this one in every game but Seafarers.
|
||||
*/
|
||||
object CatanBoard {
|
||||
|
||||
/** The standard island's geometry. */
|
||||
val base: Topology = Topology(
|
||||
HEXES = buildList {
|
||||
for (r in -2..2) {
|
||||
for (q in -2..2) {
|
||||
if (Topology.hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
|
||||
}
|
||||
}
|
||||
},
|
||||
standardFrame = true,
|
||||
)
|
||||
|
||||
val HEXES: List<HexCoord> get() = base.HEXES
|
||||
val HEX_COUNT: Int get() = base.HEX_COUNT
|
||||
val hexIndex: Map<HexCoord, Int> get() = base.hexIndex
|
||||
val VERTEX_POS: List<Pair<Int, Int>> get() = base.VERTEX_POS
|
||||
val VERTEX_COUNT: Int get() = base.VERTEX_COUNT
|
||||
val HEX_CORNERS: List<List<Int>> get() = base.HEX_CORNERS
|
||||
val EDGE_VERTICES: List<Pair<Int, Int>> get() = base.EDGE_VERTICES
|
||||
val EDGE_COUNT: Int get() = base.EDGE_COUNT
|
||||
val VERTEX_EDGES: List<List<Int>> get() = base.VERTEX_EDGES
|
||||
val VERTEX_NEIGHBORS: List<List<Int>> get() = base.VERTEX_NEIGHBORS
|
||||
val VERTEX_HEXES: List<List<Int>> get() = base.VERTEX_HEXES
|
||||
val EDGE_HEXES: List<List<Int>> get() = base.EDGE_HEXES
|
||||
val EDGE_NEIGHBORS: List<List<Int>> get() = base.EDGE_NEIGHBORS
|
||||
val HEX_NEIGHBORS: List<List<Int>> get() = base.HEX_NEIGHBORS
|
||||
val COASTAL: List<Int> get() = base.COASTAL
|
||||
val PORT_POSITIONS: List<Int> get() = base.PORT_POSITIONS
|
||||
|
||||
fun otherEnd(edge: Int, vertex: Int): Int = base.otherEnd(edge, vertex)
|
||||
fun hexCenter(h: Int): Pair<Int, Int> = base.hexCenter(h)
|
||||
fun hexesAdjacent(a: Int, b: Int): Boolean = base.hexesAdjacent(a, b)
|
||||
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int = Topology.hexDistance(q1, r1, q2, r2)
|
||||
fun numberOdds(number: Int): Int = Topology.numberOdds(number)
|
||||
|
||||
/** The six board-corner hexes: pairwise non-adjacent by construction. */
|
||||
private val CORNER_HEXES = listOf(
|
||||
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
|
||||
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Island generation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The island layout for one game: terrain and number per hex, the robber
|
||||
* on the desert, and the nine harbours.
|
||||
*/
|
||||
@Serializable
|
||||
data class Generated(
|
||||
val terrains: List<Terrain>,
|
||||
/** 0 where there is no token (the desert). */
|
||||
val numbers: List<Int>,
|
||||
val robber: Int,
|
||||
val ports: List<Port>,
|
||||
)
|
||||
|
||||
/** The terrain tile mix: four forests, four pastures, four fields, three hills, three mountains, one desert. */
|
||||
private val TERRAIN_TILES: List<Terrain> = listOf(
|
||||
Terrain.FOREST, Terrain.FOREST, Terrain.FOREST, Terrain.FOREST,
|
||||
Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE,
|
||||
Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS,
|
||||
Terrain.HILLS, Terrain.HILLS, Terrain.HILLS,
|
||||
Terrain.MOUNTAINS, Terrain.MOUNTAINS, Terrain.MOUNTAINS,
|
||||
Terrain.DESERT,
|
||||
)
|
||||
|
||||
/** The 18 number tokens, the two hardest numbers withheld: 7 never appears. */
|
||||
private val RED_TOKENS = listOf(6, 6, 8, 8)
|
||||
private val OTHER_TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
|
||||
|
||||
/** Shuffle attempts before falling back to the corner placement for 6s and 8s. */
|
||||
private const val RED_ATTEMPTS = 60
|
||||
|
||||
/**
|
||||
* Generates the island from [seed].
|
||||
*
|
||||
* Terrain is shuffled over the hexes, exactly as shuffling the land
|
||||
* tiles face down does. Number tokens are shuffled over the land hexes
|
||||
* with one repair rule: no two of the red tokens (6 and 8) may sit on
|
||||
* adjacent hexes, the balance convention the published game asks for.
|
||||
* The robber starts on the desert. Harbour *positions* are fixed (the
|
||||
* physical frame is not shuffled); the nine harbour *types* — five
|
||||
* 2:1 resource ports and four 3:1 ports — are shuffled onto them.
|
||||
*
|
||||
* The 6/8 separation is guaranteed, not merely probable: if no shuffled
|
||||
* placement separates them (about one shuffle in five does), the red
|
||||
* tokens go to four of the six board-corner hexes, which are pairwise
|
||||
* non-adjacent, and at most one corner can be desert.
|
||||
*/
|
||||
fun generate(seed: Long): Generated {
|
||||
val random = Random(seed)
|
||||
val terrains = TERRAIN_TILES.shuffled(random)
|
||||
val desert = terrains.indexOf(Terrain.DESERT)
|
||||
val land = terrains.indices.filter { it != desert }
|
||||
|
||||
var redHexes: List<Int>? = null
|
||||
var attempt = 0
|
||||
while (redHexes == null && attempt < RED_ATTEMPTS) {
|
||||
val candidate = land.shuffled(random).take(4)
|
||||
val separated = candidate.withIndex().all { (i, a) ->
|
||||
candidate.drop(i + 1).none { hexesAdjacent(a, it) }
|
||||
}
|
||||
if (separated) redHexes = candidate
|
||||
attempt++
|
||||
}
|
||||
val reds = redHexes ?: CORNER_HEXES
|
||||
.mapNotNull { hexIndex[it] }
|
||||
.filter { it != desert }
|
||||
.shuffled(random)
|
||||
.take(4)
|
||||
|
||||
val numbers = IntArray(HEX_COUNT)
|
||||
RED_TOKENS.forEachIndexed { i, token -> numbers[reds[i]] = token }
|
||||
val rest = land.filter { it !in reds }.shuffled(random)
|
||||
OTHER_TOKENS.forEachIndexed { i, token -> numbers[rest[i]] = token }
|
||||
|
||||
val portTypes = (
|
||||
listOf(
|
||||
Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE,
|
||||
) + List(4) { null }
|
||||
).shuffled(random)
|
||||
val ports = PORT_POSITIONS.mapIndexed { i, edge ->
|
||||
val resource = portTypes[i]
|
||||
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
|
||||
}
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, ports)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The seat's profile shapes it as in [CatanAi]: easy plays on a whim and
|
||||
* discards badly, hard sizes its knights to the barbarian threat, values cards
|
||||
* by what its improvements still need and keeps its robber off its own hexes,
|
||||
* and the style leans it toward knights and expansion, toward spending down
|
||||
* a big hand, or toward the leader.
|
||||
*/
|
||||
internal class CatanCkAi(
|
||||
private val ai: CatanAi,
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
) {
|
||||
|
||||
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
val profile = profiles.of(seat)
|
||||
return choose(state, seat, legal, profile)
|
||||
}
|
||||
|
||||
private fun choose(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove = when (state.phase) {
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>(), profile)
|
||||
|
||||
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
|
||||
|
||||
CatanPhase.CHORE -> chooseChore(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>(), profile)
|
||||
|
||||
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
|
||||
|
||||
CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.DISCARDING, CatanPhase.GOLD, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||
?: error("Nothing to choose in phase ${state.phase}")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Chores
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
|
||||
val ck = state.ck!!
|
||||
return when (val chore = ck.chores.first()) {
|
||||
is DiscardChore -> {
|
||||
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
|
||||
val hand = CatanCk.goodsList(state, seat)
|
||||
// Easy now and then throws away whatever comes to hand.
|
||||
val order = if (profile.slips()) {
|
||||
hand.shuffled(profile.random)
|
||||
} else {
|
||||
hand.sortedBy { worth(state, seat, it, profile) }
|
||||
}
|
||||
DiscardGoods(order.take(n))
|
||||
}
|
||||
|
||||
is GiveChore -> {
|
||||
val pool = CatanCk.goodsList(state, seat)
|
||||
.filter { !chore.commoditiesOnly || it.isCommodity }
|
||||
.sortedBy { worth(state, seat, it, profile) }
|
||||
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>, profile: AiProfile): MoveRobber {
|
||||
require(moves.isNotEmpty()) { "No legal robber move" }
|
||||
val points = CatanRules.publicVictoryPoints(state)
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val spite = profile.style.spite
|
||||
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
|
||||
val victimWeight = 0.3 + 0.1 * profile.style.boldness
|
||||
fun score(move: MoveRobber): Double {
|
||||
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 * (if (hard) 1.5 else 1.0)
|
||||
} else {
|
||||
worth * odds + if (building.owner == leader) spite * worth * odds else 0.0
|
||||
}
|
||||
}
|
||||
if (move.victim != null) {
|
||||
val cards = CatanCk.goodsCount(state, move.victim)
|
||||
if (!hard || cards > 0) {
|
||||
score += 0.15 * cards + victimWeight * points[move.victim]
|
||||
if (move.victim == leader) score += 0.4 * spite
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
return if (profile.level == AiLevel.EASY) {
|
||||
profile.pick(moves.sortedByDescending { score(it) })
|
||||
} else {
|
||||
moves.maxByOrNull { score(it) } ?: moves.first()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The main phase
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
|
||||
val ck = state.ck!!
|
||||
val easy = profile.level == AiLevel.EASY
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val boldness = profile.style.boldness
|
||||
// A cautious seat starts getting ready a step earlier.
|
||||
val soon = ck.barbarians <= (if (boldness < 0) 4 else 3)
|
||||
val endTurn = legal.lastOrNull { it is EndTurn }
|
||||
|
||||
// Easy loses the thread now and then and just passes.
|
||||
if (easy && endTurn != null && profile.slips() && profile.random.nextBoolean()) return endTurn
|
||||
|
||||
// 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 }
|
||||
|
||||
val cities = legal.filterIsInstance<BuildCity>()
|
||||
val settlements = legal.filterIsInstance<BuildSettlement>()
|
||||
if (hard) {
|
||||
val city = cities.maxByOrNull { production(state, it.vertex) }
|
||||
val settlement = settlements.maxByOrNull { ai.hardSpot(state, seat, it.vertex) }
|
||||
val cityScore = if (city != null) production(state, city.vertex) + 0.15 else Double.NEGATIVE_INFINITY
|
||||
val settleScore = if (settlement != null) {
|
||||
ai.hardSpot(state, seat, settlement.vertex) + 0.3 + 0.1 * boldness
|
||||
} else {
|
||||
Double.NEGATIVE_INFINITY
|
||||
}
|
||||
if (city != null && cityScore >= settleScore) return city
|
||||
if (settlement != null) return settlement
|
||||
} else if (easy) {
|
||||
if (cities.isNotEmpty()) return profile.pick(cities.sortedByDescending { production(state, it.vertex) })
|
||||
if (settlements.isNotEmpty()) {
|
||||
return profile.pick(settlements.sortedByDescending { ai.spotValue(state, it.vertex) })
|
||||
}
|
||||
} else {
|
||||
cities.maxByOrNull { production(state, it.vertex) }?.let { return it }
|
||||
|
||||
settlements.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.
|
||||
if (!(easy && profile.slips())) defence(state, seat, legal, soon, profile)?.let { return it }
|
||||
|
||||
if (!(easy && profile.slips())) progress(state, seat, legal)?.let { return it }
|
||||
|
||||
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
|
||||
if (CatanCk.goodsCount(state, seat) >= (if (boldness < 0) 5 else 7)) return it
|
||||
}
|
||||
|
||||
legal.filterIsInstance<BuildRoad>()
|
||||
.filter { ai.roadValue(state, it) > 0.1 - 0.04 * boldness }
|
||||
.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 }
|
||||
|
||||
// Stay under the seven: a cautious (or careful) seat sells down a big hand.
|
||||
if ((hard || boldness < 0) && CatanCk.goodsCount(state, seat) > 7) {
|
||||
legal.filterIsInstance<GoodsBankTrade>()
|
||||
.maxByOrNull { t ->
|
||||
held(state, seat, t.give) * 10.0 - CatanCk.bankRatio(state, seat, t.give) * 3.0 +
|
||||
worth(state, seat, t.get, profile)
|
||||
}
|
||||
?.let { return it }
|
||||
}
|
||||
|
||||
return 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,
|
||||
profile: AiProfile,
|
||||
): CatanMove? {
|
||||
val mine = CatanCk.knightsOf(state, seat)
|
||||
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
|
||||
|
||||
// Hard sizes the defence to the threat: the barbarians bring one strength
|
||||
// per city on the board, and once the table's active knights outweigh them
|
||||
// and this seat pulls its weight, the rest of its goods go elsewhere.
|
||||
if (profile.level == AiLevel.HARD && soon && CatanCk.strength(state, seat) >= 1) {
|
||||
val threat = state.buildings.count { it?.type == BuildingType.CITY }
|
||||
val table = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
|
||||
if (table > threat) return null
|
||||
}
|
||||
|
||||
if (soon) {
|
||||
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
|
||||
}
|
||||
val want = if (soon) 4 else if (profile.style.boldness > 0.5) 3 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>, profile: AiProfile): CatanMove {
|
||||
val roads = legal.filterIsInstance<BuildRoad>().filter { ai.roadValue(state, it) > 0.05 }
|
||||
val road = when {
|
||||
roads.isEmpty() -> null
|
||||
profile.level == AiLevel.EASY -> profile.pick(roads.sortedByDescending { ai.roadValue(state, it) })
|
||||
else -> roads.maxByOrNull { ai.roadValue(state, it) }
|
||||
}
|
||||
return road
|
||||
?: legal.firstOrNull { it is EndFreeRoads }
|
||||
?: legal.first()
|
||||
}
|
||||
|
||||
/** Extra the incoming cards must be worth: easy is not fussy, hard and spiteful seats are tougher on the leader. */
|
||||
private fun tradeMargin(state: CatanState, seat: Int, other: Int, profile: AiProfile): Double {
|
||||
var margin = 0.0
|
||||
if (profile.level == AiLevel.EASY) margin -= 0.6
|
||||
val points = CatanRules.publicVictoryPoints(state)
|
||||
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
|
||||
if (leader != null && other == leader) {
|
||||
if (profile.level == AiLevel.HARD) margin += 0.4
|
||||
margin += 0.5 * profile.style.spite
|
||||
}
|
||||
return margin
|
||||
}
|
||||
|
||||
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): 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, profile) }
|
||||
val outgoing = offer.get.sumOf { worth(state, seat, it, profile) }
|
||||
return if (incoming > outgoing + tradeMargin(state, seat, offer.by, profile)) 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 + tradeMargin(state, seat, plain.by, profile)) 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, profile: AiProfile = AiProfile.DEFAULT): Double {
|
||||
val base = good.resource?.let { ai.want(it) } ?: when (good) {
|
||||
Good.PAPER -> 0.95
|
||||
Good.COIN -> 0.9
|
||||
else -> 0.85
|
||||
}
|
||||
if (profile.level != AiLevel.HARD) return base
|
||||
// Hard keeps the commodity an improvement is about to need.
|
||||
var bonus = if (good.resource == Resource.ORE || good.resource == Resource.GRAIN) 0.1 else 0.0
|
||||
for (track in Track.entries) {
|
||||
if (Good.of(track.commodity) != good) continue
|
||||
val level = CatanCk.improvement(state, seat, track)
|
||||
if (level < IMPROVEMENT_MAX && held(state, seat, good) <= level + 1) bonus += 0.5
|
||||
}
|
||||
return base + bonus
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -0,0 +1,500 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
|
||||
const val NO_PLAYER = -1
|
||||
|
||||
/** Road segments required to hold Longest Road. */
|
||||
const val LONGEST_ROAD_MIN = 5
|
||||
|
||||
/** Knights required to take Largest Army. */
|
||||
const val LARGEST_ARMY_MIN = 3
|
||||
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* The five resources the island produces.
|
||||
*
|
||||
* Catan names two of them after the terrain rather than the commodity
|
||||
* (wood becomes lumber, sheep become wool); those names travel with the
|
||||
* trademark, so the engine speaks in resources only.
|
||||
*/
|
||||
@Serializable
|
||||
enum class Resource { LUMBER, BRICK, WOOL, GRAIN, ORE }
|
||||
|
||||
/** One terrain type per hex; the desert produces nothing. */
|
||||
@Serializable
|
||||
enum class Terrain(val produces: Resource?) {
|
||||
FOREST(Resource.LUMBER),
|
||||
PASTURE(Resource.WOOL),
|
||||
FIELDS(Resource.GRAIN),
|
||||
HILLS(Resource.BRICK),
|
||||
MOUNTAINS(Resource.ORE),
|
||||
DESERT(null),
|
||||
|
||||
/** Seafarers: open water. Ships sail on it; nothing is built on it and nothing produces. */
|
||||
SEA(null),
|
||||
|
||||
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
|
||||
GOLD(null),
|
||||
}
|
||||
|
||||
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||
@Serializable
|
||||
data class HexCoord(val q: Int, val r: Int)
|
||||
|
||||
@Serializable
|
||||
enum class BuildingType { SETTLEMENT, CITY }
|
||||
|
||||
/** What stands on one vertex: a settlement or city owned by one seat. */
|
||||
@Serializable
|
||||
data class CatanBuilding(val type: BuildingType, val owner: Int)
|
||||
|
||||
/**
|
||||
* One harbour. A port sits on a coastal edge: a player who owns a
|
||||
* settlement or city on *either* endpoint vertex of [edge] may trade at
|
||||
* [ratio] (2 or 3) instead of the bank's 4:1. A resource port trades at 2:1
|
||||
* for its own resource only; [resource] is null for a generic 3:1 harbour.
|
||||
*/
|
||||
@Serializable
|
||||
data class Port(val edge: Int, val resource: Resource?, val ratio: Int)
|
||||
|
||||
@Serializable
|
||||
enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTORY_POINT }
|
||||
|
||||
/**
|
||||
* One development card. [id] is stable for the life of the game (the deck
|
||||
* is built once from the seed), so moves can name a card by id even after
|
||||
* it moves from the deck into a hand. [boughtTurn] records when it was
|
||||
* drawn: a card cannot be played on the turn it was bought.
|
||||
*/
|
||||
@Serializable
|
||||
data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0)
|
||||
|
||||
/**
|
||||
* A counter-proposal to a [TradeOffer]: seat [by] would [give] these cards to
|
||||
* the offerer if the offerer would hand over [get].
|
||||
*/
|
||||
@Serializable
|
||||
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] and [CatanPhase.TRADE_COUNTERS].
|
||||
*/
|
||||
@Serializable
|
||||
enum class CatanPhase {
|
||||
/** Snake order forward: settlement + road each, seats 0..n-1. */
|
||||
SETUP_FIRST,
|
||||
|
||||
/** Snake order back: settlement + road each, seats n-1..0, plus income. */
|
||||
SETUP_SECOND,
|
||||
|
||||
/** The player whose turn it is must roll the dice. */
|
||||
AWAIT_ROLL,
|
||||
|
||||
/** Seafarers: the first seat in [SeaState.gold] is choosing the resources its gold fields paid. */
|
||||
GOLD,
|
||||
|
||||
/** Players holding more than 7 resource cards discard half. */
|
||||
DISCARDING,
|
||||
|
||||
/** The roller must move the robber and steal. */
|
||||
ROBBER,
|
||||
|
||||
/** Build, trade, buy and play development cards, or end the turn. */
|
||||
PLAYING,
|
||||
|
||||
/** A played Road Building card: up to two roads remain to be placed. */
|
||||
FREE_ROADS,
|
||||
|
||||
/** The [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,
|
||||
}
|
||||
|
||||
/** An expansion a table can switch on. The base game is what is left with none selected. */
|
||||
@Serializable
|
||||
enum class CatanExpansion {
|
||||
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
|
||||
CITIES_AND_KNIGHTS,
|
||||
|
||||
/** Seafarers: ships, a pirate, gold fields and islands to settle (see `docs/RULES-catan-seafarers.md`). */
|
||||
SEAFARERS,
|
||||
}
|
||||
|
||||
/**
|
||||
* What a table plays: the expansions switched on, and the Seafarers map when that one is.
|
||||
*
|
||||
* Saves written before expansions became toggles carry `"ruleSet"` instead of `"expansions"`;
|
||||
* [CatanOptionsSerializer] reads either.
|
||||
*/
|
||||
@Serializable(with = CatanOptionsSerializer::class)
|
||||
data class CatanOptions(
|
||||
val playerCount: Int = 3,
|
||||
val pointsToWin: Int = 10,
|
||||
val expansions: Set<CatanExpansion> = emptySet(),
|
||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
|
||||
// Lifted once Cities & Knights runs on a Seafarers map.
|
||||
require(expansions.size <= 1) { "Combining expansions is not supported yet" }
|
||||
}
|
||||
|
||||
val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions
|
||||
val citiesAndKnights: Boolean get() = CatanExpansion.CITIES_AND_KNIGHTS in expansions
|
||||
}
|
||||
|
||||
/** The wire shape of [CatanOptions]. */
|
||||
@Serializable
|
||||
private class CatanOptionsWire(
|
||||
val playerCount: Int = 3,
|
||||
val pointsToWin: Int = 10,
|
||||
val expansions: Set<CatanExpansion> = emptySet(),
|
||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||
)
|
||||
|
||||
/** Reads the legacy single `ruleSet` field as an expansion set. */
|
||||
object CatanOptionsSerializer : KSerializer<CatanOptions> {
|
||||
override val descriptor: SerialDescriptor = CatanOptionsWire.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
|
||||
CatanOptionsWire.serializer(),
|
||||
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario),
|
||||
)
|
||||
|
||||
override fun deserialize(decoder: Decoder): CatanOptions {
|
||||
val input = decoder as JsonDecoder
|
||||
var obj = input.decodeJsonElement().jsonObject
|
||||
val legacy = (obj["ruleSet"] as? JsonPrimitive)?.content
|
||||
if (legacy != null) {
|
||||
val expansions = if (legacy == "BASE") emptyList() else listOf(JsonPrimitive(legacy))
|
||||
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
|
||||
}
|
||||
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
|
||||
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole game as one immutable value.
|
||||
*
|
||||
* Public information (terrain, numbers, ports, buildings, roads, resource
|
||||
* counts, dev-card counts, knights played) is stored directly. Hidden
|
||||
* information — the deck's order and every hand's development cards — is
|
||||
* here in full because the host owns the truth; [CatanRules.viewFor] is
|
||||
* what keeps it off the wire.
|
||||
*/
|
||||
@Serializable
|
||||
data class CatanState(
|
||||
val options: CatanOptions,
|
||||
val seed: Long,
|
||||
val phase: CatanPhase,
|
||||
/**
|
||||
* The seat whose turn it is — not always the seat expected to move:
|
||||
* during [CatanPhase.DISCARDING] and [CatanPhase.TRADE_PENDING] another
|
||||
* seat acts on the turn owner's behalf.
|
||||
*/
|
||||
val turn: Int,
|
||||
/** How many setup placements are done, 0 until 2 * playerCount. */
|
||||
val setupIndex: Int,
|
||||
/** The seat that places first and takes the first turn; drawn from the table seed. */
|
||||
val firstSeat: Int = 0,
|
||||
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
|
||||
val turnNumber: Int,
|
||||
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
|
||||
val terrains: List<Terrain>,
|
||||
/** Number token per hex, 0 where there is none (the desert). */
|
||||
val numbers: List<Int>,
|
||||
/** Hex the robber currently occupies. */
|
||||
val robber: Int,
|
||||
val ports: List<Port>,
|
||||
/** What stands on each vertex, or null. */
|
||||
val buildings: List<CatanBuilding?>,
|
||||
/** Owning seat per edge, or [NO_PLAYER]. */
|
||||
val roads: List<Int>,
|
||||
/** Resource cards per seat. Card counts are public in Catan. */
|
||||
val resources: List<Map<Resource, Int>>,
|
||||
/** The bank's development cards, top first. Order is hidden. */
|
||||
val devDeck: List<DevCard>,
|
||||
/** Development cards per seat. Hidden from everyone but the holder. */
|
||||
val devHands: List<List<DevCard>>,
|
||||
/** Public mirror of the dev-hand sizes, for views and scoreboards. */
|
||||
val devHandCounts: List<Int>,
|
||||
/** Knights played (not merely held) per seat. */
|
||||
val knightsPlayed: List<Int>,
|
||||
/** Whether the turn owner has already played a development card. */
|
||||
val devCardPlayedThisTurn: Boolean,
|
||||
val pendingTrade: TradeOffer?,
|
||||
/** Roads still owed on a played Road Building card. */
|
||||
val pendingFreeRoads: Int,
|
||||
/** Seat due to discard during [CatanPhase.DISCARDING]. */
|
||||
val discardSeat: Int?,
|
||||
val longestRoadOwner: Int?,
|
||||
val largestArmyOwner: Int?,
|
||||
/** The dice as last rolled, for the table display. */
|
||||
val lastDice: List<Int>,
|
||||
/**
|
||||
* How many dice rolls and steals have happened so far. Random events are
|
||||
* derived from the seed and this counter, so the same game replays
|
||||
* identically without storing a random generator in the state.
|
||||
*/
|
||||
val rngStep: Int,
|
||||
val winner: Int?,
|
||||
val log: List<String>,
|
||||
/** Everything Cities & Knights adds; null in the base game. */
|
||||
val ck: CkState? = null,
|
||||
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
|
||||
val sea: SeaState? = null,
|
||||
) {
|
||||
val playerCount: Int get() = options.playerCount
|
||||
|
||||
/** The geometry this game is played on: the standard island, or a Seafarers map. */
|
||||
val topo: Topology get() = sea?.let { SeaBoards.topology(it.scenario) } ?: CatanBoard.base
|
||||
|
||||
/** Whether this table plays Seafarers. */
|
||||
val isSea: Boolean get() = sea != null
|
||||
|
||||
/** 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()
|
||||
|
||||
/** [seat]'s resource cards as a flat list, one entry per card. */
|
||||
fun handOf(seat: Int): List<Resource> =
|
||||
Resource.entries.flatMap { res -> List(resources[seat][res] ?: 0) { res } }
|
||||
|
||||
/** Whether [seat] holds every card in [cards], counting duplicates. */
|
||||
fun holdsAll(seat: Int, cards: List<Resource>): Boolean {
|
||||
val need = cards.groupingBy { it }.eachCount()
|
||||
return need.all { (res, n) -> (resources[seat][res] ?: 0) >= n }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface CatanMove
|
||||
|
||||
/** Setup placement: a settlement on [vertex] plus a road on [edge]. */
|
||||
@Serializable
|
||||
@SerialName("setup")
|
||||
data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
|
||||
|
||||
/**
|
||||
* Roll the dice.
|
||||
*
|
||||
* With [forced] null the roll is derived from the game seed, which is what
|
||||
* the AI, the UI and the network always send. [forced] exists so tests and
|
||||
* offline reviews can stage a specific number; it is not part of the
|
||||
* enumerated move list, so the network driver's "is this move in the legal
|
||||
* list" check rejects any forced roll a client tries to sneak through —
|
||||
* only code holding the state directly (the host's own tests) can use it.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("roll")
|
||||
data class RollDice(
|
||||
val forced: List<Int>? = null,
|
||||
/** Test-only, like [forced]: the event die of a Cities & Knights roll. */
|
||||
val event: EventFace? = null,
|
||||
) : CatanMove
|
||||
|
||||
/** Discard exactly these cards. */
|
||||
@Serializable
|
||||
@SerialName("discard")
|
||||
data class Discard(val cards: List<Resource>) : CatanMove
|
||||
|
||||
/** Move the robber to [hex] and steal from [victim], if anyone can be robbed. */
|
||||
@Serializable
|
||||
@SerialName("robber")
|
||||
data class MoveRobber(val hex: HexCoord, val victim: Int? = null) : CatanMove
|
||||
|
||||
/** Build a road on [edge] — paid normally, or free during FREE_ROADS. */
|
||||
@Serializable
|
||||
@SerialName("road")
|
||||
data class BuildRoad(val edge: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("settlement")
|
||||
data class BuildSettlement(val vertex: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("city")
|
||||
data class BuildCity(val vertex: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("buyCard")
|
||||
data object BuyDevCard : CatanMove
|
||||
|
||||
/**
|
||||
* Play a knight. The card is named by [card] id; the robber move follows as
|
||||
* its own move once the phase has advanced.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("knight")
|
||||
data class PlayKnight(val card: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("roadBuilding")
|
||||
data class PlayRoadBuilding(val card: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("monopoly")
|
||||
data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
|
||||
|
||||
/** Take [take] (up to two cards) from the bank. */
|
||||
@Serializable
|
||||
@SerialName("plenty")
|
||||
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
|
||||
|
||||
/**
|
||||
* Offer a domestic trade to [to] (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
|
||||
* it is validated structurally by [CatanRules.isLegalMove] instead of
|
||||
* appearing in [CatanRules.legalMoves].
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("offer")
|
||||
data class OfferTrade(val to: Int?, val give: List<Resource>, val get: List<Resource>) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("accept")
|
||||
data object AcceptTrade : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("decline")
|
||||
data object DeclineTrade : CatanMove
|
||||
|
||||
/**
|
||||
* 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")
|
||||
data class MaritimeTrade(val give: Resource, val get: Resource) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("endTurn")
|
||||
data object EndTurn : CatanMove
|
||||
|
||||
/** Forfeit any remaining free roads from a played Road Building card. */
|
||||
@Serializable
|
||||
@SerialName("endFreeRoads")
|
||||
data object EndFreeRoads : CatanMove
|
||||
|
||||
/**
|
||||
* Seafarers state: the ships, the pirate and the islands, alongside the base game's
|
||||
* roads, robber and buildings (which keep working exactly as before).
|
||||
*/
|
||||
@Serializable
|
||||
data class SeaState(
|
||||
val scenario: SeaScenario,
|
||||
/** Owning seat per edge, or [NO_PLAYER]: a ship on that edge. Roads stay in [CatanState.roads]. */
|
||||
val ships: List<Int>,
|
||||
/** The hex the pirate occupies: always a sea hex. */
|
||||
val pirate: Int,
|
||||
/** Ships built this turn (they may not sail until the next) and whether a ship has sailed this turn. */
|
||||
val builtThisTurn: List<Int> = emptyList(),
|
||||
val shipMoved: Boolean = false,
|
||||
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
|
||||
val islandBonus: List<Int>,
|
||||
/** The island that counts as home: the largest, and the pirate-free start of the original map. */
|
||||
val home: Int,
|
||||
/** The islands the starting settlements may go on. Empty in a save from before there were several: just [home]. */
|
||||
val starts: List<Int> = emptyList(),
|
||||
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
|
||||
val gold: List<GoldOwed> = emptyList(),
|
||||
)
|
||||
|
||||
/** Whether [island] is one the game starts on, and so never earns the new-island bonus. */
|
||||
fun SeaState.isStart(island: Int): Boolean = island == home || island in starts
|
||||
|
||||
@Serializable
|
||||
data class GoldOwed(val seat: Int, val count: Int)
|
||||
|
||||
/** Build a ship on [edge]: 1 lumber and 1 wool, on a sea edge joined to your fleet or a harbour town. */
|
||||
@Serializable
|
||||
@SerialName("ship")
|
||||
data class BuildShip(val edge: Int) : CatanMove
|
||||
|
||||
/** Sail the ship on [from] to [to]: once a turn, never the turn it was built, only from the open end of a route. */
|
||||
@Serializable
|
||||
@SerialName("moveShip")
|
||||
data class MoveShip(val from: Int, val to: Int) : CatanMove
|
||||
|
||||
/** Move the pirate to the sea hex [hex] and rob [victim], a seat with a ship beside it, if there is one. */
|
||||
@Serializable
|
||||
@SerialName("pirate")
|
||||
data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
|
||||
|
||||
/** Choose the [cards] a gold field paid. */
|
||||
@Serializable
|
||||
@SerialName("gold")
|
||||
data class PickGold(val cards: List<Resource>) : CatanMove
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.random.Random
|
||||
|
||||
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
|
||||
@Serializable
|
||||
enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart = SeaStart.HOME_ONLY) {
|
||||
NEW_SHORES(
|
||||
"Heading for New Shores",
|
||||
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
|
||||
),
|
||||
FOUR_ISLANDS(
|
||||
"Four Islands",
|
||||
"Four equal islands, one start each, and two gold islets in the middle. Settle your own island, then sail.",
|
||||
SeaStart.MAIN_ISLANDS,
|
||||
),
|
||||
THROUGH_THE_DESERT(
|
||||
"Through the Desert",
|
||||
"One island cut by a broad desert that yields nothing. Reach the far side by road, or sail round to the islets.",
|
||||
),
|
||||
}
|
||||
|
||||
/** Where the starting settlements may go. */
|
||||
enum class SeaStart {
|
||||
/** The largest island only. */
|
||||
HOME_ONLY,
|
||||
|
||||
/** Any island big enough to live on (four hexes or more); the small ones are prizes to sail to. */
|
||||
MAIN_ISLANDS,
|
||||
}
|
||||
|
||||
/**
|
||||
* The Seafarers maps as geometry plus a generator for one game's tiles.
|
||||
*
|
||||
* A map is drawn below as text: `.` is sea, `#` a land hex that gets a random resource,
|
||||
* `$` a gold field, `D` the desert. Odd rows are shifted half a hex to the right (the usual
|
||||
* "odd-r" offset layout). The outermost ring is always sea, so every coast is closed.
|
||||
*/
|
||||
object SeaBoards {
|
||||
|
||||
private val NEW_SHORES_MAP = listOf(
|
||||
"..........",
|
||||
"...##.....",
|
||||
"..####.$#.",
|
||||
".#####..#.",
|
||||
".##D###...",
|
||||
"..####..#.",
|
||||
".......##.",
|
||||
".$#.......",
|
||||
"..........",
|
||||
)
|
||||
|
||||
private val FOUR_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
"..###..###..",
|
||||
"..###..###..",
|
||||
"............",
|
||||
"..$.....$...",
|
||||
"............",
|
||||
"..###..###..",
|
||||
"..###..###..",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val THROUGH_THE_DESERT_MAP = listOf(
|
||||
"............",
|
||||
".###DD###...",
|
||||
".###DDD###..",
|
||||
"..###DD###..",
|
||||
"............",
|
||||
"..$.....$...",
|
||||
"............",
|
||||
"...##...#...",
|
||||
"............",
|
||||
)
|
||||
|
||||
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
|
||||
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
|
||||
SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP
|
||||
SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP
|
||||
}
|
||||
|
||||
private val topologies = HashMap<SeaScenario, Topology>()
|
||||
|
||||
/** The frame of [scenario]: every hex, with its land mask. Built once and shared. */
|
||||
fun topology(scenario: SeaScenario): Topology = synchronized(topologies) {
|
||||
topologies.getOrPut(scenario) { build(scenario) }
|
||||
}
|
||||
|
||||
/** Hex (row, col) of the text map as an axial coordinate (odd-r offset). */
|
||||
private fun coord(row: Int, col: Int): HexCoord = HexCoord(col - (row - (row and 1)) / 2, row)
|
||||
|
||||
private fun build(scenario: SeaScenario): Topology {
|
||||
val map = rows(scenario)
|
||||
val hexes = mutableListOf<HexCoord>()
|
||||
val land = mutableListOf<Boolean>()
|
||||
map.forEachIndexed { row, text ->
|
||||
text.forEachIndexed { col, ch ->
|
||||
hexes += coord(row, col)
|
||||
land += ch != '.'
|
||||
}
|
||||
}
|
||||
return Topology(hexes, land, standardFrame = false, harbours = 9)
|
||||
}
|
||||
|
||||
/** Terrain kind the map asks of each hex: sea, gold, desert, or "pick a resource". */
|
||||
private fun kinds(scenario: SeaScenario): List<Char> = rows(scenario).flatMap { it.toList() }
|
||||
|
||||
/** One game's tiles on a Seafarers map. */
|
||||
@Serializable
|
||||
data class Generated(
|
||||
val terrains: List<Terrain>,
|
||||
val numbers: List<Int>,
|
||||
val robber: Int,
|
||||
val pirate: Int,
|
||||
val ports: List<Port>,
|
||||
val home: Int,
|
||||
/** The islands the starting settlements may go on; always includes [home]. */
|
||||
val starts: List<Int> = listOf(home),
|
||||
)
|
||||
|
||||
/** An island of this many land hexes or more can be started on. */
|
||||
private const val MAIN_ISLAND_HEXES = 4
|
||||
|
||||
private val RESOURCE_TILES = listOf(
|
||||
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
|
||||
)
|
||||
|
||||
/** Number tokens: the standard 18 plus ten more, so every land hex but the desert has one. */
|
||||
private val TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12) +
|
||||
listOf(3, 4, 5, 6, 8, 9, 10, 11, 5, 9)
|
||||
|
||||
fun generate(scenario: SeaScenario, seed: Long): Generated {
|
||||
val topo = topology(scenario)
|
||||
val kinds = kinds(scenario)
|
||||
val random = Random(seed)
|
||||
|
||||
// Resources fill the '#' hexes as evenly as the count allows, in shuffled order.
|
||||
val plain = kinds.indices.filter { kinds[it] == '#' }
|
||||
val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random)
|
||||
val terrains = MutableList(kinds.size) { Terrain.SEA }
|
||||
kinds.forEachIndexed { h, ch ->
|
||||
terrains[h] = when (ch) {
|
||||
'$' -> Terrain.GOLD
|
||||
'D' -> Terrain.DESERT
|
||||
'#' -> pool[plain.indexOf(h)]
|
||||
else -> Terrain.SEA
|
||||
}
|
||||
}
|
||||
|
||||
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT }
|
||||
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
|
||||
val tokens = TOKENS.take(numbered.size).shuffled(random)
|
||||
val numbers = IntArray(terrains.size)
|
||||
var placed = false
|
||||
var attempt = 0
|
||||
while (!placed && attempt < 200) {
|
||||
val order = numbered.shuffled(random)
|
||||
val assigned = IntArray(terrains.size)
|
||||
order.forEachIndexed { i, h -> assigned[h] = tokens[i] }
|
||||
val reds = numbered.filter { assigned[it] == 6 || assigned[it] == 8 }
|
||||
val clash = reds.any { a -> reds.any { b -> a < b && topo.hexesAdjacent(a, b) } }
|
||||
if (!clash || attempt == 199) {
|
||||
assigned.copyInto(numbers)
|
||||
placed = true
|
||||
}
|
||||
attempt++
|
||||
}
|
||||
|
||||
val portTypes = (
|
||||
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
|
||||
).shuffled(random)
|
||||
val ports = topo.PORT_POSITIONS.mapIndexed { i, edge ->
|
||||
val resource = portTypes[i % portTypes.size]
|
||||
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
|
||||
}
|
||||
|
||||
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
|
||||
?: numbered.first()
|
||||
val home = (0 until topo.islandCount).maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
|
||||
// The pirate starts in the open sea: the sea hex farthest from every shore.
|
||||
val sea = terrains.indices.filter { terrains[it] == Terrain.SEA }
|
||||
val pirate = sea.maxByOrNull { h ->
|
||||
val c = topo.HEXES[h]
|
||||
terrains.indices.filter { terrains[it] != Terrain.SEA }.minOfOrNull {
|
||||
Topology.hexDistance(c.q, c.r, topo.HEXES[it].q, topo.HEXES[it].r)
|
||||
} ?: 0
|
||||
} ?: sea.first()
|
||||
|
||||
val starts = when (scenario.start) {
|
||||
SeaStart.HOME_ONLY -> listOf(home)
|
||||
SeaStart.MAIN_ISLANDS -> (0 until topo.islandCount).filter { island ->
|
||||
topo.islandOf.count { it == island } >= MAIN_ISLAND_HEXES
|
||||
}.ifEmpty { listOf(home) }
|
||||
}
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
/**
|
||||
* The rules Seafarers adds to the base game, kept apart from [CatanRules] so the base
|
||||
* game's code reads as it always did. [CatanRules] calls in here only when a game has
|
||||
* [CatanState.sea]. See `docs/RULES-catan-seafarers.md`.
|
||||
*/
|
||||
internal object SeaRules {
|
||||
|
||||
const val MAX_SHIPS = 15
|
||||
|
||||
/** Points for being the first to settle an island other than the home island. */
|
||||
const val ISLAND_BONUS = 2
|
||||
|
||||
val SHIP_COST: Map<Resource, Int> = mapOf(Resource.LUMBER to 1, Resource.WOOL to 1)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ships
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun fleetOf(state: CatanState, seat: Int): Int = state.sea!!.ships.count { it == seat }
|
||||
|
||||
/**
|
||||
* Whether [seat]'s ships and towns reach [vertex], so a ship may start there. A town
|
||||
* of anyone else is a wall; [ignoring] is a ship that is being lifted off the board.
|
||||
*/
|
||||
private fun shipReaches(state: CatanState, seat: Int, vertex: Int, ignoring: Int): Boolean {
|
||||
val building = state.buildings[vertex]
|
||||
if (building != null) return building.owner == seat
|
||||
val sea = state.sea!!
|
||||
return state.topo.VERTEX_EDGES[vertex].any { it != ignoring && sea.ships[it] == seat }
|
||||
}
|
||||
|
||||
/** A sea edge a ship could sit on, joined to [seat]'s fleet or a town of theirs. */
|
||||
private fun shipSites(state: CatanState, seat: Int, ignoring: Int = -1): List<Int> {
|
||||
val topo = state.topo
|
||||
val sea = state.sea!!
|
||||
return (0 until topo.EDGE_COUNT).filter { e ->
|
||||
topo.edgeSea[e] &&
|
||||
state.roads[e] == NO_PLAYER &&
|
||||
(sea.ships[e] == NO_PLAYER || e == ignoring) &&
|
||||
sea.pirate !in topo.EDGE_HEXES[e] &&
|
||||
topo.EDGE_VERTICES[e].let { (a, b) ->
|
||||
shipReaches(state, seat, a, ignoring) || shipReaches(state, seat, b, ignoring)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildShipMoves(state: CatanState, seat: Int): List<BuildShip> =
|
||||
if (fleetOf(state, seat) >= MAX_SHIPS) emptyList() else shipSites(state, seat).map { BuildShip(it) }
|
||||
|
||||
/** Whether the ship on [edge] sits at the open end of a route: one end touches nothing else of [seat]'s. */
|
||||
private fun atOpenEnd(state: CatanState, seat: Int, edge: Int): Boolean {
|
||||
val topo = state.topo
|
||||
val sea = state.sea!!
|
||||
return topo.EDGE_VERTICES[edge].toList().any { v ->
|
||||
state.buildings[v] == null &&
|
||||
topo.VERTEX_EDGES[v].none { it != edge && (state.roads[it] == seat || sea.ships[it] == seat) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A ship may sail once a turn, never the turn it was built, only from the open end of a route. */
|
||||
fun moveShipMoves(state: CatanState, seat: Int): List<MoveShip> {
|
||||
val sea = state.sea!!
|
||||
if (sea.shipMoved) return emptyList()
|
||||
val moves = mutableListOf<MoveShip>()
|
||||
for (e in sea.ships.indices) {
|
||||
if (sea.ships[e] != seat || e in sea.builtThisTurn || !atOpenEnd(state, seat, e)) continue
|
||||
shipSites(state, seat, ignoring = e).filter { it != e }.forEach { moves += MoveShip(e, it) }
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pirate
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Seats with a ship beside [hex] that hold at least one card: whom the pirate can rob there. */
|
||||
private fun pirateVictims(state: CatanState, thief: Int, hex: Int): List<Int> {
|
||||
val topo = state.topo
|
||||
val sea = state.sea!!
|
||||
return (0 until topo.EDGE_COUNT).filter { hex in topo.EDGE_HEXES[it] }
|
||||
.map { sea.ships[it] }
|
||||
.filter { it != NO_PLAYER && it != thief }
|
||||
.distinct()
|
||||
.filter { state.resourceCount(it) > 0 }
|
||||
}
|
||||
|
||||
fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> {
|
||||
val topo = state.topo
|
||||
val sea = state.sea!!
|
||||
val moves = mutableListOf<MovePirate>()
|
||||
for (h in 0 until topo.HEX_COUNT) {
|
||||
if (topo.land[h] || h == sea.pirate) continue
|
||||
val victims = pirateVictims(state, seat, h)
|
||||
if (victims.isEmpty()) {
|
||||
moves += MovePirate(topo.HEXES[h])
|
||||
} else {
|
||||
victims.forEach { moves += MovePirate(topo.HEXES[h], it) }
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Gold
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Every way the seat at the front of the gold queue can choose its cards, supply permitting. */
|
||||
fun goldMoves(state: CatanState): List<PickGold> {
|
||||
val owed = state.sea!!.gold.firstOrNull() ?: return emptyList()
|
||||
val supply = Resource.entries.associateWith { CatanRules.bankSupply(state, it) }
|
||||
val take = minOf(owed.count, supply.values.sum())
|
||||
val picks = mutableListOf<PickGold>()
|
||||
fun walk(index: Int, left: Int, acc: MutableList<Resource>) {
|
||||
if (left == 0) {
|
||||
picks += PickGold(acc.toList())
|
||||
return
|
||||
}
|
||||
if (index == Resource.entries.size) return
|
||||
val resource = Resource.entries[index]
|
||||
for (n in 0..minOf(left, supply.getValue(resource))) {
|
||||
repeat(n) { acc += resource }
|
||||
walk(index + 1, left - n, acc)
|
||||
repeat(n) { acc.removeAt(acc.size - 1) }
|
||||
}
|
||||
}
|
||||
walk(0, take, mutableListOf())
|
||||
return picks
|
||||
}
|
||||
|
||||
/** Drops queue entries the bank can no longer pay, and leaves the gold phase once nobody is owed. */
|
||||
fun settleGold(state: CatanState): CatanState {
|
||||
val sea = state.sea!!
|
||||
val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) }
|
||||
val queue = if (supply > 0) sea.gold else emptyList()
|
||||
return if (queue.isEmpty()) {
|
||||
state.copy(sea = sea.copy(gold = emptyList()), phase = CatanPhase.PLAYING)
|
||||
} else {
|
||||
state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Islands and scoring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The bonus points [seat] holds for first landings. */
|
||||
fun bonusPoints(state: CatanState, seat: Int): Int =
|
||||
(state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS
|
||||
|
||||
/** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */
|
||||
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
|
||||
val sea = state.sea ?: return state
|
||||
val island = state.topo.vertexIsland[vertex]
|
||||
if (island < 0 || sea.isStart(island) || sea.islandBonus[island] != NO_PLAYER) return state
|
||||
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
|
||||
return state.copy(
|
||||
sea = sea.copy(islandBonus = bonus),
|
||||
log = state.log + "Seat $seat is the first to settle a new island: +$ISLAND_BONUS victory points.",
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Longest route
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The seat's longest route over roads *and* ships. A route is a simple path of the seat's
|
||||
* pieces that never runs through an opponent's town; it may change between road and ship
|
||||
* only at a town of the seat's own.
|
||||
*/
|
||||
fun longestRouteFor(state: CatanState, seat: Int): Int {
|
||||
val topo = state.topo
|
||||
val sea = state.sea!!
|
||||
val blocked = BooleanArray(topo.VERTEX_COUNT) { v ->
|
||||
state.buildings[v]?.let { it.owner != seat } == true
|
||||
}
|
||||
fun owns(e: Int) = state.roads[e] == seat || sea.ships[e] == seat
|
||||
fun kind(e: Int) = if (state.roads[e] == seat) 0 else 1
|
||||
|
||||
fun walk(v: Int, arrivedBy: Int, visited: BooleanArray): Int {
|
||||
visited[v] = true
|
||||
var best = 0
|
||||
val ownTown = state.buildings[v]?.owner == seat
|
||||
for (e in topo.VERTEX_EDGES[v]) {
|
||||
if (!owns(e)) continue
|
||||
val k = kind(e)
|
||||
if (arrivedBy != -1 && k != arrivedBy && !ownTown) continue
|
||||
val w = topo.otherEnd(e, v)
|
||||
if (visited[w]) continue
|
||||
best = maxOf(best, if (blocked[w]) 1 else 1 + walk(w, k, visited))
|
||||
}
|
||||
visited[v] = false
|
||||
return best
|
||||
}
|
||||
|
||||
var best = 0
|
||||
for (start in 0 until topo.VERTEX_COUNT) {
|
||||
if (topo.VERTEX_EDGES[start].none { owns(it) }) continue
|
||||
best = maxOf(best, walk(start, -1, BooleanArray(topo.VERTEX_COUNT)))
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.prolibertate.games.game.checkers
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlin.math.roundToInt
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -17,22 +20,49 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* recapture as its only legal move, so the search plays it whether it wanted to
|
||||
* or not.
|
||||
*/
|
||||
class CheckersAi(private val level: CheckersLevel? = null) :
|
||||
GameAi<CheckersState, CheckersMove> {
|
||||
class CheckersAi(
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val level: CheckersLevel? = null,
|
||||
) : GameAi<CheckersState, CheckersMove> {
|
||||
|
||||
private var nodeBudget = 0
|
||||
private var nodesUsed = 0
|
||||
|
||||
// Evaluation weights and viewpoint for the move being chosen. At the default
|
||||
// (balanced) style they equal the plain constants, so Medium is unchanged.
|
||||
private var rootSeat = 0
|
||||
private var wAdvance = ADVANCE
|
||||
private var wEdge = EDGE
|
||||
private var wBackRow = BACK_ROW
|
||||
private var wTheirMobility = MOBILITY
|
||||
|
||||
override fun chooseMove(
|
||||
state: CheckersState,
|
||||
seat: Int,
|
||||
legal: List<CheckersMove>,
|
||||
): CheckersMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
val bold = profile.style.boldness
|
||||
val spite = profile.style.spite
|
||||
rootSeat = seat
|
||||
// Bold seats push men forward and care less about the edge and back row;
|
||||
// cautious ones do the reverse. Spiteful seats weigh the opponent's
|
||||
// freedom of movement more heavily.
|
||||
wAdvance = ADVANCE + (bold * 2).roundToInt()
|
||||
wEdge = EDGE - (bold * 2).roundToInt()
|
||||
wBackRow = BACK_ROW - (bold * 3).roundToInt()
|
||||
wTheirMobility = MOBILITY + (spite * 2).roundToInt()
|
||||
|
||||
val maxDepth = when (profile.level) {
|
||||
AiLevel.EASY -> maxOf(2, setting.depth - 2)
|
||||
AiLevel.MEDIUM -> setting.depth
|
||||
AiLevel.HARD -> setting.depth + 2
|
||||
}
|
||||
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
|
||||
nodesUsed = 0
|
||||
|
||||
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
|
||||
@@ -40,7 +70,7 @@ class CheckersAi(private val level: CheckersLevel? = null) :
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
for (depth in 1..maxDepth) {
|
||||
val results = mutableListOf<Pair<CheckersMove, Int>>()
|
||||
val ordered = order(legal, preferred = best)
|
||||
for (move in ordered) {
|
||||
@@ -61,7 +91,16 @@ class CheckersAi(private val level: CheckersLevel? = null) :
|
||||
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
val chosen = acceptable[random.nextInt(acceptable.size)].first
|
||||
if (profile.level != AiLevel.EASY) return chosen
|
||||
|
||||
// Easy now and then settles for another sound-looking move: only ones
|
||||
// within a man and a half of the best, never a real blunder.
|
||||
val ranked = listOf(chosen) + scored
|
||||
.filter { it.first != chosen && it.second >= bestScore - EASY_TOLERANCE }
|
||||
.sortedByDescending { it.second }
|
||||
.map { it.first }
|
||||
return profile.pick(ranked)
|
||||
}
|
||||
|
||||
private fun CheckersLevel.slack(): Int = when (this) {
|
||||
@@ -126,29 +165,32 @@ class CheckersAi(private val level: CheckersLevel? = null) :
|
||||
*/
|
||||
private fun evaluate(state: CheckersState): Int {
|
||||
val us = state.turn
|
||||
val them = other(us)
|
||||
var score = 0
|
||||
val me = rootSeat
|
||||
|
||||
for (square in 0 until SQUARES) {
|
||||
val piece = state.board[square] ?: continue
|
||||
val sign = if (piece.seat == us) 1 else -1
|
||||
val sign = if (piece.seat == me) 1 else -1
|
||||
score += sign * if (piece.king) KING else MAN
|
||||
|
||||
if (!piece.king) {
|
||||
// How many rows it has come, out of the seven it needs.
|
||||
val advanced = if (piece.seat == BLACK_SEAT) rowOf(square) else 7 - rowOf(square)
|
||||
score += sign * advanced * ADVANCE
|
||||
score += sign * advanced * wAdvance
|
||||
}
|
||||
|
||||
// Sitting on the edge cannot be jumped, which is worth a little in
|
||||
// a game whose entire tactic is being jumped.
|
||||
if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * EDGE
|
||||
if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * wEdge
|
||||
|
||||
if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * BACK_ROW
|
||||
if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * wBackRow
|
||||
}
|
||||
|
||||
score += MOBILITY * (mobility(state, us) - mobility(state, them))
|
||||
return score
|
||||
val mine = mobility(state, me)
|
||||
val theirs = mobility(state, other(me))
|
||||
score += MOBILITY * mine - wTheirMobility * theirs
|
||||
// Scored from the root seat's side; flip for the side to move.
|
||||
return if (us == me) score else -score
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +220,8 @@ class CheckersAi(private val level: CheckersLevel? = null) :
|
||||
const val INFINITY = 1_000_000
|
||||
const val WIN = 100_000
|
||||
const val NODE_BUDGET = 200_000
|
||||
const val HARD_NODE_BUDGET = 450_000
|
||||
const val EASY_TOLERANCE = 150
|
||||
|
||||
const val MAN = 100
|
||||
const val KING = 175
|
||||
|
||||
@@ -2,6 +2,9 @@ package org.prolibertate.games.game.chess
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -16,7 +19,14 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* so the setup screen sets the strength without the caller having to thread it
|
||||
* through.
|
||||
*/
|
||||
class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessMove> {
|
||||
class ChessAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<ChessState, ChessMove> {
|
||||
|
||||
/** Fixed strength that overrides the table's option (used by tests and tools). */
|
||||
private var level: ChessLevel? = null
|
||||
|
||||
constructor(level: ChessLevel) : this(AiProfiles.DEFAULT) {
|
||||
this.level = level
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling on how much thinking any one move gets. A phone should not stall
|
||||
@@ -28,10 +38,22 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
|
||||
|
||||
override fun chooseMove(state: ChessState, seat: Int, legal: List<ChessMove>): ChessMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
// Easy looks one move less far ahead; Hard looks one further and gets
|
||||
// roughly twice the node budget (still a couple of seconds on a phone).
|
||||
val maxDepth = when (profile.level) {
|
||||
AiLevel.EASY -> maxOf(1, setting.depth - 1)
|
||||
AiLevel.MEDIUM -> setting.depth
|
||||
AiLevel.HARD -> setting.depth + 1
|
||||
}
|
||||
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
|
||||
aiWhite = state.whiteToMove
|
||||
styleActive = profile.style.boldness != 0.0 || profile.style.spite != 0.0
|
||||
styleBoldness = profile.style.boldness
|
||||
styleSpite = profile.style.spite
|
||||
nodesUsed = 0
|
||||
|
||||
// Deterministic per position, so a host and a client replaying the same
|
||||
@@ -42,7 +64,7 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
for (depth in 1..maxDepth) {
|
||||
val results = mutableListOf<Pair<ChessMove, Int>>()
|
||||
// Search the previous iteration's best first, so the sub-searches
|
||||
// below it have a decent bound to prune against.
|
||||
@@ -74,9 +96,56 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
|
||||
// makes them beatable without making them play nonsense.
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
val chosen = acceptable[random.nextInt(acceptable.size)].first
|
||||
if (profile.level != AiLevel.EASY) return chosen
|
||||
|
||||
// Easy sometimes settles for a worse move, but only one within a modest
|
||||
// margin of the best, so it never throws a piece away or walks into mate.
|
||||
val ranked = scored.filter { it.second >= bestScore - setting.slack() - EASY_EXTRA_SLACK }
|
||||
.sortedByDescending { it.second }.map { it.first }
|
||||
val ordered = listOf(chosen) + ranked.filter { it != chosen }
|
||||
return profile.pick(ordered)
|
||||
}
|
||||
|
||||
// Per-move style state, set at the start of chooseMove.
|
||||
private var aiWhite = true
|
||||
private var styleActive = false
|
||||
private var styleBoldness = 0.0
|
||||
private var styleSpite = 0.0
|
||||
|
||||
/**
|
||||
* A small temperament term (well under a pawn) from the mover's point of
|
||||
* view. Aggressive seats like pieces near the enemy king, cautious seats
|
||||
* like a sheltered own king, spiteful seats like the enemy being badly
|
||||
* placed. Zero for the default style, so default play is unchanged.
|
||||
*/
|
||||
private fun styleTerm(state: ChessState): Int {
|
||||
val enemyKing = state.kingSquare(white = !aiWhite)
|
||||
val myKing = state.kingSquare(white = aiWhite)
|
||||
var attackers = 0
|
||||
var shield = 0
|
||||
var enemyPlacement = 0
|
||||
for (square in 0 until 64) {
|
||||
val piece = state.board[square] ?: continue
|
||||
if (piece.white == aiWhite) {
|
||||
if (piece.kind == PieceKind.KING) continue
|
||||
if (enemyKing != null && piece.kind != PieceKind.PAWN && distance(square, enemyKing) <= 3) attackers++
|
||||
if (myKing != null && distance(square, myKing) <= 1) shield++
|
||||
} else {
|
||||
val table = tableFor(piece.kind)
|
||||
enemyPlacement += table[if (piece.white) square else square xor 56]
|
||||
}
|
||||
}
|
||||
var term = 0.0
|
||||
if (styleBoldness > 0) term += styleBoldness * 4 * attackers
|
||||
if (styleBoldness < 0) term += -styleBoldness * 5 * shield
|
||||
term -= styleSpite * 0.1 * enemyPlacement
|
||||
val own = term.toInt().coerceIn(-40, 40)
|
||||
return if (state.whiteToMove == aiWhite) own else -own
|
||||
}
|
||||
|
||||
private fun distance(a: Int, b: Int): Int = maxOf(abs(fileOf(a) - fileOf(b)), abs(rankOf(a) - rankOf(b)))
|
||||
|
||||
private fun ChessLevel.slack(): Int = when (this) {
|
||||
ChessLevel.CASUAL -> 70
|
||||
ChessLevel.CLUB -> 20
|
||||
@@ -116,7 +185,7 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
|
||||
*/
|
||||
private fun quiescence(state: ChessState, alpha: Int, beta: Int, ply: Int): Int {
|
||||
nodesUsed++
|
||||
val standPat = evaluate(state)
|
||||
val standPat = evaluate(state) + if (styleActive) styleTerm(state) else 0
|
||||
if (standPat >= beta) return beta
|
||||
var localAlpha = maxOf(alpha, standPat)
|
||||
if (ply >= MAX_QUIESCENCE_PLY) return localAlpha
|
||||
@@ -205,6 +274,8 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
|
||||
const val INFINITY = 1_000_000
|
||||
const val MATE = 100_000
|
||||
const val NODE_BUDGET = 120_000
|
||||
const val HARD_NODE_BUDGET = 240_000
|
||||
const val EASY_EXTRA_SLACK = 60
|
||||
const val MAX_QUIESCENCE_PLY = 24
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,52 +1,135 @@
|
||||
package org.prolibertate.games.game.crazyeights
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic Crazy 8s opponent.
|
||||
* A Crazy 8s opponent whose strength and temperament come from the seat's profile.
|
||||
*
|
||||
* It sheds expensive cards first, keeps its eights back for when it is stuck,
|
||||
* and when it does play one it names the suit it holds most of.
|
||||
* MEDIUM (the default) sheds expensive cards first, keeps its eights back for
|
||||
* when it is stuck, and when it does play one it names the suit it holds most of.
|
||||
*
|
||||
* EASY plays the same way but sometimes sheds a cheaper card or wastes an eight.
|
||||
* HARD counts the cards on the discard pile to know what is still out, plays to
|
||||
* keep a long suit it can run, and when someone is close to going out it turns
|
||||
* the game to a suit that is nearly exhausted so they cannot follow.
|
||||
*/
|
||||
class CrazyEightsAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
class CrazyEightsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<CrazyEightsState, CrazyEightsMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/** Play an eight anyway once the hand is down to this size. */
|
||||
val eightEndgame: Int,
|
||||
) {
|
||||
EASY(eightEndgame = 99),
|
||||
NORMAL(eightEndgame = 3),
|
||||
CAUTIOUS(eightEndgame = 2),
|
||||
}
|
||||
|
||||
override fun chooseMove(
|
||||
state: CrazyEightsState,
|
||||
seat: Int,
|
||||
legal: List<CrazyEightsMove>,
|
||||
): CrazyEightsMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val plays = legal.filterIsInstance<PlayCard>()
|
||||
if (plays.isEmpty()) return legal.first()
|
||||
|
||||
val move: CrazyEightsMove = when (profile.level) {
|
||||
AiLevel.MEDIUM -> standardRanking(state, seat, plays, EIGHT_ENDGAME).first()
|
||||
AiLevel.EASY -> profile.pick(standardRanking(state, seat, plays, EASY_EIGHT_ENDGAME))
|
||||
AiLevel.HARD -> hard(state, seat, plays, profile)
|
||||
}
|
||||
return if (move in legal) move else legal.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordinary cards first, most expensive leading, then eights with the suit
|
||||
* this hand holds most of named first.
|
||||
*/
|
||||
private fun standardRanking(
|
||||
state: CrazyEightsState,
|
||||
seat: Int,
|
||||
plays: List<PlayCard>,
|
||||
eightEndgame: Int,
|
||||
): List<PlayCard> {
|
||||
val hand = state.hands[seat]
|
||||
val ordinary = plays.filter { !isWild(it.card) }
|
||||
|
||||
// An eight is the only card that always plays, so it is worth more held
|
||||
// than spent — unless the hand is nearly gone, or nothing else works.
|
||||
val useEight = ordinary.isEmpty() || hand.size <= difficulty.eightEndgame
|
||||
val useEight = ordinary.isEmpty() || hand.size <= eightEndgame
|
||||
val pool = if (useEight) plays else ordinary
|
||||
|
||||
val best = pool
|
||||
.filter { !isWild(it.card) }
|
||||
.maxByOrNull { crazyEightsPenalty(it.card) }
|
||||
|
||||
if (best != null) return best
|
||||
val ranked = pool.filter { !isWild(it.card) }.sortedByDescending { crazyEightsPenalty(it.card) }
|
||||
if (ranked.isNotEmpty() && !useEight) return ranked
|
||||
|
||||
// Playing an eight: switch the game to whatever we hold most of.
|
||||
val strongest = Suit.entries.maxByOrNull { suit -> hand.count { it.suit == suit } }
|
||||
?: Suit.SPADES
|
||||
return pool.firstOrNull { it.nominatedSuit == strongest } ?: pool.first()
|
||||
val suits = Suit.entries.sortedByDescending { suit -> hand.count { it.suit == suit } }
|
||||
val wilds = suits.flatMap { suit -> pool.filter { isWild(it.card) && it.nominatedSuit == suit } }
|
||||
return ranked + wilds.ifEmpty { pool.filter { isWild(it.card) } }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hard
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun hard(
|
||||
state: CrazyEightsState,
|
||||
seat: Int,
|
||||
plays: List<PlayCard>,
|
||||
profile: AiProfile,
|
||||
): CrazyEightsMove {
|
||||
val hand = state.hands[seat]
|
||||
val options = state.options
|
||||
|
||||
// Cards this seat can account for: its own hand and everything on the pile.
|
||||
val known = HashSet<Card>().also { it.addAll(hand); it.addAll(state.discard) }
|
||||
val unseen = FULL_DECK.filter { it !in known }
|
||||
fun unseenIn(suit: Suit): Int = unseen.count { it.suit == suit }
|
||||
|
||||
val next = (seat + 1) % options.playerCount
|
||||
val nextCount = state.handCounts[next]
|
||||
val smallestOpponent = (0 until options.playerCount).filter { it != seat }
|
||||
.minOf { state.handCounts[it] }
|
||||
// How hard to squeeze the suits: more when somebody is about to go out.
|
||||
val urgency = 1.0 + profile.style.spite +
|
||||
(if (nextCount <= 2) 1.0 else 0.0) + (if (smallestOpponent <= 2) 0.5 else 0.0)
|
||||
|
||||
/** Value of leaving the game showing [suit], with [remaining] the cards still held. */
|
||||
fun suitValue(suit: Suit, remaining: List<Card>): Double =
|
||||
3.0 * remaining.count { it.suit == suit } - 0.7 * urgency * unseenIn(suit)
|
||||
|
||||
val ordinary = plays.filter { !isWild(it.card) }
|
||||
val wilds = plays.filter { isWild(it.card) }
|
||||
|
||||
// Aggressive seats spend eights earlier; cautious ones keep them as insurance.
|
||||
val endgame = (EIGHT_ENDGAME + profile.style.boldness * 2.0)
|
||||
val blocking = smallestOpponent <= 1 || (nextCount <= 2 && urgency >= 2.0)
|
||||
val useEight = ordinary.isEmpty() || hand.size <= endgame || (blocking && wilds.isNotEmpty())
|
||||
|
||||
if (hand.size == 1) {
|
||||
plays.firstOrNull()?.let { return it }
|
||||
}
|
||||
|
||||
if (!useEight || wilds.isEmpty()) {
|
||||
if (ordinary.isNotEmpty()) {
|
||||
// Cautious seats shed liabilities harder, bold ones care more about position.
|
||||
val shedWeight = 1.0 - 0.3 * profile.style.boldness
|
||||
return ordinary.maxBy { play ->
|
||||
val remaining = hand - play.card
|
||||
crazyEightsPenalty(play.card) * shedWeight + suitValue(play.card.suit, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An eight: name the suit that is best for this hand and worst for the others.
|
||||
return wilds.maxBy { play ->
|
||||
val remaining = hand - play.card
|
||||
suitValue(play.nominatedSuit ?: play.card.suit, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EIGHT_ENDGAME = 3
|
||||
const val EASY_EIGHT_ENDGAME = 99
|
||||
val FULL_DECK: List<Card> = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ package org.prolibertate.games.game.cribbage
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
import kotlin.math.abs
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* A cribbage opponent that counts rather than guesses.
|
||||
@@ -18,8 +22,14 @@ import kotlin.math.abs
|
||||
*
|
||||
* The play is a heuristic: take the points on offer, and try not to leave the
|
||||
* count where a ten-card collects fifteen or thirty-one.
|
||||
*
|
||||
* Per seat, from [profiles]: Easy settles for a worse lay-away or card now and
|
||||
* then; Hard also samples the crib (what the unseen cards might add to it) and
|
||||
* weighs what a reply could score off each card it plays. Boldness trades the
|
||||
* two counts-to-avoid and crib ownership; spite weighs the other side's crib.
|
||||
*/
|
||||
class CribbageAi : GameAi<CribbageState, CribbageMove> {
|
||||
class CribbageAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<CribbageState, CribbageMove> {
|
||||
|
||||
override fun chooseMove(
|
||||
state: CribbageState,
|
||||
@@ -27,30 +37,78 @@ class CribbageAi : GameAi<CribbageState, CribbageMove> {
|
||||
legal: List<CribbageMove>,
|
||||
): CribbageMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val layAways = legal.filterIsInstance<LayAway>()
|
||||
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways)
|
||||
return choosePeg(state, legal.filterIsInstance<PegCard>())
|
||||
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways, profile)
|
||||
val pegs = legal.filterIsInstance<PegCard>()
|
||||
if (pegs.isNotEmpty()) return choosePeg(state, seat, pegs, profile)
|
||||
return legal.first()
|
||||
}
|
||||
|
||||
private fun chooseLayAway(
|
||||
state: CribbageState,
|
||||
seat: Int,
|
||||
choices: List<LayAway>,
|
||||
profile: AiProfile,
|
||||
): LayAway {
|
||||
val hand = state.hands[seat]
|
||||
val held = hand.toSet()
|
||||
val unseen = Decks.standard52().filter { it !in held }
|
||||
// A partner's crib is your crib. That is the whole of four-handed play.
|
||||
val ownCrib = state.options.teamOf(seat) == state.options.teamOf(state.dealer)
|
||||
val boldness = profile.style.boldness
|
||||
// Bold seats back their own crib; careful ones fear the other side's.
|
||||
val cribSign = if (ownCrib) {
|
||||
1.0 + 0.25 * boldness
|
||||
} else {
|
||||
-(1.0 - 0.25 * boldness + 0.3 * profile.style.spite)
|
||||
}
|
||||
val trials = if (profile.level == AiLevel.HARD) sampleCribs(hand, unseen, state.options) else null
|
||||
|
||||
return choices.maxByOrNull { choice ->
|
||||
val scored = choices.map { choice ->
|
||||
val given = choice.cards.toSet()
|
||||
val keep = hand.filter { it !in given }
|
||||
val expected = unseen.sumOf { starter ->
|
||||
CribbageScoring.show(keep, starter, isCrib = false).sumOf { it.points }
|
||||
}.toDouble() / unseen.size
|
||||
expected + cribValue(choice.cards) * (if (ownCrib) 1.0 else -1.0)
|
||||
} ?: choices.first()
|
||||
val crib = if (trials != null) {
|
||||
// Heuristic and sampled estimates agree on direction; averaging
|
||||
// them smooths the sampling noise without losing the structure.
|
||||
(sampledCribValue(choice.cards, trials) + cribValue(choice.cards)) / 2.0
|
||||
} else {
|
||||
cribValue(choice.cards)
|
||||
}
|
||||
choice to expected + crib * cribSign
|
||||
}
|
||||
// Stable sort: ties resolve to the first choice, as maxByOrNull did.
|
||||
val ranked = scored.sortedByDescending { it.second }.map { it.first }
|
||||
return profile.pick(ranked)
|
||||
}
|
||||
|
||||
/** Sets of unseen cards a crib might receive, plus a cut, for Hard to average over. */
|
||||
private fun sampleCribs(
|
||||
hand: List<Card>,
|
||||
unseen: List<Card>,
|
||||
options: CribbageOptions,
|
||||
): List<List<Card>> {
|
||||
val random = Random(hand.sumOf { it.rank.ordinal * 4 + it.suit.ordinal }.toLong())
|
||||
// Whatever the crib is short of after our own cards, plus the cut.
|
||||
val others = (CRIB_SIZE - options.layAwaySize).coerceAtLeast(0)
|
||||
return List(CRIB_SAMPLES) {
|
||||
val picked = LinkedHashSet<Card>()
|
||||
while (picked.size < others + 1) picked += unseen[random.nextInt(unseen.size)]
|
||||
picked.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampledCribValue(given: List<Card>, trials: List<List<Card>>): Double {
|
||||
var total = 0
|
||||
for (trial in trials) {
|
||||
val starter = trial.last()
|
||||
val crib = given + trial.dropLast(1)
|
||||
total += CribbageScoring.show(crib, starter, isCrib = true).sumOf { it.points }
|
||||
}
|
||||
return total.toDouble() / trials.size
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,22 +141,63 @@ class CribbageAi : GameAi<CribbageState, CribbageMove> {
|
||||
return value
|
||||
}
|
||||
|
||||
private fun choosePeg(state: CribbageState, choices: List<PegCard>): PegCard {
|
||||
private fun choosePeg(
|
||||
state: CribbageState,
|
||||
seat: Int,
|
||||
choices: List<PegCard>,
|
||||
profile: AiProfile,
|
||||
): PegCard {
|
||||
val count = state.count
|
||||
val onTheTable = state.series.map { it.card }
|
||||
val boldness = profile.style.boldness
|
||||
// Bold seats risk leaving five or twenty-one; careful ones really avoid it.
|
||||
val countPenalty = 3.0 - boldness * 1.5
|
||||
val leadFivePenalty = 4.0 - boldness * 2.0
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
// Cards a reply could come from: nothing this seat holds, has seen played or was cut.
|
||||
val unseen = if (hard) {
|
||||
val known = (state.remaining(seat) + state.played.map { it.card } + listOfNotNull(state.starter)).toSet()
|
||||
Decks.standard52().filter { it !in known && it !in state.hands[seat] }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return choices.maxByOrNull { choice ->
|
||||
val scored = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
|
||||
val scored = choices.map { choice ->
|
||||
val points = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
|
||||
val after = count + pipValue(choice.card)
|
||||
var value = scored * 10.0
|
||||
var value = points * 10.0
|
||||
// Five and twenty-one are the two counts to avoid leaving: any of
|
||||
// the sixteen ten-cards takes fifteen or thirty-one off them.
|
||||
if (after == 5 || after == 21) value -= 3.0
|
||||
if (after == 5 || after == 21) value -= countPenalty
|
||||
// Leading a five is the same present, offered first.
|
||||
if (count == 0 && choice.card.rank == Rank.FIVE) value -= 4.0
|
||||
if (count == 0 && choice.card.rank == Rank.FIVE) value -= leadFivePenalty
|
||||
// Big cards early. Low ones are what let you keep playing at the
|
||||
// awkward end of a series, where the last card is worth a point.
|
||||
value + pipValue(choice.card) * 0.1
|
||||
} ?: choices.first()
|
||||
value += pipValue(choice.card) * 0.1
|
||||
if (hard && unseen.isNotEmpty() && after < CRIBBAGE_LIMIT) {
|
||||
value -= replyThreat(onTheTable + choice.card, after, unseen) * 8.0
|
||||
}
|
||||
choice to value
|
||||
}
|
||||
val ranked = scored.sortedByDescending { it.second }.map { it.first }
|
||||
return profile.pick(ranked)
|
||||
}
|
||||
|
||||
/** Average points a reply scores off [series], over the cards still unseen. */
|
||||
private fun replyThreat(series: List<Card>, after: Int, unseen: List<Card>): Double {
|
||||
var total = 0
|
||||
var playable = 0
|
||||
for (card in unseen) {
|
||||
if (after + pipValue(card) > CRIBBAGE_LIMIT) continue
|
||||
playable++
|
||||
total += CribbageScoring.peg(series + card).sumOf { it.points }
|
||||
}
|
||||
// Nothing playable is a go for them, worth a point to whoever last played.
|
||||
return if (playable == 0) 0.0 else total.toDouble() / unseen.size
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CRIB_SIZE = 4
|
||||
const val CRIB_SAMPLES = 60
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.prolibertate.games.game.engine
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.random.Random
|
||||
|
||||
/** How well one computer seat plays. */
|
||||
enum class AiLevel {
|
||||
/** Makes frequent, plausible mistakes and plays without much foresight. */
|
||||
EASY,
|
||||
|
||||
/** The game's standard computer opponent. */
|
||||
MEDIUM,
|
||||
|
||||
/** Plays carefully: more foresight, no deliberate mistakes. */
|
||||
HARD,
|
||||
}
|
||||
|
||||
/** What the person at setup chooses for the table's computer seats. */
|
||||
@Serializable
|
||||
enum class AiDifficulty(val label: String) {
|
||||
EASY("Easy"),
|
||||
MEDIUM("Medium"),
|
||||
HARD("Hard"),
|
||||
|
||||
/** Each computer seat gets its own level, picked at random. */
|
||||
MIXED("Mixed"),
|
||||
}
|
||||
|
||||
/**
|
||||
* A computer seat's temperament. Never shown to the human players: it only
|
||||
* shapes how the AI trades off the options in front of it.
|
||||
*/
|
||||
enum class AiStyle(
|
||||
/** -1 plays safe, +1 gambles; 0 is the game's standard balance. */
|
||||
val boldness: Double,
|
||||
/** How much the seat cares about stopping opponents rather than building its own position. */
|
||||
val spite: Double,
|
||||
) {
|
||||
BALANCED(0.0, 0.0),
|
||||
AGGRESSIVE(0.8, 0.3),
|
||||
CAUTIOUS(-0.8, 0.0),
|
||||
SPOILER(0.0, 0.8),
|
||||
}
|
||||
|
||||
/**
|
||||
* One computer seat's level and style, and a source of randomness of its own.
|
||||
*
|
||||
* [DEFAULT] is the game's standard opponent: [AiLevel.MEDIUM], [AiStyle.BALANCED],
|
||||
* and it never slips, so an AI built without a profile plays exactly as it did
|
||||
* before profiles existed.
|
||||
*/
|
||||
class AiProfile(
|
||||
val level: AiLevel = AiLevel.MEDIUM,
|
||||
val style: AiStyle = AiStyle.BALANCED,
|
||||
val random: Random = Random(0),
|
||||
) {
|
||||
/**
|
||||
* Whether this seat should throw away its best option this time. Only
|
||||
* [AiLevel.EASY] does, and only some of the time, so it still plays like a
|
||||
* person who is not concentrating rather than like a random-move generator.
|
||||
*/
|
||||
fun slips(): Boolean = level == AiLevel.EASY && random.nextDouble() < EASY_SLIP_CHANCE
|
||||
|
||||
/**
|
||||
* Picks from [ranked] (best first). Easy sometimes settles for a worse choice,
|
||||
* drawn from the better half so it stays plausible; other levels take the best.
|
||||
*/
|
||||
fun <T> pick(ranked: List<T>): T {
|
||||
require(ranked.isNotEmpty()) { "Nothing to pick from" }
|
||||
if (!slips() || ranked.size == 1) return ranked.first()
|
||||
val pool = ranked.take(maxOf(2, (ranked.size + 1) / 2))
|
||||
return pool[random.nextInt(pool.size)]
|
||||
}
|
||||
|
||||
/** [base] nudged by this seat's boldness: a gambler raises it, a cautious seat lowers it. */
|
||||
fun bolder(base: Double, strength: Double = 1.0): Double = base + style.boldness * strength
|
||||
|
||||
companion object {
|
||||
const val EASY_SLIP_CHANCE = 0.3
|
||||
val DEFAULT = AiProfile()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile for every computer seat at a table, worked out from the table's
|
||||
* difficulty setting and its seed alone.
|
||||
*
|
||||
* Derived rather than stored, so a saved game, a rematch from the same config
|
||||
* and a guest's copy of a hosted table all give the same seat the same
|
||||
* personality without it travelling anywhere.
|
||||
*/
|
||||
class AiProfiles(
|
||||
private val difficulty: AiDifficulty,
|
||||
private val seed: Long,
|
||||
) {
|
||||
private val cache = HashMap<Int, AiProfile>()
|
||||
|
||||
constructor(config: TableConfig) : this(config.aiDifficulty, config.seed)
|
||||
|
||||
@Synchronized
|
||||
fun of(seat: Int): AiProfile = cache.getOrPut(seat) {
|
||||
val picker = Random(seed * 31 + seat * 7919L + 17)
|
||||
val level = when (difficulty) {
|
||||
AiDifficulty.EASY -> AiLevel.EASY
|
||||
AiDifficulty.MEDIUM -> AiLevel.MEDIUM
|
||||
AiDifficulty.HARD -> AiLevel.HARD
|
||||
AiDifficulty.MIXED -> AiLevel.entries[picker.nextInt(AiLevel.entries.size)]
|
||||
}
|
||||
// The standard opponent is balanced by definition; the others each get a
|
||||
// temperament, so no two computer seats feel alike.
|
||||
val style = if (difficulty == AiDifficulty.MEDIUM) {
|
||||
AiStyle.BALANCED
|
||||
} else {
|
||||
AiStyle.entries[picker.nextInt(AiStyle.entries.size)]
|
||||
}
|
||||
AiProfile(level, style, Random(picker.nextLong()))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Standard opponents, for a caller that has no table to ask. */
|
||||
val DEFAULT = AiProfiles(AiDifficulty.MEDIUM, 0L)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ data class TableConfig(
|
||||
val optionsJson: String,
|
||||
/** Shared shuffle seed so a hand is reproducible from its state. */
|
||||
val seed: Long,
|
||||
/** How hard the computer seats play. Old saves and old hosts omit it and get the standard opponent. */
|
||||
val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,21 @@ interface GameRules<S : Any, M : Any> {
|
||||
|
||||
fun legalMoves(state: S, seat: Int): List<M>
|
||||
|
||||
/**
|
||||
* Whether [move] is currently legal for [seat].
|
||||
*
|
||||
* Defaults to membership in [legalMoves], which is everything a game
|
||||
* with a small, enumerable move set needs. A game whose move space is
|
||||
* combinatorial — Catan's trade offers name any two multisets of cards —
|
||||
* overrides this and validates structurally instead, because enumerating
|
||||
* every intent would explode the list the UI and the network driver work
|
||||
* from. Gates that check a specific move should call this method rather
|
||||
* than testing list membership directly, so both kinds of game behave the
|
||||
* same way.
|
||||
*/
|
||||
fun isLegalMove(state: S, seat: Int, move: M): Boolean =
|
||||
move in legalMoves(state, seat)
|
||||
|
||||
/**
|
||||
* Applies [move] on behalf of [seat].
|
||||
*
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.prolibertate.games.game.engine
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A game in progress, kept so it can be picked up again after the app has been
|
||||
* closed.
|
||||
*
|
||||
* The match is stored the way the host holds it: the [config] it was dealt from
|
||||
* (seed, seats, options) and the whole authoritative [stateJson], hidden cards
|
||||
* included, as the game's own rules encode it. Nothing here knows what game it
|
||||
* is; [gameId] is only the name of the slot.
|
||||
*/
|
||||
@Serializable
|
||||
data class SavedMatch(
|
||||
val gameId: String,
|
||||
val config: TableConfig,
|
||||
/** The seat the person at this device plays. */
|
||||
val localSeat: Int,
|
||||
val stateJson: String,
|
||||
/** Wall-clock milliseconds when the position was written. */
|
||||
val savedAtMillis: Long,
|
||||
/** A line saying how the game stands, from the rules' own summary. */
|
||||
val summary: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* One saved game per game, kept as a file each under [directory].
|
||||
*
|
||||
* One slot per game rather than a list: the player is mid-way through a game of
|
||||
* Chess or they are not. Starting a new one replaces it, which is what the
|
||||
* resume prompt on the setup screen says.
|
||||
*
|
||||
* Writes are atomic — the position goes to a temporary file that is then moved
|
||||
* over the real one — so being killed half way through a save leaves the
|
||||
* previous position, not half of the new one. Reads never throw: a file that
|
||||
* will not parse is a game that cannot be resumed, so it is removed and reported
|
||||
* as absent.
|
||||
*/
|
||||
class SavedGameStore(private val directory: File) {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private fun fileFor(gameId: String): File {
|
||||
// The ids are catalogue constants, but they become file names, so
|
||||
// nothing that could climb out of the directory is let through.
|
||||
require(gameId.isNotBlank() && gameId.all { it.isLetterOrDigit() || it == '_' || it == '-' }) {
|
||||
"Not a game id: $gameId"
|
||||
}
|
||||
return File(directory, "$gameId.json")
|
||||
}
|
||||
|
||||
/** Writes [match] over whatever was saved for its game. */
|
||||
@Synchronized
|
||||
fun save(match: SavedMatch) {
|
||||
directory.mkdirs()
|
||||
val target = fileFor(match.gameId)
|
||||
val temp = File(directory, "${match.gameId}.json.tmp")
|
||||
temp.writeText(json.encodeToString(SavedMatch.serializer(), match))
|
||||
if (!temp.renameTo(target)) {
|
||||
// Some file systems will not rename over an existing file.
|
||||
target.delete()
|
||||
check(temp.renameTo(target)) { "Could not write the saved game" }
|
||||
}
|
||||
}
|
||||
|
||||
/** The saved game for [gameId], or null if there is none or it cannot be read. */
|
||||
@Synchronized
|
||||
fun load(gameId: String): SavedMatch? {
|
||||
val file = fileFor(gameId)
|
||||
if (!file.isFile) return null
|
||||
val match = runCatching {
|
||||
json.decodeFromString(SavedMatch.serializer(), file.readText())
|
||||
}.getOrNull()
|
||||
if (match == null || match.gameId != gameId) {
|
||||
file.delete()
|
||||
return null
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
/** Forgets the saved game for [gameId]. A no-op if there is none. */
|
||||
@Synchronized
|
||||
fun clear(gameId: String) {
|
||||
fileFor(gameId).delete()
|
||||
}
|
||||
|
||||
/** Every readable saved game, the most recently played first. */
|
||||
@Synchronized
|
||||
fun all(): List<SavedMatch> {
|
||||
val files = directory.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
|
||||
return files
|
||||
.mapNotNull { file -> runCatching { load(file.name.removeSuffix(".json")) }.getOrNull() }
|
||||
.sortedByDescending { it.savedAtMillis }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package org.prolibertate.games.game.euchre
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -13,7 +17,7 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* That is enough to be a reasonable table opponent without pretending to be a
|
||||
* solver.
|
||||
*/
|
||||
class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
class EuchreAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<EuchreState, EuchreMove> {
|
||||
|
||||
/**
|
||||
@@ -22,25 +26,50 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
* means every seat bids and the card is never turned down — see
|
||||
* EuchreBiddingRateTest, which pins the resulting order-up rate.
|
||||
*/
|
||||
enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) {
|
||||
/** Bids loosely and plays straightforwardly. */
|
||||
EASY(bidThreshold = 21, aloneThreshold = 32),
|
||||
private companion object {
|
||||
const val BID_THRESHOLD = 24.0
|
||||
const val ALONE_THRESHOLD = 32.0
|
||||
}
|
||||
|
||||
NORMAL(bidThreshold = 24, aloneThreshold = 32),
|
||||
/** Cards this AI has watched played in the current hand, per seat it plays. */
|
||||
private val memory = HashMap<Int, SeenCards>()
|
||||
|
||||
/** Bids tighter and is harder to sneak a trick past. */
|
||||
CAUTIOUS(bidThreshold = 27, aloneThreshold = 31),
|
||||
private class SeenCards {
|
||||
var key: Long = Long.MIN_VALUE
|
||||
val cards = HashSet<Card>()
|
||||
}
|
||||
|
||||
/** Public information only: cards that have appeared on the table this hand. */
|
||||
private fun observe(state: EuchreState, seat: Int): Set<Card> = synchronized(memory) {
|
||||
val seen = memory.getOrPut(seat) { SeenCards() }
|
||||
val key = state.seed * 1_000_003L + state.handNumber
|
||||
if (key != seen.key || state.phase == EuchrePhase.BID_ROUND_1) {
|
||||
seen.key = key
|
||||
seen.cards.clear()
|
||||
}
|
||||
state.trick.forEach { seen.cards.add(it.card) }
|
||||
state.completedTrick.forEach { seen.cards.add(it.card) }
|
||||
state.turnedDown?.let { seen.cards.add(it) }
|
||||
seen.cards.removeAll(state.hands[seat].toSet())
|
||||
seen.cards.toSet()
|
||||
}
|
||||
|
||||
override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal)
|
||||
EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal)
|
||||
EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal)
|
||||
EuchrePhase.PLAYING -> choosePlay(state, seat, legal)
|
||||
else -> legal.first()
|
||||
val profile = profiles.of(seat)
|
||||
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
|
||||
val move = try {
|
||||
when (state.phase) {
|
||||
EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal, profile)
|
||||
EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal, profile)
|
||||
EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal, profile)
|
||||
EuchrePhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
|
||||
else -> legal.first()
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
legal.first()
|
||||
}
|
||||
return if (move in legal) move else legal.first()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -75,27 +104,61 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
return value
|
||||
}
|
||||
|
||||
/** The five-card hand left after the dealer picks up [upCard] and discards the least useful card. */
|
||||
private fun bestAfterPickup(hand: List<Card>, upCard: Card, trump: Suit): Int {
|
||||
val six = hand + upCard
|
||||
return six.maxOf { dropped -> handValue(six - dropped, trump) }
|
||||
}
|
||||
|
||||
/** A hand worth playing without a partner: real trump length with a bower to lead it. */
|
||||
private fun soundAlone(hand: List<Card>, trump: Suit): Boolean {
|
||||
val trumps = hand.count { isTrumpCard(it, trump) }
|
||||
val bower = hand.any { isRightBower(it, trump) || isLeftBower(it, trump) }
|
||||
return trumps >= 3 && bower
|
||||
}
|
||||
|
||||
/** Easy misjudges how good its hand is. */
|
||||
private fun misjudge(value: Int, profile: AiProfile): Int =
|
||||
if (profile.level == AiLevel.EASY) value + profile.random.nextInt(-5, 6) else value
|
||||
|
||||
private fun chooseBidRound1(
|
||||
state: EuchreState,
|
||||
seat: Int,
|
||||
legal: List<EuchreMove>,
|
||||
profile: AiProfile,
|
||||
): EuchreMove {
|
||||
val upCard = state.upCard ?: return Pass
|
||||
val trump = upCard.suit
|
||||
var hand = state.hands[seat]
|
||||
val isDealer = seat == state.dealer
|
||||
val partnerDealer = partnerOf(seat) == state.dealer
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
|
||||
// If our partner is the dealer they gain the turn card, and if we are
|
||||
// the dealer we gain it ourselves — both make the hand stronger than it
|
||||
// looks. Ordering up into an opponent dealer instead hands them a card.
|
||||
if (seat == state.dealer) hand = hand + upCard
|
||||
var value = handValue(hand, trump)
|
||||
if (partnerOf(seat) == state.dealer) value += 2
|
||||
if (seat != state.dealer && partnerOf(seat) != state.dealer) value -= 2
|
||||
val owned = if (isDealer) state.hands[seat] + upCard else state.hands[seat]
|
||||
var value = when {
|
||||
isDealer && hard -> bestAfterPickup(state.hands[seat], upCard, trump) + 3
|
||||
else -> handValue(owned, trump)
|
||||
}
|
||||
if (partnerDealer) value += 2
|
||||
if (!isDealer && !partnerDealer) {
|
||||
value -= 2
|
||||
// An opposing dealer who picks up a jack gets a bower out of our bid.
|
||||
if (hard && upCard.rank == Rank.JACK) value -= 2
|
||||
}
|
||||
value = misjudge(value, profile)
|
||||
|
||||
val bidThreshold = profile.bolder(BID_THRESHOLD, -3.0)
|
||||
var aloneThreshold = profile.bolder(ALONE_THRESHOLD, -3.0)
|
||||
if (profile.level == AiLevel.EASY) aloneThreshold += 4.0
|
||||
|
||||
val alone = OrderUp(alone = true)
|
||||
if (value >= difficulty.aloneThreshold && legal.contains(alone)) return alone
|
||||
if (value >= aloneThreshold && legal.contains(alone) && (!hard || soundAlone(owned, trump))) {
|
||||
return alone
|
||||
}
|
||||
val order = OrderUp(alone = false)
|
||||
if (value >= difficulty.bidThreshold && legal.contains(order)) return order
|
||||
if (value >= bidThreshold && legal.contains(order)) return order
|
||||
return if (legal.contains(Pass)) Pass else legal.first()
|
||||
}
|
||||
|
||||
@@ -103,37 +166,64 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
state: EuchreState,
|
||||
seat: Int,
|
||||
legal: List<EuchreMove>,
|
||||
profile: AiProfile,
|
||||
): EuchreMove {
|
||||
val hand = state.hands[seat]
|
||||
val callable = legal.filterIsInstance<CallTrump>()
|
||||
if (callable.isEmpty()) return legal.first()
|
||||
val plain = callable.filter { !it.alone }
|
||||
|
||||
val best = callable
|
||||
.filter { !it.alone }
|
||||
.maxByOrNull { handValue(hand, it.suit) }
|
||||
?: callable.first()
|
||||
val bestValue = handValue(hand, best.suit)
|
||||
val best: CallTrump
|
||||
val bestValue: Int
|
||||
if (profile.level == AiLevel.EASY) {
|
||||
val noisy = plain.associateWith { handValue(hand, it.suit) + profile.random.nextInt(-5, 6) }
|
||||
val ranked = plain.sortedByDescending { noisy.getValue(it) }
|
||||
best = if (ranked.isEmpty()) callable.first() else profile.pick(ranked)
|
||||
bestValue = noisy[best] ?: handValue(hand, best.suit)
|
||||
} else {
|
||||
best = plain.maxByOrNull { handValue(hand, it.suit) } ?: callable.first()
|
||||
bestValue = handValue(hand, best.suit)
|
||||
}
|
||||
|
||||
val bidThreshold = profile.bolder(BID_THRESHOLD, -3.0)
|
||||
var aloneThreshold = profile.bolder(ALONE_THRESHOLD, -3.0)
|
||||
if (profile.level == AiLevel.EASY) aloneThreshold += 4.0
|
||||
|
||||
val alone = callable.firstOrNull { it.alone && it.suit == best.suit }
|
||||
if (alone != null && bestValue >= difficulty.aloneThreshold) return alone
|
||||
if (bestValue >= difficulty.bidThreshold) return best
|
||||
val alonePlausible = profile.level != AiLevel.HARD || soundAlone(hand, best.suit)
|
||||
if (alone != null && bestValue >= aloneThreshold && alonePlausible) return alone
|
||||
if (bestValue >= bidThreshold) return best
|
||||
// Stuck as dealer with a bad hand: name the least-bad suit anyway.
|
||||
return if (legal.contains(Pass)) Pass else best
|
||||
}
|
||||
|
||||
private fun chooseDiscard(state: EuchreState, legal: List<EuchreMove>): EuchreMove {
|
||||
private fun chooseDiscard(
|
||||
state: EuchreState,
|
||||
legal: List<EuchreMove>,
|
||||
profile: AiProfile,
|
||||
): EuchreMove {
|
||||
val trump = requireNotNull(state.trump)
|
||||
val discards = legal.filterIsInstance<Discard>()
|
||||
// Prefer to void a side suit entirely; otherwise throw the lowest card.
|
||||
val hand = state.hands[state.dealer]
|
||||
val suitCounts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount()
|
||||
return discards.minByOrNull { discard ->
|
||||
fun keepScore(discard: Discard): Int {
|
||||
val card = discard.card
|
||||
val suit = effectiveSuit(card, trump)
|
||||
val trumpPenalty = if (suit == trump) 1000 else 0
|
||||
val singletonBonus = if (suitCounts[suit] == 1 && card.rank != Rank.ACE) -20 else 0
|
||||
trumpPenalty + singletonBonus + card.rank.order
|
||||
} ?: discards.first()
|
||||
return trumpPenalty + singletonBonus + card.rank.order
|
||||
}
|
||||
return when (profile.level) {
|
||||
AiLevel.HARD -> discards
|
||||
.sortedWith(
|
||||
compareByDescending<Discard> { handValue(hand - it.card, trump) }
|
||||
.thenBy { keepScore(it) },
|
||||
)
|
||||
.first()
|
||||
AiLevel.EASY -> profile.pick(discards.sortedBy { keepScore(it) })
|
||||
AiLevel.MEDIUM -> discards.minByOrNull { keepScore(it) } ?: discards.first()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -144,12 +234,40 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
state: EuchreState,
|
||||
seat: Int,
|
||||
legal: List<EuchreMove>,
|
||||
profile: AiProfile,
|
||||
seen: Set<Card>,
|
||||
): EuchreMove {
|
||||
val trump = requireNotNull(state.trump)
|
||||
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||
if (cards.size == 1) return PlayCard(cards.first())
|
||||
|
||||
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, trump))
|
||||
val maker = state.maker
|
||||
val defending = maker != null && teamOf(maker) != teamOf(seat)
|
||||
val makerSide = maker != null && teamOf(maker) == teamOf(seat)
|
||||
// A spoiler on the defence leads trump to cut the maker's trump down.
|
||||
val spiteful = defending && profile.style.spite >= 0.5
|
||||
|
||||
val best = if (profile.level == AiLevel.HARD) {
|
||||
hardPlay(state, seat, cards, trump, seen, makerSide, spiteful)
|
||||
} else {
|
||||
standardPlay(state, seat, cards, trump, spiteful, profile)
|
||||
}
|
||||
if (profile.level != AiLevel.EASY) return PlayCard(best)
|
||||
|
||||
// Easy sometimes burns a high card it should have kept.
|
||||
val ranked = listOf(best) + (cards - best).sortedByDescending { trumpStrength(it, trump) }
|
||||
return PlayCard(profile.pick(ranked))
|
||||
}
|
||||
|
||||
private fun standardPlay(
|
||||
state: EuchreState,
|
||||
seat: Int,
|
||||
cards: List<Card>,
|
||||
trump: Suit,
|
||||
spiteful: Boolean,
|
||||
profile: AiProfile,
|
||||
): Card {
|
||||
if (state.trick.isEmpty()) return chooseLead(cards, trump, spiteful)
|
||||
|
||||
val ledSuit = effectiveSuit(state.trick.first().card, trump)
|
||||
val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
|
||||
@@ -157,22 +275,120 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
val lastToPlay = state.trick.size == state.activeSeatCount - 1
|
||||
|
||||
// Partner already has it and we are last: never waste a good card.
|
||||
if (partnerWinning && lastToPlay) return PlayCard(lowest(cards, trump, ledSuit))
|
||||
// (Easy sometimes forgets and overtakes anyway.)
|
||||
if (partnerWinning && lastToPlay && !profile.slips()) {
|
||||
return lowest(cards, trump, ledSuit)
|
||||
}
|
||||
|
||||
val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit)
|
||||
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
|
||||
return when {
|
||||
// Take it as cheaply as we can.
|
||||
winners.isNotEmpty() && (!partnerWinning || !lastToPlay) ->
|
||||
PlayCard(winners.minBy { trickStrength(it, trump, ledSuit) })
|
||||
winners.isNotEmpty() && (!partnerWinning || !lastToPlay || profile.level == AiLevel.EASY) ->
|
||||
winners.minBy { trickStrength(it, trump, ledSuit) }
|
||||
|
||||
else -> PlayCard(lowest(cards, trump, ledSuit))
|
||||
else -> lowest(cards, trump, ledSuit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseLead(cards: List<Card>, trump: Suit): Card {
|
||||
/**
|
||||
* The strong player's choice. It remembers which cards have gone, so it
|
||||
* knows when a card is the best one left, and it lets a partner who is
|
||||
* already safe take the trick.
|
||||
*/
|
||||
private fun hardPlay(
|
||||
state: EuchreState,
|
||||
seat: Int,
|
||||
cards: List<Card>,
|
||||
trump: Suit,
|
||||
seen: Set<Card>,
|
||||
makerSide: Boolean,
|
||||
spiteful: Boolean,
|
||||
): Card {
|
||||
val hand = state.hands[seat]
|
||||
// Cards that could still be in somebody else's hand.
|
||||
val unseen = Decks.euchre(state.options.deckSize).filter { it !in seen && it !in hand }
|
||||
val unseenTrump = unseen.count { isTrumpCard(it, trump) }
|
||||
|
||||
/** No unseen card of its suit beats it; trump is ignored for a side-suit card. */
|
||||
fun isBoss(card: Card): Boolean {
|
||||
val led = effectiveSuit(card, trump)
|
||||
val mine = trickStrength(card, trump, led)
|
||||
val cardIsTrump = isTrumpCard(card, trump)
|
||||
return unseen.none { other ->
|
||||
(cardIsTrump || !isTrumpCard(other, trump)) && trickStrength(other, trump, led) > mine
|
||||
}
|
||||
}
|
||||
|
||||
if (state.trick.isEmpty()) {
|
||||
val myTrump = cards.filter { isTrumpCard(it, trump) }
|
||||
val off = cards.filter { !isTrumpCard(it, trump) }
|
||||
val bossTrump = myTrump.filter { isBoss(it) }.maxByOrNull { trumpStrength(it, trump) }
|
||||
if (unseenTrump > 0 && bossTrump != null && (makerSide || myTrump.size >= 2)) return bossTrump
|
||||
if (unseenTrump > 0 && makerSide && myTrump.size >= 3) {
|
||||
return myTrump.maxBy { trumpStrength(it, trump) }
|
||||
}
|
||||
if (unseenTrump > 0 && spiteful && myTrump.size >= 2) {
|
||||
return myTrump.maxBy { trumpStrength(it, trump) }
|
||||
}
|
||||
val bossOff = off.filter { isBoss(it) }.maxByOrNull { it.rank.order }
|
||||
if (bossOff != null) return bossOff
|
||||
if (bossTrump != null) return bossTrump
|
||||
if (off.isEmpty()) return cards.maxBy { trumpStrength(it, trump) }
|
||||
if (makerSide) return chooseLead(cards, trump, false)
|
||||
// On the defence with nothing sure: lead low from the shortest suit,
|
||||
// which keeps the aces and sets up a ruff.
|
||||
val counts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount()
|
||||
return off.minWith(
|
||||
compareBy<Card>({ counts[effectiveSuit(it, trump)] ?: 0 }, { it.rank.order }),
|
||||
)
|
||||
}
|
||||
|
||||
val ledSuit = effectiveSuit(state.trick.first().card, trump)
|
||||
val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
|
||||
val partnerWinning = partnerOf(bestSoFar.seat) == seat
|
||||
val remaining = state.activeSeatCount - state.trick.size - 1
|
||||
val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit)
|
||||
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
|
||||
|
||||
fun safe(card: Card): Boolean =
|
||||
remaining == 0 ||
|
||||
unseen.none { trickStrength(it, trump, ledSuit) > trickStrength(card, trump, ledSuit) }
|
||||
|
||||
fun throwAway(): Card {
|
||||
// A card that neither follows nor trumps costs nothing; shed the
|
||||
// lowest one that is not a sure winner later.
|
||||
val junk = cards.filter { trickStrength(it, trump, ledSuit) == 0 }
|
||||
if (junk.isNotEmpty()) {
|
||||
return junk.minBy { it.rank.order + if (isBoss(it)) 100 else 0 }
|
||||
}
|
||||
return lowest(cards, trump, ledSuit)
|
||||
}
|
||||
|
||||
if (partnerWinning) {
|
||||
val partnerSecure = remaining == 0 ||
|
||||
unseen.none { trickStrength(it, trump, ledSuit) > bestStrength }
|
||||
if (partnerSecure) return throwAway()
|
||||
// Partner may yet be beaten: back them with a side-suit card, but do
|
||||
// not spend trump on top of their lead.
|
||||
val backing = winners.filter { !isTrumpCard(it, trump) }
|
||||
.minByOrNull { trickStrength(it, trump, ledSuit) }
|
||||
return backing ?: throwAway()
|
||||
}
|
||||
if (winners.isNotEmpty()) {
|
||||
val sure = winners.filter { safe(it) }
|
||||
return (sure.ifEmpty { winners }).minBy { trickStrength(it, trump, ledSuit) }
|
||||
}
|
||||
return throwAway()
|
||||
}
|
||||
|
||||
private fun chooseLead(cards: List<Card>, trump: Suit, spiteTrump: Boolean): Card {
|
||||
// Right bower up front pulls trump and protects the left.
|
||||
cards.firstOrNull { isRightBower(it, trump) }?.let { return it }
|
||||
if (spiteTrump) {
|
||||
val trumps = cards.filter { isTrumpCard(it, trump) }
|
||||
if (trumps.size >= 2) return trumps.maxBy { trumpStrength(it, trump) }
|
||||
}
|
||||
// Off-suit aces are most likely to survive early.
|
||||
cards.filter { !isTrumpCard(it, trump) && it.rank == Rank.ACE }
|
||||
.maxByOrNull { it.rank.order }
|
||||
|
||||
@@ -1,49 +1,78 @@
|
||||
package org.prolibertate.games.game.golf
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic Golf opponent.
|
||||
* A Golf opponent whose strength and temperament come from the seat's profile.
|
||||
*
|
||||
* It takes a card off the discard pile when it is cheap or completes a column,
|
||||
* replaces its worst known card, and turns cards over when it has nothing
|
||||
* better to do. It never peeks at a face-down card — including its own, which
|
||||
* it genuinely does not know.
|
||||
* MEDIUM (the default) takes a card off the discard pile when it is cheap or
|
||||
* completes a column, replaces its worst known card, and turns cards over when
|
||||
* it has nothing better to do. It never peeks at a face-down card — including
|
||||
* its own, which it genuinely does not know.
|
||||
*
|
||||
* EASY is pickier about the discard pile, closes the hole from a poor position
|
||||
* and sometimes makes a worse play than it saw. HARD keeps count of every card
|
||||
* it has seen (turned-over grids and the discard pile), values its unknown
|
||||
* cards at what is still unseen, and chooses each draw and placement by how
|
||||
* much it lowers its expected score, including the chance of completing a
|
||||
* column and whether closing the hole would leave it ahead.
|
||||
*/
|
||||
class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
class GolfAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<GolfState, GolfMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/** The tuning the standard plan runs on. */
|
||||
private class Params(
|
||||
/** Take a discard worth this or less rather than gambling on the stock. */
|
||||
val discardAppetite: Int,
|
||||
/** Only close the hole out when the visible score is at or under this. */
|
||||
val closeOutBelow: Int,
|
||||
) {
|
||||
EASY(discardAppetite = 2, closeOutBelow = 30),
|
||||
NORMAL(discardAppetite = 5, closeOutBelow = 16),
|
||||
CAUTIOUS(discardAppetite = 7, closeOutBelow = 12),
|
||||
}
|
||||
)
|
||||
|
||||
override fun chooseMove(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
GolfPhase.SETUP -> chooseReveal(state, seat, legal)
|
||||
GolfPhase.DRAW -> chooseDraw(state, seat, legal)
|
||||
GolfPhase.PLACE -> choosePlace(state, seat, legal)
|
||||
val profile = profiles.of(seat)
|
||||
val move = when (state.phase) {
|
||||
GolfPhase.SETUP -> chooseReveal(state, seat, legal, profile)
|
||||
GolfPhase.DRAW -> chooseDraw(state, seat, legal, profile)
|
||||
GolfPhase.PLACE -> choosePlace(state, seat, legal, profile)
|
||||
else -> legal.first()
|
||||
}
|
||||
return if (move in legal) move else legal.first()
|
||||
}
|
||||
|
||||
private fun paramsFor(level: AiLevel): Params = when (level) {
|
||||
AiLevel.EASY -> EASY
|
||||
else -> NORMAL
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Setup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opening reveals are spread across columns. Two cards in the same column
|
||||
* tell you whether that column can be cancelled but nothing about the rest
|
||||
* of the board; one in each column leaves more of the grid legible.
|
||||
*/
|
||||
private fun chooseReveal(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
|
||||
private fun chooseReveal(
|
||||
state: GolfState,
|
||||
seat: Int,
|
||||
legal: List<GolfMove>,
|
||||
profile: AiProfile,
|
||||
): GolfMove {
|
||||
val reveals = legal.filterIsInstance<RevealCard>()
|
||||
if (reveals.isEmpty()) return legal.first()
|
||||
val options = state.options
|
||||
|
||||
// A distracted player turns over whatever is nearest.
|
||||
if (profile.slips()) return reveals[profile.random.nextInt(reveals.size)]
|
||||
|
||||
val untouchedColumn = reveals.firstOrNull { move ->
|
||||
val column = move.index % options.cols
|
||||
(0 until options.rows).none { row ->
|
||||
@@ -53,17 +82,61 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
return untouchedColumn ?: reveals.first()
|
||||
}
|
||||
|
||||
private fun chooseDraw(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
|
||||
// -----------------------------------------------------------------------
|
||||
// Drawing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseDraw(
|
||||
state: GolfState,
|
||||
seat: Int,
|
||||
legal: List<GolfMove>,
|
||||
profile: AiProfile,
|
||||
): GolfMove {
|
||||
if (profile.level == AiLevel.HARD) return hardDraw(state, seat, legal, profile)
|
||||
|
||||
val params = paramsFor(profile.level)
|
||||
val top = state.discard.lastOrNull()
|
||||
var take = false
|
||||
if (top != null && legal.contains(DrawFromDiscard)) {
|
||||
val worthTaking = golfValue(top) <= difficulty.discardAppetite ||
|
||||
take = golfValue(top) <= params.discardAppetite ||
|
||||
completesColumn(state, seat, top) != null
|
||||
if (worthTaking) return DrawFromDiscard
|
||||
}
|
||||
// Easy sometimes flips its own decision.
|
||||
if (profile.slips() && legal.contains(DrawFromDiscard) && legal.contains(DrawFromStock)) take = !take
|
||||
if (take && legal.contains(DrawFromDiscard)) return DrawFromDiscard
|
||||
return if (legal.contains(DrawFromStock)) DrawFromStock else legal.first()
|
||||
}
|
||||
|
||||
private fun choosePlace(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
|
||||
// -----------------------------------------------------------------------
|
||||
// Placing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlace(
|
||||
state: GolfState,
|
||||
seat: Int,
|
||||
legal: List<GolfMove>,
|
||||
profile: AiProfile,
|
||||
): GolfMove {
|
||||
if (profile.level == AiLevel.HARD) return hardPlace(state, seat, legal, profile)
|
||||
|
||||
val best = standardPlace(state, seat, legal, paramsFor(profile.level))
|
||||
if (profile.level != AiLevel.EASY || !profile.slips()) return best
|
||||
|
||||
// A lapse: turn something over or cover a face-down card instead.
|
||||
val alternatives = buildList<GolfMove> {
|
||||
addAll(legal.filterIsInstance<DiscardAndFlip>())
|
||||
addAll(legal.filterIsInstance<ReplaceCard>().filter { !state.revealed[seat][it.index] })
|
||||
}.filter { it != best }
|
||||
if (alternatives.isEmpty()) return best
|
||||
return alternatives[profile.random.nextInt(alternatives.size)]
|
||||
}
|
||||
|
||||
private fun standardPlace(
|
||||
state: GolfState,
|
||||
seat: Int,
|
||||
legal: List<GolfMove>,
|
||||
params: Params,
|
||||
): GolfMove {
|
||||
val drawn = state.drawn ?: return legal.first()
|
||||
val replacements = legal.filterIsInstance<ReplaceCard>()
|
||||
val flips = legal.filterIsInstance<DiscardAndFlip>()
|
||||
@@ -90,7 +163,7 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
val showing = state.grids[seat].indices
|
||||
.filter { state.revealed[seat][it] }
|
||||
.sumOf { golfValue(state.grids[seat][it]) }
|
||||
if (showing > difficulty.closeOutBelow) return DiscardOnly
|
||||
if (showing > params.closeOutBelow) return DiscardOnly
|
||||
}
|
||||
|
||||
// Nothing worth swapping: throw it and turn something over instead,
|
||||
@@ -106,7 +179,7 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
private fun completesColumn(
|
||||
state: GolfState,
|
||||
seat: Int,
|
||||
card: org.prolibertate.games.game.cards.Card,
|
||||
card: Card,
|
||||
): Int? {
|
||||
val options = state.options
|
||||
for (col in 0 until options.cols) {
|
||||
@@ -122,4 +195,229 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hard
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What this seat knows about the hole: the cards in every turned-over cell,
|
||||
* the discard pile and (on its own turn) the card in its hand. Everything
|
||||
* else — face-down cells and the stock — is valued at the average of the
|
||||
* cards not yet seen.
|
||||
*/
|
||||
private class Board(private val state: GolfState, seat: Int) {
|
||||
val options = state.options
|
||||
private val counts = HashMap<Rank, Int>()
|
||||
private val unseenTotal: Int
|
||||
val unknownValue: Double
|
||||
private val lines: List<List<Int>>
|
||||
|
||||
init {
|
||||
val copies = if (options.playerCount * options.gridSize > 24) 2 else 1
|
||||
Rank.standard.forEach { counts[it] = 4 * copies }
|
||||
fun see(card: Card) {
|
||||
counts[card.rank] = maxOf(0, (counts[card.rank] ?: 0) - 1)
|
||||
}
|
||||
state.grids.forEachIndexed { player, grid ->
|
||||
grid.forEachIndexed { index, card -> if (state.revealed[player][index]) see(card) }
|
||||
}
|
||||
state.discard.forEach { see(it) }
|
||||
if (seat == state.turn) state.drawn?.let { see(it) }
|
||||
|
||||
unseenTotal = counts.values.sum()
|
||||
unknownValue = if (unseenTotal == 0) {
|
||||
5.0
|
||||
} else {
|
||||
counts.entries.sumOf { (rank, n) -> n * valueOf(rank).toDouble() } / unseenTotal
|
||||
}
|
||||
|
||||
val columns = (0 until options.cols).map { col -> (0 until options.rows).map { it * options.cols + col } }
|
||||
val rows = if (options.rows == 3) {
|
||||
(0 until options.rows).map { row -> (0 until options.cols).map { row * options.cols + it } }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
lines = columns + rows
|
||||
}
|
||||
|
||||
fun valueOf(rank: Rank): Int = golfValue(Card(rank, Suit.SPADES))
|
||||
|
||||
private fun chanceOf(rank: Rank): Double =
|
||||
if (unseenTotal == 0) 0.0 else (counts[rank] ?: 0).toDouble() / unseenTotal
|
||||
|
||||
/** A seat's cells as far as this seat can see them; null is a face-down card. */
|
||||
fun cellsOf(player: Int): List<Card?> =
|
||||
state.grids[player].mapIndexed { index, card -> if (state.revealed[player][index]) card else null }
|
||||
|
||||
/** Expected score of a grid, counting the chance that an unknown cell completes a line. */
|
||||
fun expected(cells: List<Card?>): Double {
|
||||
val cancelled = BooleanArray(cells.size)
|
||||
for (line in lines) {
|
||||
val line0 = line.map { cells[it] }
|
||||
if (line0.all { it != null } && line0.map { it!!.rank }.distinct().size == 1) {
|
||||
line.forEach { cancelled[it] = true }
|
||||
}
|
||||
}
|
||||
var total = 0.0
|
||||
for (index in cells.indices) {
|
||||
if (cancelled[index]) continue
|
||||
val card = cells[index]
|
||||
total += if (card != null) golfValue(card).toDouble() else unknownValue
|
||||
}
|
||||
for (line in lines) {
|
||||
if (line.any { cancelled[it] }) continue
|
||||
val unknown = line.filter { cells[it] == null }
|
||||
if (unknown.size != 1) continue
|
||||
val known = line.mapNotNull { cells[it] }
|
||||
if (known.isEmpty() || known.map { it.rank }.distinct().size != 1) continue
|
||||
val rank = known.first().rank
|
||||
total -= chanceOf(rank) * (known.sumOf { golfValue(it) } + valueOf(rank))
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Unseen ranks and how many of each remain, for averaging over a stock draw. */
|
||||
fun unseenRanks(): List<Pair<Rank, Int>> = counts.entries.filter { it.value > 0 }.map { it.key to it.value }
|
||||
|
||||
/** How likely turning over [index] is to finish a line that already shows one rank. */
|
||||
fun matchHope(cells: List<Card?>, index: Int): Double {
|
||||
var best = 0.0
|
||||
for (line in lines) {
|
||||
if (index !in line) continue
|
||||
val others = line.filter { it != index }
|
||||
val known = others.mapNotNull { cells[it] }
|
||||
if (known.size != others.size || known.isEmpty()) continue
|
||||
if (known.map { it.rank }.distinct().size != 1) continue
|
||||
val rank = known.first().rank
|
||||
best = maxOf(best, chanceOf(rank) * (known.sumOf { golfValue(it) } + valueOf(rank)))
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
private fun effectiveBoldness(state: GolfState, seat: Int, profile: AiProfile): Double {
|
||||
// Well behind late in the match: take more chances.
|
||||
val holesLeft = state.options.holes - state.hole - 1
|
||||
val best = (0 until state.options.playerCount).filter { it != seat }.minOf { state.scores[it] }
|
||||
val behind = state.scores[seat] - best
|
||||
return profile.style.boldness + if (holesLeft <= 2 && behind > 8) 0.3 else 0.0
|
||||
}
|
||||
|
||||
/** Scores one way of using the card in hand: lower grid score, more information, less risk. */
|
||||
private class Planner(
|
||||
val state: GolfState,
|
||||
val seat: Int,
|
||||
val board: Board,
|
||||
val boldness: Double,
|
||||
val spite: Double,
|
||||
) {
|
||||
private val cells = board.cellsOf(seat)
|
||||
private val before = board.expected(cells)
|
||||
private val hidden = cells.count { it == null }
|
||||
private val finalTurn = state.closedBy != null
|
||||
private val rivalBest: Double = (0 until state.options.playerCount)
|
||||
.filter { it != seat }
|
||||
.minOfOrNull { board.expected(board.cellsOf(it)) } ?: before
|
||||
|
||||
private fun closePenalty(after: Double): Double {
|
||||
if (finalTurn) return 0.0
|
||||
val margin = 2.0 - 2.0 * boldness
|
||||
return maxOf(0.0, after - (rivalBest - margin)) * 0.8
|
||||
}
|
||||
|
||||
private fun handoff(drawn: Card): Double =
|
||||
maxOf(0.0, 3.0 - golfValue(drawn)) * 0.15 * (1.0 + spite)
|
||||
|
||||
private fun flipBonus(index: Int): Double =
|
||||
if (finalTurn || hidden <= 1) 0.0 else 0.35 + 0.5 * board.matchHope(cells, index)
|
||||
|
||||
/** Utility of [move] with [drawn] in hand; higher is better. */
|
||||
fun utility(move: GolfMove, drawn: Card): Double = when (move) {
|
||||
is ReplaceCard -> {
|
||||
val after = cells.toMutableList().also { it[move.index] = drawn }
|
||||
val closes = hidden == 1 && cells[move.index] == null
|
||||
val afterScore = board.expected(after)
|
||||
before - afterScore - if (closes) closePenalty(afterScore) else 0.0
|
||||
}
|
||||
|
||||
is DiscardAndFlip -> {
|
||||
val closes = hidden == 1
|
||||
flipBonus(move.index) - handoff(drawn) - if (closes) closePenalty(before) else 0.0
|
||||
}
|
||||
|
||||
is DiscardOnly -> -handoff(drawn)
|
||||
else -> Double.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
/** The moves open to a card in hand, standing in for the legal list during look-ahead. */
|
||||
fun candidates(fromDiscard: Boolean, putt: Boolean): List<GolfMove> = buildList {
|
||||
for (index in cells.indices) add(ReplaceCard(index))
|
||||
if (!fromDiscard) {
|
||||
for (index in cells.indices) if (cells[index] == null) add(DiscardAndFlip(index))
|
||||
if (putt && hidden == 1) add(DiscardOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun planner(state: GolfState, seat: Int, profile: AiProfile): Planner {
|
||||
val board = Board(state, seat)
|
||||
return Planner(state, seat, board, effectiveBoldness(state, seat, profile), profile.style.spite)
|
||||
}
|
||||
|
||||
private fun hardDraw(
|
||||
state: GolfState,
|
||||
seat: Int,
|
||||
legal: List<GolfMove>,
|
||||
profile: AiProfile,
|
||||
): GolfMove {
|
||||
val top = state.discard.lastOrNull()
|
||||
val canStock = legal.contains(DrawFromStock)
|
||||
val canDiscard = legal.contains(DrawFromDiscard) && top != null
|
||||
if (!canDiscard) return if (canStock) DrawFromStock else legal.first()
|
||||
if (!canStock) return DrawFromDiscard
|
||||
|
||||
val plan = planner(state, seat, profile)
|
||||
val putt = state.options.lineUpFinalPutt
|
||||
|
||||
// The discard has to be used, so it is only as good as its best replacement.
|
||||
val takeUtility = plan.candidates(fromDiscard = true, putt = putt)
|
||||
.maxOf { plan.utility(it, top!!) } +
|
||||
profile.style.spite * 0.3 * maxOf(0, 4 - golfValue(top!!))
|
||||
|
||||
// The stock is a gamble on the average of what is left, but can be thrown back.
|
||||
val ranks = plan.board.unseenRanks()
|
||||
val total = ranks.sumOf { it.second }
|
||||
val stockUtility = if (total == 0) {
|
||||
Double.NEGATIVE_INFINITY
|
||||
} else {
|
||||
val moves = plan.candidates(fromDiscard = false, putt = putt)
|
||||
ranks.sumOf { (rank, n) ->
|
||||
val sample = Card(rank, Suit.SPADES)
|
||||
n * moves.maxOf { plan.utility(it, sample) }
|
||||
} / total
|
||||
}
|
||||
|
||||
// A bold seat wants a clear edge before it passes up the unknown card.
|
||||
val take = takeUtility - plan.boldness * 0.4 > stockUtility
|
||||
return if (take) DrawFromDiscard else DrawFromStock
|
||||
}
|
||||
|
||||
private fun hardPlace(
|
||||
state: GolfState,
|
||||
seat: Int,
|
||||
legal: List<GolfMove>,
|
||||
profile: AiProfile,
|
||||
): GolfMove {
|
||||
val drawn = state.drawn ?: return legal.first()
|
||||
val plan = planner(state, seat, profile)
|
||||
val options = legal.filter { it is ReplaceCard || it is DiscardAndFlip || it is DiscardOnly }
|
||||
if (options.isEmpty()) return legal.first()
|
||||
return options.maxBy { plan.utility(it, drawn) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val EASY = Params(discardAppetite = 2, closeOutBelow = 30)
|
||||
private val NORMAL = Params(discardAppetite = 5, closeOutBelow = 16)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,35 +3,98 @@ package org.prolibertate.games.game.hearts
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A cautious Hearts player.
|
||||
* A Hearts player whose strength and temperament come from the seat's profile.
|
||||
*
|
||||
* It plays to avoid points rather than to shoot the moon: it sheds the queen of
|
||||
* spades and the guards above her at the first opportunity, ducks under whoever
|
||||
* is winning a trick when it can, and takes a trick as cheaply as possible when
|
||||
* it cannot get out of the way. That is enough to make it an opponent worth
|
||||
* beating without pretending to read the table.
|
||||
* MEDIUM (the default) avoids points rather than shooting the moon: it sheds
|
||||
* the queen of spades and the guards above her at the first opportunity, ducks
|
||||
* under whoever is winning a trick when it can, and takes a trick as cheaply as
|
||||
* possible when it cannot get out of the way.
|
||||
*
|
||||
* EASY plays the same plan but sometimes settles for a worse card in each
|
||||
* decision. HARD counts the penalty cards and spades that have been taken,
|
||||
* leads by risk, sheds by what is still dangerous, shoots the moon when its
|
||||
* hand and the table allow it and tries to break a moon somebody else is on.
|
||||
* It only uses its own hand and what the table has shown.
|
||||
*/
|
||||
object HeartsAi : GameAi<HeartsState, HeartsMove> {
|
||||
class HeartsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<HeartsState, HeartsMove> {
|
||||
|
||||
override fun chooseMove(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
HeartsPhase.PASSING -> choosePass(state, seat, legal)
|
||||
else -> choosePlay(state, seat, legal)
|
||||
val profile = profiles.of(seat)
|
||||
val move = when (state.phase) {
|
||||
HeartsPhase.PASSING -> choosePass(state, seat, legal, profile)
|
||||
else -> choosePlay(state, seat, legal, profile)
|
||||
}
|
||||
return if (move in legal) move else legal.first()
|
||||
}
|
||||
|
||||
private fun choosePass(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||
// -----------------------------------------------------------------------
|
||||
// Passing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePass(
|
||||
state: HeartsState,
|
||||
seat: Int,
|
||||
legal: List<HeartsMove>,
|
||||
profile: AiProfile,
|
||||
): HeartsMove {
|
||||
val hand = state.hands[seat]
|
||||
val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
|
||||
val wanted: Set<Card> = when (profile.level) {
|
||||
AiLevel.MEDIUM -> hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
|
||||
AiLevel.EASY -> easyPass(hand, profile)
|
||||
AiLevel.HARD -> hardPass(state, hand, profile)
|
||||
}
|
||||
// Prefer the exact bundle the heuristic asked for, but fall back rather
|
||||
// than fail: the caller's list is what the host will validate against.
|
||||
return legal.firstOrNull { it is PassCards && it.cards.toSet() == wanted } ?: legal.first()
|
||||
}
|
||||
|
||||
private fun easyPass(hand: List<Card>, profile: AiProfile): Set<Card> {
|
||||
val ranked = hand.sortedByDescending { passDanger(it, hand) }
|
||||
val chosen = ranked.take(PASS_SIZE).toMutableList()
|
||||
if (profile.slips()) {
|
||||
// Keeps one of the cards it should have sent.
|
||||
val spare = ranked.drop(PASS_SIZE).take(4)
|
||||
if (spare.isNotEmpty()) {
|
||||
chosen[profile.random.nextInt(chosen.size)] = spare[profile.random.nextInt(spare.size)]
|
||||
}
|
||||
}
|
||||
return chosen.toSet()
|
||||
}
|
||||
|
||||
private fun hardPass(state: HeartsState, hand: List<Card>, profile: AiProfile): Set<Card> {
|
||||
if (state.options.allowShootTheMoon && moonHand(hand, profile)) {
|
||||
// Keep every high card and heart, send the three smallest.
|
||||
val junk = hand.filter { !(it.suit == Suit.HEARTS && it.rank.order >= 10) }
|
||||
.sortedBy { it.rank.order }
|
||||
if (junk.size >= PASS_SIZE) return junk.take(PASS_SIZE).toSet()
|
||||
}
|
||||
return hand.sortedByDescending { passDanger(it, hand) + shortSuitBonus(it, hand) }
|
||||
.take(PASS_SIZE).toSet()
|
||||
}
|
||||
|
||||
/** Sending away the last cards of a short suit makes a void, which is worth a lot when discarding. */
|
||||
private fun shortSuitBonus(card: Card, hand: List<Card>): Int {
|
||||
if (card.suit == Suit.HEARTS || card.suit == Suit.SPADES) return 0
|
||||
val length = hand.count { it.suit == card.suit }
|
||||
return if (length > 3) 0 else (4 - length) * 8
|
||||
}
|
||||
|
||||
/** A hand worth trying to take all twenty-six with: lots of high cards and a heart suit to run. */
|
||||
private fun moonHand(hand: List<Card>, profile: AiProfile): Boolean {
|
||||
val hearts = hand.count { it.suit == Suit.HEARTS }
|
||||
if (hearts < 4) return false
|
||||
val strength = hand.count { it.rank.order >= Rank.QUEEN.order } +
|
||||
hand.count { it.suit == Suit.HEARTS && it.rank.order >= 9 }
|
||||
return strength >= profile.bolder(8.0, -2.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* How much a card wants to be somebody else's problem.
|
||||
*
|
||||
@@ -52,27 +115,41 @@ object HeartsAi : GameAi<HeartsState, HeartsMove> {
|
||||
}
|
||||
}
|
||||
|
||||
private fun choosePlay(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||
// -----------------------------------------------------------------------
|
||||
// Playing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlay(
|
||||
state: HeartsState,
|
||||
seat: Int,
|
||||
legal: List<HeartsMove>,
|
||||
profile: AiProfile,
|
||||
): HeartsMove {
|
||||
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||
if (cards.isEmpty()) return legal.first()
|
||||
val chosen = when {
|
||||
state.trick.isEmpty() -> lead(cards)
|
||||
else -> follow(state, cards)
|
||||
val chosen = when (profile.level) {
|
||||
AiLevel.MEDIUM -> standardRanking(state, cards).first()
|
||||
AiLevel.EASY -> profile.pick(standardRanking(state, cards))
|
||||
AiLevel.HARD -> hardPlay(state, seat, cards, profile)
|
||||
}
|
||||
return PlayCard(chosen)
|
||||
return PlayCard(if (chosen in cards) chosen else cards.first())
|
||||
}
|
||||
|
||||
/** The standard plan's preferences, best first. */
|
||||
private fun standardRanking(state: HeartsState, cards: List<Card>): List<Card> =
|
||||
if (state.trick.isEmpty()) rankedLead(cards) else rankedFollow(state, cards)
|
||||
|
||||
/**
|
||||
* Leads low, and keeps off spades while the queen is still unaccounted for —
|
||||
* leading a spade under her is how a seat ends up holding her.
|
||||
*/
|
||||
private fun lead(cards: List<Card>): Card {
|
||||
val offSpades = cards.filter { it.suit != Suit.SPADES }
|
||||
val pool = if (offSpades.isNotEmpty()) offSpades else cards
|
||||
return pool.minBy { it.rank.order }
|
||||
private fun rankedLead(cards: List<Card>): List<Card> {
|
||||
val offSpades = cards.filter { it.suit != Suit.SPADES }.sortedBy { it.rank.order }
|
||||
val spades = cards.filter { it.suit == Suit.SPADES }.sortedBy { it.rank.order }
|
||||
return offSpades + spades
|
||||
}
|
||||
|
||||
private fun follow(state: HeartsState, cards: List<Card>): Card {
|
||||
private fun rankedFollow(state: HeartsState, cards: List<Card>): List<Card> {
|
||||
val led = state.trick.first().card.suit
|
||||
val best = state.trick.maxOf { trickStrength(it.card, led) }
|
||||
val pot = pointsIn(state.trick.map { it.card })
|
||||
@@ -82,27 +159,189 @@ object HeartsAi : GameAi<HeartsState, HeartsMove> {
|
||||
if (ducking.isNotEmpty()) {
|
||||
// Safe under the current winner, so shed the most expensive card
|
||||
// that still loses.
|
||||
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
|
||||
val rest = (cards - ducking.toSet()).sortedBy { it.rank.order }
|
||||
return ducking.sortedByDescending { it.rank.order + pointsOf(it) * 2 } + rest
|
||||
}
|
||||
|
||||
val void = cards.none { it.suit == led }
|
||||
if (void) return discard(cards)
|
||||
|
||||
// Every card left would take the trick. Playing last with nothing in the
|
||||
// pot means it costs nothing, so there is no reason to waste a high card
|
||||
// on it; otherwise take it as cheaply as possible.
|
||||
return if (last && pot == 0) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
|
||||
return if (last && pot == 0) {
|
||||
cards.sortedByDescending { it.rank.order }
|
||||
} else {
|
||||
cards.sortedBy { it.rank.order }
|
||||
}
|
||||
}
|
||||
|
||||
/** Void in the led suit and free to throw anything, so throw the worst of it. */
|
||||
private fun discard(cards: List<Card>): Card {
|
||||
cards.firstOrNull { it == QUEEN_OF_SPADES }?.let { return it }
|
||||
cards.filter { it.suit == Suit.SPADES && it.rank.order > Rank.QUEEN.order }
|
||||
.maxByOrNull { it.rank.order }
|
||||
?.let { return it }
|
||||
cards.filter { it.suit == Suit.HEARTS }
|
||||
.maxByOrNull { it.rank.order }
|
||||
?.let { return it }
|
||||
return cards.maxBy { it.rank.order }
|
||||
// -----------------------------------------------------------------------
|
||||
// Hard play
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What this seat can legitimately know: its hand, and every card taken or on the table. */
|
||||
private class Memory(state: HeartsState, seat: Int) {
|
||||
val hand: List<Card> = state.hands[seat]
|
||||
val unseen: List<Card>
|
||||
|
||||
init {
|
||||
val seen = HashSet<Card>()
|
||||
state.taken.forEach { seen.addAll(it) }
|
||||
state.trick.forEach { seen.add(it.card) }
|
||||
seen.addAll(hand)
|
||||
unseen = FULL_DECK.filter { it !in seen }
|
||||
}
|
||||
|
||||
val queenUnseen: Boolean get() = QUEEN_OF_SPADES in unseen
|
||||
|
||||
fun unseenIn(suit: Suit): Int = unseen.count { it.suit == suit }
|
||||
|
||||
fun unseenHigher(card: Card): Int =
|
||||
unseen.count { it.suit == card.suit && it.rank.order > card.rank.order }
|
||||
}
|
||||
|
||||
private fun hardPlay(state: HeartsState, seat: Int, cards: List<Card>, profile: AiProfile): Card {
|
||||
val mem = Memory(state, seat)
|
||||
if (isShooting(state, seat, mem, profile)) return shootPlay(state, cards, mem)
|
||||
return if (state.trick.isEmpty()) {
|
||||
hardLead(cards, mem, profile)
|
||||
} else {
|
||||
hardFollow(state, seat, cards, mem, profile)
|
||||
}
|
||||
}
|
||||
|
||||
/** Holding every point so far, with a hand that can keep winning. */
|
||||
private fun isShooting(state: HeartsState, seat: Int, mem: Memory, profile: AiProfile): Boolean {
|
||||
if (!state.options.allowShootTheMoon) return false
|
||||
val all = pointsIn(state.taken.flatten())
|
||||
val mine = pointsIn(state.taken[seat])
|
||||
if (mine != all) return false
|
||||
if (mine == 0 && !moonHand(mem.hand, profile)) return false
|
||||
if (mem.hand.isEmpty()) return false
|
||||
val masters = mem.hand.count { mem.unseenHigher(it) == 0 }
|
||||
return masters.toDouble() / mem.hand.size >= profile.bolder(0.6, -0.15)
|
||||
}
|
||||
|
||||
private fun shootPlay(state: HeartsState, cards: List<Card>, mem: Memory): Card {
|
||||
if (state.trick.isEmpty()) {
|
||||
val masters = cards.filter { mem.unseenHigher(it) == 0 }
|
||||
if (masters.isNotEmpty()) {
|
||||
return masters.sortedWith(
|
||||
compareBy<Card> { it.suit == Suit.HEARTS }.thenByDescending { it.rank.order },
|
||||
).first()
|
||||
}
|
||||
return cards.maxBy { it.rank.order }
|
||||
}
|
||||
val led = state.trick.first().card.suit
|
||||
val best = state.trick.maxOf { trickStrength(it.card, led) }
|
||||
val remaining = state.options.playerCount - state.trick.size - 1
|
||||
val winning = cards.filter { trickStrength(it, led) > best }
|
||||
if (winning.isNotEmpty()) {
|
||||
if (remaining == 0) return winning.minBy { it.rank.order }
|
||||
return winning.filter { mem.unseenHigher(it) == 0 }.minByOrNull { it.rank.order }
|
||||
?: winning.maxBy { it.rank.order }
|
||||
}
|
||||
// Cannot win this one: lose it with a card that is not a point.
|
||||
return cards.minBy { pointsOf(it) * 100 + it.rank.order }
|
||||
}
|
||||
|
||||
/** Another seat that has taken every penalty card so far and looks set to run the table. */
|
||||
private fun moonThreat(state: HeartsState, seat: Int): Int? {
|
||||
if (!state.options.allowShootTheMoon) return null
|
||||
val all = pointsIn(state.taken.flatten())
|
||||
if (all < 14 && !(state.trickNumber >= 6 && all >= 8)) return null
|
||||
return (0 until state.options.playerCount).firstOrNull {
|
||||
it != seat && pointsIn(state.taken[it]) == all
|
||||
}
|
||||
}
|
||||
|
||||
private fun scoreLeader(state: HeartsState, seat: Int): Int? =
|
||||
(0 until state.options.playerCount).filter { it != seat }.minByOrNull { state.scores[it] }
|
||||
|
||||
private fun hardLead(cards: List<Card>, mem: Memory, profile: AiProfile): Card {
|
||||
// A cautious seat weighs a trick it might win more heavily than a bold one.
|
||||
val weight = 1.0 - 0.25 * profile.style.boldness
|
||||
|
||||
fun risk(card: Card): Double {
|
||||
val others = mem.unseenIn(card.suit)
|
||||
val higher = mem.unseenHigher(card)
|
||||
val winChance = if (others == 0) 1.0 else Math.pow(1.0 - higher.toDouble() / others, 3.0)
|
||||
var points = 0.5
|
||||
if (card.suit == Suit.HEARTS) points += 2.0
|
||||
if (card.suit == Suit.SPADES && mem.queenUnseen) points += 13 * 0.3
|
||||
// A suit nobody else holds lets everyone dump on whoever wins it.
|
||||
if (others == 0) points += 3.0
|
||||
val shedding = if (winChance < 0.15) 0.05 * card.rank.order else 0.0
|
||||
return winChance * points * weight - shedding
|
||||
}
|
||||
return cards.minBy { risk(it) }
|
||||
}
|
||||
|
||||
private fun hardFollow(
|
||||
state: HeartsState,
|
||||
seat: Int,
|
||||
cards: List<Card>,
|
||||
mem: Memory,
|
||||
profile: AiProfile,
|
||||
): Card {
|
||||
val trick = state.trick
|
||||
val led = trick.first().card.suit
|
||||
val winner = trick.maxBy { trickStrength(it.card, led) }
|
||||
val best = trickStrength(winner.card, led)
|
||||
val pot = pointsIn(trick.map { it.card })
|
||||
val remaining = state.options.playerCount - trick.size - 1
|
||||
val winning = cards.filter { trickStrength(it, led) > best }
|
||||
val ducking = cards.filter { trickStrength(it, led) < best }
|
||||
val threat = moonThreat(state, seat)
|
||||
|
||||
// Taking even a few points is far better than letting a moon through.
|
||||
if (threat != null && pot > 0 && winning.isNotEmpty()) {
|
||||
return winning.minBy { it.rank.order }
|
||||
}
|
||||
|
||||
val void = cards.none { it.suit == led }
|
||||
if (void) return hardDischarge(state, seat, cards, mem, profile, winner.seat, threat)
|
||||
|
||||
if (ducking.isNotEmpty()) {
|
||||
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
|
||||
}
|
||||
// Forced to win. Last to play, the trick is taken whatever is played,
|
||||
// so spend the highest card; otherwise win as cheaply as possible.
|
||||
return if (remaining == 0) {
|
||||
cards.maxBy { it.rank.order }
|
||||
} else {
|
||||
cards.minBy { it.rank.order }
|
||||
}
|
||||
}
|
||||
|
||||
/** Void in the led suit: throw away whatever is still dangerous to hold. */
|
||||
private fun hardDischarge(
|
||||
state: HeartsState,
|
||||
seat: Int,
|
||||
cards: List<Card>,
|
||||
mem: Memory,
|
||||
profile: AiProfile,
|
||||
winnerSeat: Int,
|
||||
threat: Int?,
|
||||
): Card {
|
||||
val spoil = profile.style.spite >= 0.5 && winnerSeat == scoreLeader(state, seat)
|
||||
|
||||
fun score(card: Card): Double {
|
||||
var s: Double = when {
|
||||
card == QUEEN_OF_SPADES -> 1000.0
|
||||
else -> card.rank.order.toDouble()
|
||||
}
|
||||
// The ace and king of spades only matter while the queen is out there.
|
||||
if (card.suit == Suit.SPADES && card.rank.order > Rank.QUEEN.order && mem.queenUnseen) s += 200
|
||||
if (card.suit == Suit.HEARTS) s += 100
|
||||
if (pointsOf(card) == 0 && mem.hand.count { it.suit == card.suit } <= 2) s += 10
|
||||
if (spoil && pointsOf(card) > 0) s += 300
|
||||
// Do not feed a seat that is running for the moon.
|
||||
if (threat != null && winnerSeat == threat && pointsOf(card) > 0) s -= 2000
|
||||
return s
|
||||
}
|
||||
return cards.maxBy { score(it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FULL_DECK: List<Card> = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package org.prolibertate.games.game.kaiser
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -12,25 +16,51 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* in play chases the five of hearts while trying not to be the one holding the
|
||||
* trick the three of spades lands in.
|
||||
*/
|
||||
class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
class KaiserAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<KaiserState, KaiserMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
private companion object {
|
||||
/** Hand strength needed before bidding above the floor. */
|
||||
val bidThreshold: Int,
|
||||
) {
|
||||
EASY(bidThreshold = 12),
|
||||
NORMAL(bidThreshold = 16),
|
||||
CAUTIOUS(bidThreshold = 19),
|
||||
const val BID_THRESHOLD = 16.0
|
||||
const val EASY_BID_THRESHOLD = 15.0
|
||||
}
|
||||
|
||||
/** Cards this AI has watched played in the current hand, per seat it plays. */
|
||||
private val memory = HashMap<Int, SeenCards>()
|
||||
|
||||
private class SeenCards {
|
||||
var key: Long = Long.MIN_VALUE
|
||||
val cards = HashSet<Card>()
|
||||
}
|
||||
|
||||
/** Public information only: cards that have appeared on the table this hand. */
|
||||
private fun observe(state: KaiserState, seat: Int): Set<Card> = synchronized(memory) {
|
||||
val seen = memory.getOrPut(seat) { SeenCards() }
|
||||
val key = state.seed * 1_000_003L + state.handNumber
|
||||
if (key != seen.key || state.phase == KaiserPhase.BIDDING) {
|
||||
seen.key = key
|
||||
seen.cards.clear()
|
||||
}
|
||||
state.trick.forEach { seen.cards.add(it.card) }
|
||||
state.completedTrick.forEach { seen.cards.add(it.card) }
|
||||
seen.cards.removeAll(state.hands[seat].toSet())
|
||||
seen.cards.toSet()
|
||||
}
|
||||
|
||||
override fun chooseMove(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
KaiserPhase.BIDDING -> chooseBid(state, seat, legal)
|
||||
KaiserPhase.PLAYING -> choosePlay(state, seat, legal)
|
||||
else -> legal.first()
|
||||
val profile = profiles.of(seat)
|
||||
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
|
||||
val move = try {
|
||||
when (state.phase) {
|
||||
KaiserPhase.BIDDING -> chooseBid(state, seat, legal, profile)
|
||||
KaiserPhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
|
||||
else -> legal.first()
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
legal.first()
|
||||
}
|
||||
return if (move in legal) move else legal.first()
|
||||
}
|
||||
|
||||
/** Trump length and top cards, plus a nod to holding the counters. */
|
||||
@@ -50,41 +80,137 @@ class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
return value
|
||||
}
|
||||
|
||||
private fun chooseBid(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||
/**
|
||||
* A finer rating on the same scale as [suitValue]: it rewards a run of top
|
||||
* trump, short side suits to ruff with, and only counts the five of hearts
|
||||
* when the hand can protect it.
|
||||
*/
|
||||
private fun hardValue(hand: List<Card>, trump: Suit): Int {
|
||||
val trumps = hand.filter { it.suit == trump }
|
||||
var value = trumps.size * 3
|
||||
for (card in trumps) {
|
||||
value += when (card.rank) {
|
||||
Rank.ACE -> 4
|
||||
Rank.KING -> 3
|
||||
Rank.QUEEN -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
// A king backed by the ace, or a queen backed by both, is a real trick.
|
||||
val ranks = trumps.map { it.rank.order }.toSet()
|
||||
if (Rank.ACE.order in ranks && Rank.KING.order in ranks) value += 2
|
||||
if (Rank.ACE.order in ranks && Rank.KING.order in ranks && Rank.QUEEN.order in ranks) value += 1
|
||||
|
||||
for (card in hand) {
|
||||
if (card.suit == trump) continue
|
||||
if (card.rank == Rank.ACE) value += 2
|
||||
else if (card.rank == Rank.KING && hand.any { it.suit == card.suit && it.rank == Rank.ACE }) value += 1
|
||||
}
|
||||
if (trumps.size >= 3) {
|
||||
value += Suit.entries.filter { it != trump }.count { suit -> hand.none { it.suit == suit } } * 2
|
||||
}
|
||||
if (hand.contains(FIVE_OF_HEARTS)) {
|
||||
val guarded = trump == Suit.HEARTS ||
|
||||
hand.any { it.suit == Suit.HEARTS && it.rank == Rank.ACE }
|
||||
value += if (guarded) 3 else -1
|
||||
}
|
||||
if (hand.contains(THREE_OF_SPADES)) value -= if (trump == Suit.SPADES) 1 else 2
|
||||
return value
|
||||
}
|
||||
|
||||
private fun chooseBid(
|
||||
state: KaiserState,
|
||||
seat: Int,
|
||||
legal: List<KaiserMove>,
|
||||
profile: AiProfile,
|
||||
): KaiserMove {
|
||||
val hand = state.hands[seat]
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val easy = profile.level == AiLevel.EASY
|
||||
|
||||
val naming = legal.filterIsInstance<NameTrump>()
|
||||
if (naming.isNotEmpty()) {
|
||||
val hand = state.hands[seat]
|
||||
val best = naming
|
||||
.filter { it.suit != null }
|
||||
.maxByOrNull { suitValue(hand, it.suit!!) }
|
||||
return best ?: naming.first()
|
||||
val named = naming.filter { it.suit != null }
|
||||
return when {
|
||||
hard -> named.maxByOrNull { hardValue(hand, it.suit!!) }
|
||||
easy -> {
|
||||
val noisy = named.associateWith { suitValue(hand, it.suit!!) + profile.random.nextInt(-4, 5) }
|
||||
named.sortedByDescending { noisy.getValue(it) }.let { if (it.isEmpty()) null else profile.pick(it) }
|
||||
}
|
||||
else -> named.maxByOrNull { suitValue(hand, it.suit!!) }
|
||||
} ?: naming.first()
|
||||
}
|
||||
|
||||
val hand = state.hands[seat]
|
||||
val bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
|
||||
val strength = suitValue(hand, bestSuit)
|
||||
val bestSuit: Suit
|
||||
val strength: Int
|
||||
if (hard) {
|
||||
bestSuit = Suit.entries.maxBy { hardValue(hand, it) }
|
||||
strength = hardValue(hand, bestSuit)
|
||||
} else {
|
||||
bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
|
||||
strength = suitValue(hand, bestSuit) + if (easy) profile.random.nextInt(-5, 6) else 0
|
||||
}
|
||||
|
||||
val bids = legal.filterIsInstance<MakeBid>().filter { !it.bid.noTrump }
|
||||
if (strength >= difficulty.bidThreshold && bids.isNotEmpty()) {
|
||||
var needed = profile.bolder(if (easy) EASY_BID_THRESHOLD else BID_THRESHOLD, -4.0)
|
||||
// A spoiler steals contracts from the other side.
|
||||
val bidder = state.highBidder
|
||||
if (bidder != null && teamOf(bidder) != teamOf(seat) && profile.style.spite >= 0.5) needed -= 2.0
|
||||
if (hard && bids.isNotEmpty()) {
|
||||
val cheapest = bids.minBy { it.bid.points }
|
||||
// A bigger contract needs a bigger hand, and a partner who is
|
||||
// already bidding is better supported than outbid.
|
||||
needed += 2.0 * (cheapest.bid.points - state.options.minimumBid)
|
||||
if (bidder != null && bidder == partnerOf(seat)) needed += 4.0
|
||||
}
|
||||
if (strength >= needed && bids.isNotEmpty()) {
|
||||
// Bid the floor rather than reaching; the contract is what matters.
|
||||
return bids.minBy { it.bid.points }
|
||||
}
|
||||
return legal.firstOrNull { it is PassBid } ?: bids.minByOrNull { it.bid.points } ?: legal.first()
|
||||
}
|
||||
|
||||
private fun choosePlay(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
|
||||
private fun choosePlay(
|
||||
state: KaiserState,
|
||||
seat: Int,
|
||||
legal: List<KaiserMove>,
|
||||
profile: AiProfile,
|
||||
seen: Set<Card>,
|
||||
): KaiserMove {
|
||||
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||
if (cards.size == 1) return PlayCard(cards.first())
|
||||
|
||||
val bidder = state.highBidder
|
||||
val defending = bidder != null && teamOf(bidder) != teamOf(seat)
|
||||
val spiteful = defending && profile.style.spite >= 0.5
|
||||
val best = if (profile.level == AiLevel.HARD) {
|
||||
hardPlay(state, seat, cards, seen, bidder != null && !defending)
|
||||
} else {
|
||||
standardPlay(state, seat, cards, spiteful, profile)
|
||||
}
|
||||
if (profile.level != AiLevel.EASY) return PlayCard(best)
|
||||
|
||||
// Easy sometimes throws a high card, or a counter, at the wrong time.
|
||||
val ranked = listOf(best) + (cards - best).sortedByDescending { it.rank.order }
|
||||
return PlayCard(profile.pick(ranked))
|
||||
}
|
||||
|
||||
private fun standardPlay(
|
||||
state: KaiserState,
|
||||
seat: Int,
|
||||
cards: List<Card>,
|
||||
spiteful: Boolean,
|
||||
profile: AiProfile,
|
||||
): Card {
|
||||
if (state.trick.isEmpty()) {
|
||||
// Leading: pull with the top trump, else lead an ace.
|
||||
val trump = state.trump
|
||||
val topTrump = cards.filter { trump != null && it.suit == trump }
|
||||
.maxByOrNull { it.rank.order }
|
||||
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return PlayCard(topTrump)
|
||||
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return topTrump
|
||||
val ace = cards.filter { it.rank == Rank.ACE }.maxByOrNull { it.rank.order }
|
||||
if (ace != null) return PlayCard(ace)
|
||||
return PlayCard(cards.minBy { it.rank.order })
|
||||
if (ace != null) return ace
|
||||
return cards.minBy { it.rank.order }
|
||||
}
|
||||
|
||||
val ledSuit = state.trick.first().card.suit
|
||||
@@ -96,22 +222,129 @@ class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
// The five of hearts is worth more than the trick it rides in on.
|
||||
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
|
||||
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
|
||||
// A spoiler on the defence will not leave a trick to a partner who may be beaten.
|
||||
val notLast = state.trick.size < 3
|
||||
val letPartner = partnerWinning && !threeOnTable &&
|
||||
!(spiteful && notLast && winners.isNotEmpty()) && !profile.slips()
|
||||
|
||||
return when {
|
||||
// Partner has it and the trick is worth taking: let them have it,
|
||||
// unless the three of spades is in there.
|
||||
partnerWinning && !threeOnTable -> PlayCard(cards.minBy { it.rank.order })
|
||||
letPartner -> cards.minBy { it.rank.order }
|
||||
|
||||
fiveOnTable && winners.isNotEmpty() ->
|
||||
PlayCard(winners.maxBy { trickStrength(it, state.trump, ledSuit) })
|
||||
winners.maxBy { trickStrength(it, state.trump, ledSuit) }
|
||||
|
||||
// Nobody wants the three; duck if the trick is already lost.
|
||||
threeOnTable && winners.isEmpty() -> PlayCard(cards.minBy { it.rank.order })
|
||||
threeOnTable && winners.isEmpty() -> cards.minBy { it.rank.order }
|
||||
|
||||
winners.isNotEmpty() && !threeOnTable ->
|
||||
PlayCard(winners.minBy { trickStrength(it, state.trump, ledSuit) })
|
||||
winners.minBy { trickStrength(it, state.trump, ledSuit) }
|
||||
|
||||
else -> PlayCard(cards.minBy { it.rank.order })
|
||||
else -> cards.minBy { it.rank.order }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The strong player's choice: it remembers what has been played, so it
|
||||
* knows which of its cards are sure winners, feeds the five of hearts to a
|
||||
* partner who is safe, and buries the three of spades under the other side.
|
||||
*/
|
||||
private fun hardPlay(
|
||||
state: KaiserState,
|
||||
seat: Int,
|
||||
cards: List<Card>,
|
||||
seen: Set<Card>,
|
||||
bidding: Boolean,
|
||||
): Card {
|
||||
val trump = state.trump
|
||||
val hand = state.hands[seat]
|
||||
val unseen = Decks.kaiser().filter { it !in seen && it !in hand }
|
||||
val counters = setOf(FIVE_OF_HEARTS, THREE_OF_SPADES)
|
||||
|
||||
fun isBoss(card: Card): Boolean {
|
||||
val mine = trickStrength(card, trump, card.suit)
|
||||
val cardIsTrump = trump != null && card.suit == trump
|
||||
return unseen.none { other ->
|
||||
(cardIsTrump || trump == null || other.suit != trump) &&
|
||||
trickStrength(other, trump, card.suit) > mine
|
||||
}
|
||||
}
|
||||
|
||||
if (state.trick.isEmpty()) {
|
||||
val plain = cards.filter { it !in counters }.ifEmpty { return cards.minBy { it.rank.order } }
|
||||
val myTrump = plain.filter { trump != null && it.suit == trump }
|
||||
val unseenTrump = unseen.count { trump != null && it.suit == trump }
|
||||
val bossTrump = myTrump.filter { isBoss(it) }.maxByOrNull { it.rank.order }
|
||||
if (bidding && unseenTrump > 0 && bossTrump != null) return bossTrump
|
||||
plain.filter { it !in myTrump && isBoss(it) }.maxByOrNull { it.rank.order }?.let { return it }
|
||||
if (bidding && unseenTrump > 0 && myTrump.size >= 2) return myTrump.maxBy { it.rank.order }
|
||||
if (bossTrump != null) return bossTrump
|
||||
val counts = hand.groupingBy { it.suit }.eachCount()
|
||||
val offSuit = plain.filter { it !in myTrump }.ifEmpty { plain }
|
||||
// The bidders draw a suit out; the defenders shorten one to ruff.
|
||||
return if (bidding) {
|
||||
offSuit.minWith(compareBy<Card>({ -(counts[it.suit] ?: 0) }, { it.rank.order }))
|
||||
} else {
|
||||
offSuit.minWith(compareBy<Card>({ counts[it.suit] ?: 0 }, { it.rank.order }))
|
||||
}
|
||||
}
|
||||
|
||||
val ledSuit = state.trick.first().card.suit
|
||||
val best = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
|
||||
val partnerWinning = partnerOf(best.seat) == seat
|
||||
val remaining = 4 - state.trick.size - 1
|
||||
val bestStrength = trickStrength(best.card, trump, ledSuit)
|
||||
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
|
||||
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
|
||||
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
|
||||
|
||||
fun sure(card: Card): Boolean =
|
||||
remaining == 0 ||
|
||||
unseen.none { trickStrength(it, trump, ledSuit) > trickStrength(card, trump, ledSuit) }
|
||||
|
||||
fun throwAway(): Card {
|
||||
val plain = cards.filter { it !in counters }
|
||||
if (plain.isNotEmpty()) return plain.minBy { it.rank.order }
|
||||
// Only counters left: the five belongs with partner, the three does not.
|
||||
return if (partnerWinning) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
|
||||
}
|
||||
|
||||
if (partnerWinning) {
|
||||
if (sure(best.card)) {
|
||||
// Partner has it: feed them the five of hearts if we can.
|
||||
if (FIVE_OF_HEARTS in cards && trickStrength(FIVE_OF_HEARTS, trump, ledSuit) <= bestStrength) {
|
||||
return FIVE_OF_HEARTS
|
||||
}
|
||||
return throwAway()
|
||||
}
|
||||
if (!threeOnTable) {
|
||||
winners.filter { sure(it) }.minByOrNull { trickStrength(it, trump, ledSuit) }?.let { return it }
|
||||
}
|
||||
return throwAway()
|
||||
}
|
||||
|
||||
// The other side is winning.
|
||||
if (threeOnTable) return throwAway()
|
||||
if (fiveOnTable && winners.isNotEmpty()) {
|
||||
val safe = winners.filter { sure(it) }
|
||||
return if (safe.isNotEmpty()) {
|
||||
safe.minBy { trickStrength(it, trump, ledSuit) }
|
||||
} else {
|
||||
winners.maxBy { trickStrength(it, trump, ledSuit) }
|
||||
}
|
||||
}
|
||||
// Bury the three under a trick the other side is certain to take.
|
||||
if (THREE_OF_SPADES in cards &&
|
||||
trickStrength(THREE_OF_SPADES, trump, ledSuit) <= bestStrength &&
|
||||
sure(best.card)
|
||||
) {
|
||||
return THREE_OF_SPADES
|
||||
}
|
||||
if (winners.isNotEmpty()) {
|
||||
val safe = winners.filter { sure(it) }
|
||||
return (safe.ifEmpty { winners }).minBy { trickStrength(it, trump, ledSuit) }
|
||||
}
|
||||
return throwAway()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -16,9 +18,18 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* - **Strong** is Knuth's idea one ply deep: among the codes still standing, it
|
||||
* plays the one whose *worst* possible answer leaves the smallest number of
|
||||
* candidates behind, so it cannot be given a reply that tells it little.
|
||||
*
|
||||
* The computer difficulty chosen at setup picks the level: Easy plays Casual,
|
||||
* Medium plays whatever the table's saved [MastermindOptions.level] says (Club
|
||||
* for a new game) and Hard plays Strong. [level] overrides all of that, which is
|
||||
* what the tests use.
|
||||
*/
|
||||
class MastermindAi(private val level: MastermindLevel? = null) :
|
||||
GameAi<MastermindState, MastermindMove> {
|
||||
class MastermindAi(
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val level: MastermindLevel? = null,
|
||||
) : GameAi<MastermindState, MastermindMove> {
|
||||
|
||||
constructor(level: MastermindLevel?) : this(AiProfiles.DEFAULT, level)
|
||||
|
||||
override fun chooseMove(
|
||||
state: MastermindState,
|
||||
@@ -26,7 +37,12 @@ class MastermindAi(private val level: MastermindLevel? = null) :
|
||||
legal: List<MastermindMove>,
|
||||
): MastermindMove {
|
||||
require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
val profile = profiles.of(seat)
|
||||
val setting = level ?: when (profile.level) {
|
||||
AiLevel.EASY -> MastermindLevel.CASUAL
|
||||
AiLevel.MEDIUM -> state.options.level
|
||||
AiLevel.HARD -> MastermindLevel.STRONG
|
||||
}
|
||||
|
||||
// Deterministic for a given position, so a host and a client watching
|
||||
// the same game agree on what the computer did.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.prolibertate.games.game.meowdoku
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* Plays the board for the one seat.
|
||||
*
|
||||
* Like peg solitaire's AI, nothing normally asks it to: the single seat belongs
|
||||
* to the person holding the phone. It exists because an empty seat has to be
|
||||
* playable by something, and because "the computer can finish this board" is a
|
||||
* property worth testing — a generated puzzle that only a human could unpack is
|
||||
* a broken promise about uniqueness.
|
||||
*
|
||||
* It reads the answer from the state. The rules hold it, the seat is alone with
|
||||
* it, and a solver that deduced its way there would be the same length as the
|
||||
* hint engine in the original game without making this seat any more honest.
|
||||
*/
|
||||
object MeowdokuAi : GameAi<MeowdokuState, MeowdokuMove> {
|
||||
|
||||
override fun chooseMove(
|
||||
state: MeowdokuState,
|
||||
seat: Int,
|
||||
legal: List<MeowdokuMove>,
|
||||
): MeowdokuMove {
|
||||
require(legal.isNotEmpty()) { "No move available" }
|
||||
val places = legal.filterIsInstance<PlaceCat>().associateBy { it.square }
|
||||
|
||||
// The first cat still missing. A cross on its square is no obstacle:
|
||||
// crossed squares take attempts like anything else, and a correct one
|
||||
// replaces the cross.
|
||||
for (row in 0 until state.side) {
|
||||
if (state.cats.none { it.row == row }) {
|
||||
places[Square(row, state.catCols[row])]?.let { return it }
|
||||
}
|
||||
}
|
||||
|
||||
return legal.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package org.prolibertate.games.game.meowdoku
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Builds the puzzles.
|
||||
*
|
||||
* A Meowdoku puzzle is an N by N board, a colouring of it into N regions, and a
|
||||
* hidden answer of N cats. The three are not chosen separately: the cats come
|
||||
* first, then the regions grow out from them so every region owns exactly one
|
||||
* cat, then the finished board is checked to make sure it has only that one
|
||||
* answer. A puzzle with two answers is a puzzle with no answer, since the second
|
||||
* one cannot be deduced from what is printed.
|
||||
*
|
||||
* The construction is seeded and reproducible: the same seed gives the same
|
||||
* board, which is what lets a saved game come back unchanged and lets a test
|
||||
* speak about "the" puzzle for a seed.
|
||||
*/
|
||||
object MeowdokuGenerator {
|
||||
|
||||
/** Smallest and largest boards worth dealing; anything bigger is a wall. */
|
||||
val MIN_SIDE = 4
|
||||
val MAX_SIDE = 10
|
||||
|
||||
/**
|
||||
* The answer, as the column of the cat in each row.
|
||||
*
|
||||
* One cat per row and one per column is already built in, since the rows are
|
||||
* enumerated once each and no column is taken twice.
|
||||
*/
|
||||
fun placeCats(side: Int, rng: SeededRng): IntArray {
|
||||
val cols = IntArray(side) { -1 }
|
||||
val placed = placeCatsFrom(0, side, cols, rng)
|
||||
// Every board from four up has a placement, and the search is complete,
|
||||
// so failing here would mean the generator itself is broken.
|
||||
check(placed) { "No way to settle $side cats on a $side board" }
|
||||
return cols
|
||||
}
|
||||
|
||||
private fun placeCatsFrom(row: Int, side: Int, cols: IntArray, rng: SeededRng): Boolean {
|
||||
if (row == side) return true
|
||||
for (col in rng.shuffled(cols.indices.toList())) {
|
||||
if (catFitsHere(row, col, cols)) {
|
||||
cols[row] = col
|
||||
if (placeCatsFrom(row + 1, side, cols, rng)) return true
|
||||
cols[row] = -1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cat at [row],[col] is at peace with the cats in the rows above.
|
||||
*
|
||||
* No shared column, and no shared corner: a cat grooms the squares touching
|
||||
* its own on all eight sides, so a neighbour one step away in any direction
|
||||
* is too close.
|
||||
*/
|
||||
fun catFitsHere(row: Int, col: Int, cols: IntArray): Boolean {
|
||||
for (r in 0 until row) {
|
||||
val c = cols[r]
|
||||
if (c == col) return false
|
||||
if (abs(r - row) <= 1 && abs(c - col) <= 1) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The colours of the board, as a region id per square.
|
||||
*
|
||||
* Each region starts as the cat it contains and grows outwards one square at
|
||||
* a time. The first [strips] regions are grown as straight bars along their
|
||||
* cat's row or column, alternating which way they run: a board of nothing but
|
||||
* blobs reads as texture, and the bars are what give the regions a shape a
|
||||
* player can reason about. The rest take the shortest border square, which
|
||||
* keeps them clumped rather than trailing off into the far corner.
|
||||
*/
|
||||
fun growRegions(side: Int, catCols: IntArray, rng: SeededRng, strips: Int): Array<IntArray> {
|
||||
val regions = Array(side) { IntArray(side) { -1 } }
|
||||
val dirs = listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0)
|
||||
|
||||
// Region i owns the cat in row i, which is where its colour starts.
|
||||
for (r in 0 until side) regions[r][catCols[r]] = r
|
||||
|
||||
val sizes = IntArray(side) { 1 }
|
||||
var unassigned = side * side - side
|
||||
|
||||
// The straight bars are laid first, and the rest is grown around them.
|
||||
// The straight bars are laid first, and the rest is grown around them.
|
||||
// Bar i runs along its cat's row when i is even, down its column when odd.
|
||||
val stripRegions = rng.shuffled((0 until side).toList()).take(strips.coerceIn(0, side - 1)).toSet()
|
||||
for ((n, region) in stripRegions.withIndex()) {
|
||||
val r0 = region
|
||||
val c0 = catCols[r0]
|
||||
val extra = 1 + rng.nextInt(3)
|
||||
var added = 0
|
||||
if (n % 2 == 0) {
|
||||
var left = c0 - 1
|
||||
var right = c0 + 1
|
||||
while (added < extra) {
|
||||
val goLeft = rng.nextBoolean()
|
||||
when {
|
||||
goLeft && left >= 0 && regions[r0][left] == -1 ->
|
||||
regions[r0][left--] = region
|
||||
right < side && regions[r0][right] == -1 ->
|
||||
regions[r0][right++] = region
|
||||
left >= 0 && regions[r0][left] == -1 ->
|
||||
regions[r0][left--] = region
|
||||
else -> break
|
||||
}
|
||||
sizes[region]++
|
||||
unassigned--
|
||||
added++
|
||||
}
|
||||
} else {
|
||||
var up = r0 - 1
|
||||
var down = r0 + 1
|
||||
while (added < extra) {
|
||||
val goUp = rng.nextBoolean()
|
||||
when {
|
||||
goUp && up >= 0 && regions[up][c0] == -1 ->
|
||||
regions[up--][c0] = region
|
||||
down < side && regions[down][c0] == -1 ->
|
||||
regions[down++][c0] = region
|
||||
up >= 0 && regions[up][c0] == -1 ->
|
||||
regions[up--][c0] = region
|
||||
else -> break
|
||||
}
|
||||
sizes[region]++
|
||||
unassigned--
|
||||
added++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What is left goes to whichever neighbouring region is smallest, so no
|
||||
// region dies young while another sprawls.
|
||||
var guard = side * side * 20
|
||||
while (unassigned > 0 && guard > 0) {
|
||||
guard--
|
||||
for (r in 0 until side) {
|
||||
for (c in 0 until side) {
|
||||
if (regions[r][c] != -1) continue
|
||||
var best = -1
|
||||
for ((dr, dc) in dirs) {
|
||||
val nr = r + dr
|
||||
val nc = c + dc
|
||||
if (nr !in 0 until side || nc !in 0 until side) continue
|
||||
val neighbour = regions[nr][nc]
|
||||
if (neighbour == -1 || neighbour in stripRegions) continue
|
||||
if (best == -1 || sizes[neighbour] < sizes[best]) best = neighbour
|
||||
}
|
||||
if (best == -1) {
|
||||
for ((dr, dc) in dirs) {
|
||||
val nr = r + dr
|
||||
val nc = c + dc
|
||||
if (nr !in 0 until side || nc !in 0 until side) continue
|
||||
val neighbour = regions[nr][nc]
|
||||
if (neighbour != -1 && (best == -1 || sizes[neighbour] < sizes[best])) {
|
||||
best = neighbour
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best != -1) {
|
||||
regions[r][c] = best
|
||||
sizes[best]++
|
||||
unassigned--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (r in 0 until side) {
|
||||
for (c in 0 until side) {
|
||||
if (regions[r][c] == -1) regions[r][c] = 0
|
||||
}
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
/**
|
||||
* How many answers this coloured board has, up to [limit].
|
||||
*
|
||||
* A depth-first search with the obvious propagation: once a row is given a
|
||||
* cat, the squares that cat excludes — its column, the rows beside it, and
|
||||
* everything in its colour — lose their candidates. A row whose candidate
|
||||
* list runs out fails immediately and the search backtracks, which is what
|
||||
* makes the count finish quickly on boards of this size.
|
||||
*
|
||||
* Nothing about the true cats is handed to the search; it sees the colours
|
||||
* and the rules and no more.
|
||||
*/
|
||||
fun countSolutions(side: Int, regions: Array<IntArray>, limit: Int = 2): Int {
|
||||
val candidates = Array(side) { r -> (0 until side).toMutableSet() }
|
||||
val colsTaken = BooleanArray(side)
|
||||
val coloursTaken = BooleanArray(side)
|
||||
val count = IntArray(1)
|
||||
solve(0, side, regions, candidates, colsTaken, coloursTaken, count, limit)
|
||||
return count[0]
|
||||
}
|
||||
|
||||
private fun solve(
|
||||
row: Int,
|
||||
side: Int,
|
||||
regions: Array<IntArray>,
|
||||
candidates: Array<MutableSet<Int>>,
|
||||
colsTaken: BooleanArray,
|
||||
coloursTaken: BooleanArray,
|
||||
count: IntArray,
|
||||
limit: Int,
|
||||
) {
|
||||
if (count[0] >= limit) return
|
||||
if (row == side) {
|
||||
count[0]++
|
||||
return
|
||||
}
|
||||
for (col in candidates[row].toList()) {
|
||||
val colour = regions[row][col]
|
||||
if (colsTaken[col] || coloursTaken[colour]) continue
|
||||
val undo = exclude(row, col, side, regions, candidates)
|
||||
if (undo != null) {
|
||||
colsTaken[col] = true
|
||||
coloursTaken[colour] = true
|
||||
solve(row + 1, side, regions, candidates, colsTaken, coloursTaken, count, limit)
|
||||
colsTaken[col] = false
|
||||
coloursTaken[colour] = false
|
||||
undo()
|
||||
}
|
||||
if (count[0] >= limit) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crosses out what the cat at [row],[col] forbids, and returns the function
|
||||
* that puts it back. Returns null if some row loses its last candidate,
|
||||
* which means this placement is a dead end.
|
||||
*/
|
||||
private fun exclude(
|
||||
row: Int,
|
||||
col: Int,
|
||||
side: Int,
|
||||
regions: Array<IntArray>,
|
||||
candidates: Array<MutableSet<Int>>,
|
||||
): (() -> Unit)? {
|
||||
val colour = regions[row][col]
|
||||
val saved = Array(side) { r -> candidates[r].toMutableSet() }
|
||||
var dead = false
|
||||
for (r in 0 until side) {
|
||||
if (r == row) continue
|
||||
candidates[r].remove(col)
|
||||
if (abs(r - row) == 1) {
|
||||
candidates[r].remove(col - 1)
|
||||
candidates[r].remove(col + 1)
|
||||
}
|
||||
candidates[r].removeAll { c -> regions[r][c] == colour }
|
||||
if (candidates[r].isEmpty()) dead = true
|
||||
}
|
||||
if (dead) {
|
||||
for (r in 0 until side) candidates[r] = saved[r]
|
||||
return null
|
||||
}
|
||||
return {
|
||||
for (r in 0 until side) candidates[r] = saved[r]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete puzzle: the colours, the answer, and a proof it is unique.
|
||||
*
|
||||
* Generation is rejection rather than construction — build a board, check it,
|
||||
* throw it away if the check fails — and the attempts walk forward from
|
||||
* [seed] so a seed that has a puzzle within a few tries gives the same one
|
||||
* every time.
|
||||
*/
|
||||
fun generate(side: Int, seed: Long): MeowdokuPuzzle? {
|
||||
val base = (seed xor (seed ushr 32)).toInt()
|
||||
for (attempt in 0 until MAX_ATTEMPTS) {
|
||||
val rng = SeededRng(base + attempt * 7919)
|
||||
val catCols = placeCats(side, rng)
|
||||
val regions = growRegions(side, catCols, SeededRng(base * 31 + attempt * 104729), strips = side / 2)
|
||||
if (distinctColours(side, regions) != side) continue
|
||||
if (countSolutions(side, regions, limit = 2) != 1) continue
|
||||
return MeowdokuPuzzle(side, regions, catCols)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private const val MAX_ATTEMPTS = 4000
|
||||
|
||||
/** How many different colours a board actually shows. */
|
||||
private fun distinctColours(side: Int, regions: Array<IntArray>): Int =
|
||||
regions.flatMap { it.toList() }.distinct().size
|
||||
}
|
||||
|
||||
/**
|
||||
* A board and its answer.
|
||||
*
|
||||
* The answer is stored rather than re-derived because the rules need it to
|
||||
* referee: a tap either lands a cat or loses a heart, and that is judged against
|
||||
* the solution, not against whether the player could have deduced it.
|
||||
*/
|
||||
data class MeowdokuPuzzle(
|
||||
val side: Int,
|
||||
/** Region id per square; ids run 0 until side. */
|
||||
val regions: Array<IntArray>,
|
||||
/** The column of the cat in each row. */
|
||||
val catCols: IntArray,
|
||||
) {
|
||||
fun regionOf(row: Int, col: Int): Int = regions[row][col]
|
||||
|
||||
/** Every colour id, in board order, for a legend or a palette. */
|
||||
val colours: List<Int> get() = (0 until side).toList()
|
||||
|
||||
fun solutionSquares(): List<Pair<Int, Int>> =
|
||||
catCols.indices.map { it to catCols[it] }
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is MeowdokuPuzzle &&
|
||||
side == other.side &&
|
||||
regions.contentDeepEquals(other.regions) &&
|
||||
catCols.contentEquals(other.catCols)
|
||||
|
||||
override fun hashCode(): Int = 31 * side + regions.contentDeepHashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* A 32-bit linear congruential generator.
|
||||
*
|
||||
* Deliberately not [kotlin.random.Random]: that one is seeded per-JVM and can
|
||||
* change between releases, and a puzzle generator that drifts silently corrupts
|
||||
* saved games. This one is small, explicit, and the same everywhere.
|
||||
*/
|
||||
class SeededRng(seed: Int) {
|
||||
private var state = if (seed == 0) DEFAULT_SEED else seed
|
||||
|
||||
/** The next 31 bits, as a non-negative int. */
|
||||
fun nextInt(): Int {
|
||||
state = state * MULTIPLIER + 1
|
||||
return (state ushr 1) and 0x7FFF_FFFF
|
||||
}
|
||||
|
||||
/** A value in 0 until [bound], taken by rejection so it stays uniform. */
|
||||
fun nextInt(bound: Int): Int {
|
||||
require(bound > 0)
|
||||
while (true) {
|
||||
val n = nextInt()
|
||||
// 2^31 is a multiple of no useful number of squares, so a bare
|
||||
// modulus biases the low values; the slack is thrown and redrawn.
|
||||
if (n < Int.MAX_VALUE - (Int.MAX_VALUE % bound)) return n % bound
|
||||
}
|
||||
}
|
||||
|
||||
fun nextBoolean(): Boolean = nextInt(2) == 0
|
||||
|
||||
/** The same list every time for the same seed, in a different order. */
|
||||
fun <T> shuffled(items: List<T>): List<T> {
|
||||
val out = items.toMutableList()
|
||||
for (i in out.lastIndex downTo 1) {
|
||||
val j = nextInt(i + 1)
|
||||
val tmp = out[i]
|
||||
out[i] = out[j]
|
||||
out[j] = tmp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// The constants of the old VAX C library's `rand`, which are well tested
|
||||
// and give a period of the full 31 bits.
|
||||
const val MULTIPLIER = 1103515245
|
||||
const val DEFAULT_SEED = 0x5DEECE66D.toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.prolibertate.games.game.meowdoku
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Meowdoku hides N cats in an N by N board: exactly one cat in every row, every
|
||||
// column and every colour, and no two cats sharing even a corner. A tap crosses
|
||||
// a square over; a second tap on that square asks the board whether a cat
|
||||
// lives there. A right answer lands the cat, a wrong one costs a heart and
|
||||
// keeps the cross — so the crossing over is both the player's note and the
|
||||
// first half of every guess, and the hearts are what stop it being a lottery.
|
||||
|
||||
/** One square of the board. */
|
||||
@Serializable
|
||||
data class Square(val row: Int, val col: Int)
|
||||
|
||||
@Serializable
|
||||
data class MeowdokuOptions(
|
||||
/** The board is [side] by [side], and the answer is [side] cats. */
|
||||
val side: Int = 6,
|
||||
/**
|
||||
* How many wrong taps the board forgives. The original game deals three,
|
||||
* and the hearts on a level are part of its difficulty rather than a
|
||||
* courtesy.
|
||||
*/
|
||||
val hearts: Int = 3,
|
||||
/** How many cats the board will show on demand. */
|
||||
val hints: Int = 3,
|
||||
) {
|
||||
init {
|
||||
require(side in MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE) {
|
||||
"No board of $side squares a side"
|
||||
}
|
||||
require(hearts in 1..9) { "A board with no hearts is lost before it is dealt" }
|
||||
require(hints in 0..9) { "Hints must read on the board, not scroll" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dealt board plus the marks on it.
|
||||
*
|
||||
* [cats] and [marks] are sets rather than a state per square because a square's
|
||||
* story is exactly those two memberships: nothing is a cat and crossed, and the
|
||||
* empty squares carry no information worth storing.
|
||||
*/
|
||||
@Serializable
|
||||
data class MeowdokuState(
|
||||
val options: MeowdokuOptions,
|
||||
/** The region id of every square, row by row. Ids run 0 until [side]. */
|
||||
val regions: List<List<Int>>,
|
||||
/** The column of the cat in each row — the answer, kept so taps can be judged. */
|
||||
val catCols: List<Int>,
|
||||
/** Cats the player has found (or a hint has shown). */
|
||||
val cats: Set<Square>,
|
||||
/** Squares crossed over: wrong taps and pencil marks alike. */
|
||||
val marks: Set<Square>,
|
||||
val heartsLeft: Int,
|
||||
val hintsLeft: Int,
|
||||
/** Wrong taps so far, which is how a solved board says how it was solved. */
|
||||
val wrongCats: Int,
|
||||
/** The last square touched, and whether touching it cost a heart. */
|
||||
val lastSquare: Square? = null,
|
||||
val lastWrong: Boolean = false,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val side: Int get() = options.side
|
||||
|
||||
/** Cats found, out of the [side] the board hides. */
|
||||
val catsFound: Int get() = cats.size
|
||||
|
||||
val solved: Boolean get() = catsFound == side
|
||||
|
||||
/** Out of hearts, with cats still missing. */
|
||||
val lost: Boolean get() = heartsLeft == 0 && !solved
|
||||
|
||||
fun regionOf(square: Square): Int = regions[square.row][square.col]
|
||||
|
||||
fun isCatSquare(square: Square): Boolean = catCols[square.row] == square.col
|
||||
}
|
||||
|
||||
/** What the player may do to a board. */
|
||||
@Serializable
|
||||
sealed interface MeowdokuMove
|
||||
|
||||
/**
|
||||
* The second tap of a double tap: the cat attempt.
|
||||
*
|
||||
* One move rather than two, because that is what the player is doing: the board
|
||||
* decides whether the tap found a cat or cost a heart. The square may carry the
|
||||
* cross its own first tap just drew — that cross is part of the double tap,
|
||||
* not a decision against it — and a correct attempt replaces it.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("cat")
|
||||
data class PlaceCat(val square: Square) : MeowdokuMove
|
||||
|
||||
/**
|
||||
* Draws or erases a cross on a square with no cat on it.
|
||||
*
|
||||
* This is the first tap of a tap pair and every cell of a drag stroke, so it is
|
||||
* the note-taking half of the scheme: free, unrewarded, and the board cannot
|
||||
* lose a heart over it. A cross drawn here can still be tapped through with a
|
||||
* cat attempt — the note is the player's, and only they get to change their
|
||||
* mind about it.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("pencil")
|
||||
data class TogglePencil(val square: Square) : MeowdokuMove
|
||||
|
||||
/**
|
||||
* Spends a hint to show the cat in the first row that lacks one.
|
||||
*
|
||||
* The original game sells hints; here they are simply counted, and the one
|
||||
* choice that has to be made about them — a hint is placed by the board, not
|
||||
* guessed at by the player — is the same either way.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("hint")
|
||||
data object TakeHint : MeowdokuMove
|
||||
@@ -0,0 +1,197 @@
|
||||
package org.prolibertate.games.game.meowdoku
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Meowdoku as a pure state machine. See RULES-meowdoku.md.
|
||||
*
|
||||
* The engine is a second single-seat game, alongside peg solitaire, and it
|
||||
* inherits the same freedom: there is nobody to hide anything from, so
|
||||
* [viewFor] hands out the whole board including the answer. The answer lives in
|
||||
* the state rather than being re-derived because judging a tap against a
|
||||
* deduction the player could have made is a different thing from judging it
|
||||
* against what the board was dealt — and the deduction engine (crossing out
|
||||
* rows, columns and colours) belongs to the player, not the referee.
|
||||
*/
|
||||
object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.MEOWDOKU
|
||||
|
||||
/** The one seat. */
|
||||
const val SOLO_SEAT: Int = 0
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): MeowdokuState {
|
||||
val options = json.decodeFromString<MeowdokuOptions>(config.optionsJson)
|
||||
require(config.seats.size == 1) {
|
||||
"Meowdoku seats one, not ${config.seats.size}"
|
||||
}
|
||||
val puzzle = deal(options.side, config.seed)
|
||||
return MeowdokuState(
|
||||
options = options,
|
||||
regions = puzzle.regions.map { it.toList() },
|
||||
catCols = puzzle.catCols.toList(),
|
||||
cats = emptySet(),
|
||||
marks = emptySet(),
|
||||
heartsLeft = options.hearts,
|
||||
hintsLeft = options.hints,
|
||||
wrongCats = 0,
|
||||
log = listOf("A ${options.side} by ${options.side} board, ${options.side} cats to find."),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deals until the board is good.
|
||||
*
|
||||
* A seed whose first attempts all fail to produce a unique board moves the
|
||||
* seed along rather than giving up: the same table asked twice gets the
|
||||
* same board both times, which is what a saved game and a unit test need.
|
||||
*/
|
||||
private fun deal(side: Int, seed: Long): MeowdokuPuzzle {
|
||||
var trySeed = seed
|
||||
repeat(DEAL_TRIES) {
|
||||
MeowdokuGenerator.generate(side, trySeed)?.let { return it }
|
||||
trySeed = trySeed * 6364136223846793005L + 1442695040888963407L
|
||||
}
|
||||
error("No unique ${side} x $side board within $DEAL_TRIES deals of seed $seed")
|
||||
}
|
||||
|
||||
private const val DEAL_TRIES = 8
|
||||
|
||||
/**
|
||||
* Every tap the board would take.
|
||||
*
|
||||
* The scheme is the original's: the first tap crosses a square over and
|
||||
* opens a short window, and a second tap in that window is the cat attempt
|
||||
* — so an attempt is offered on every square without a cat, crossed or
|
||||
* not. The cross the attempt replaces is exactly the cross the first tap
|
||||
* drew. Cats, once placed, are never tapped again: they are not a guess
|
||||
* you take back.
|
||||
*
|
||||
* The list is written row by row, which is also the order the hint uses.
|
||||
*/
|
||||
override fun legalMoves(state: MeowdokuState, seat: Int): List<MeowdokuMove> {
|
||||
if (seat != SOLO_SEAT || isFinished(state)) return emptyList()
|
||||
val cats = ArrayList<MeowdokuMove>()
|
||||
val pencils = ArrayList<MeowdokuMove>()
|
||||
for (row in 0 until state.side) {
|
||||
for (col in 0 until state.side) {
|
||||
val square = Square(row, col)
|
||||
if (square in state.cats) continue
|
||||
cats += PlaceCat(square)
|
||||
pencils += TogglePencil(square)
|
||||
}
|
||||
}
|
||||
val hint = if (state.hintsLeft > 0) listOf(TakeHint) else emptyList()
|
||||
return cats + pencils + hint
|
||||
}
|
||||
|
||||
override fun applyMove(
|
||||
state: MeowdokuState,
|
||||
seat: Int,
|
||||
move: MeowdokuMove,
|
||||
): MeowdokuState {
|
||||
require(seat == SOLO_SEAT) { "Meowdoku has one seat" }
|
||||
require(move in legalMoves(state, seat)) { "$move is not a legal move" }
|
||||
return when (move) {
|
||||
is PlaceCat -> tap(state, move.square)
|
||||
is TogglePencil -> pencil(state, move.square)
|
||||
TakeHint -> hint(state)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The second tap of a double tap: found a cat, or lost a heart.
|
||||
*
|
||||
* A correct tap clears the cross the first tap left behind — the cat
|
||||
* replaces it. A wrong one leaves the cross where it is and spends a
|
||||
* heart, because a wrong square crossed twice is the same mistake twice,
|
||||
* and the board remembers it either way.
|
||||
*/
|
||||
private fun tap(state: MeowdokuState, square: Square): MeowdokuState {
|
||||
if (state.isCatSquare(square)) {
|
||||
val next = state.copy(
|
||||
cats = state.cats + square,
|
||||
marks = state.marks - square,
|
||||
lastSquare = square,
|
||||
lastWrong = false,
|
||||
)
|
||||
val note = if (next.solved) {
|
||||
"All ${next.side} cats, ${next.wrongCats} wrong taps."
|
||||
} else {
|
||||
"Found a cat at row ${square.row + 1}, column ${square.col + 1}."
|
||||
}
|
||||
return next.copy(log = next.log + note)
|
||||
}
|
||||
val hearts = state.heartsLeft - 1
|
||||
val next = state.copy(
|
||||
marks = state.marks + square,
|
||||
heartsLeft = hearts,
|
||||
wrongCats = state.wrongCats + 1,
|
||||
lastSquare = square,
|
||||
lastWrong = true,
|
||||
)
|
||||
return if (hearts == 0) {
|
||||
next.copy(log = next.log + "Out of hearts, with ${next.side - next.catsFound} cats still hiding.")
|
||||
} else {
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
private fun pencil(state: MeowdokuState, square: Square): MeowdokuState {
|
||||
val marks = if (square in state.marks) state.marks - square else state.marks + square
|
||||
return state.copy(marks = marks, lastSquare = square, lastWrong = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the cat in the first row without one.
|
||||
*
|
||||
* A revealed cat lands like a found one — and erases a pencil cross on its
|
||||
* square, because the point of a hint is that the player stops having to
|
||||
* work out whether the board contradicts their own notes.
|
||||
*/
|
||||
private fun hint(state: MeowdokuState): MeowdokuState {
|
||||
val row = (0 until state.side).first { r -> state.cats.none { it.row == r } }
|
||||
val square = Square(row, state.catCols[row])
|
||||
return state.copy(
|
||||
cats = state.cats + square,
|
||||
marks = state.marks - square,
|
||||
hintsLeft = state.hintsLeft - 1,
|
||||
lastSquare = square,
|
||||
lastWrong = false,
|
||||
log = state.log + "A hint shows the cat in row ${row + 1}.",
|
||||
)
|
||||
}
|
||||
|
||||
/** A board is finished when it is solved or when the hearts run out. */
|
||||
override fun isFinished(state: MeowdokuState): Boolean = state.solved || state.lost
|
||||
|
||||
override fun currentSeat(state: MeowdokuState): Int? =
|
||||
if (isFinished(state)) null else SOLO_SEAT
|
||||
|
||||
override fun summary(state: MeowdokuState): String = when {
|
||||
state.solved -> "Solved with ${state.heartsLeft} hearts to spare"
|
||||
state.lost -> "Lost with ${state.catsFound} of ${state.side} cats found"
|
||||
else -> "${state.catsFound} of ${state.side} cats, ${state.heartsLeft} hearts left"
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing to strip. One person plays, and the answer in the state is there
|
||||
* for the referee, not as a secret. Should a second seat ever race through
|
||||
* the same board, this is where [MeowdokuState.catCols] gets hidden.
|
||||
*/
|
||||
override fun viewFor(state: MeowdokuState, seat: Int): MeowdokuState = state
|
||||
|
||||
override fun encodeState(state: MeowdokuState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): MeowdokuState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: MeowdokuMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): MeowdokuMove = this.json.decodeFromString(json)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.prolibertate.games.game.morris
|
||||
|
||||
import kotlin.random.Random
|
||||
import kotlin.math.roundToInt
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -19,7 +22,20 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
*
|
||||
* Strength comes from the table's own options unless a level is passed in.
|
||||
*/
|
||||
class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, MorrisMove> {
|
||||
class MorrisAi(
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val level: MorrisLevel? = null,
|
||||
) : GameAi<MorrisState, MorrisMove> {
|
||||
|
||||
// Viewpoint and weights for the move being chosen. At the default (balanced)
|
||||
// style they equal the plain constants, so Medium is unchanged.
|
||||
private var rootSeat = 0
|
||||
private var wMill = MILL
|
||||
private var wAlmost = ALMOST
|
||||
private var wTheirAlmost = ALMOST
|
||||
private var wMobility = MOBILITY
|
||||
private var wTheirMobility = MOBILITY
|
||||
private var wDanger = DANGER
|
||||
|
||||
/**
|
||||
* Ceiling on how much thinking any one move gets, so a phone does not stall
|
||||
@@ -32,10 +48,29 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
|
||||
|
||||
override fun chooseMove(state: MorrisState, seat: Int, legal: List<MorrisMove>): MorrisMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
val bold = profile.style.boldness
|
||||
val spite = profile.style.spite
|
||||
rootSeat = seat
|
||||
// Bold seats value mills and half-built mills more; cautious ones value
|
||||
// mobility and avoiding the three-piece trap. Spiteful seats weigh the
|
||||
// opponent's mill threats and freedom of movement more.
|
||||
wMill = MILL + (bold * 6).roundToInt()
|
||||
wAlmost = ALMOST + (bold * 3).roundToInt()
|
||||
wTheirAlmost = ALMOST + (spite * 4).roundToInt() + (-bold * 2).roundToInt()
|
||||
wMobility = MOBILITY + (-bold * 1).roundToInt()
|
||||
wTheirMobility = MOBILITY + (spite * 2).roundToInt() + (-bold * 1).roundToInt()
|
||||
wDanger = DANGER + (-bold * 10).roundToInt()
|
||||
|
||||
val maxDepth = when (profile.level) {
|
||||
AiLevel.EASY -> maxOf(2, setting.depth - 2)
|
||||
AiLevel.MEDIUM -> setting.depth
|
||||
AiLevel.HARD -> setting.depth + 1
|
||||
}
|
||||
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
|
||||
nodesUsed = 0
|
||||
|
||||
// Deterministic for a given position, so a host and a client replaying
|
||||
@@ -46,7 +81,7 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
for (depth in 1..maxDepth) {
|
||||
val results = mutableListOf<Pair<MorrisMove, Int>>()
|
||||
val ordered = order(legal, preferred = best)
|
||||
for (move in ordered) {
|
||||
@@ -73,7 +108,16 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
|
||||
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
val chosen = acceptable[random.nextInt(acceptable.size)].first
|
||||
if (profile.level != AiLevel.EASY) return chosen
|
||||
|
||||
// Easy now and then settles for another sound-looking move: only ones
|
||||
// within about a piece of the best, never a real blunder.
|
||||
val ranked = listOf(chosen) + scored
|
||||
.filter { it.first != chosen && it.second >= bestScore - EASY_TOLERANCE }
|
||||
.sortedByDescending { it.second }
|
||||
.map { it.first }
|
||||
return profile.pick(ranked)
|
||||
}
|
||||
|
||||
/** How far below best a move may score and still be considered. */
|
||||
@@ -155,16 +199,17 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
|
||||
|
||||
/** The position from the point of view of the side to move. */
|
||||
private fun evaluate(state: MorrisState): Int {
|
||||
val us = state.turn
|
||||
val them = other(us)
|
||||
val me = rootSeat
|
||||
val them = other(me)
|
||||
|
||||
var score = PIECE * (state.remaining(us) - state.remaining(them))
|
||||
score += MILL * (state.millCount(us) - state.millCount(them))
|
||||
score += ALMOST * (almostMills(state, us) - almostMills(state, them))
|
||||
score += MOBILITY * (mobility(state, us) - mobility(state, them))
|
||||
score += LADDER * (midEdgeCount(state, us) - midEdgeCount(state, them))
|
||||
score -= DANGER * (onThree(state, us) - onThree(state, them))
|
||||
return score
|
||||
var score = PIECE * (state.remaining(me) - state.remaining(them))
|
||||
score += wMill * (state.millCount(me) - state.millCount(them))
|
||||
score += wAlmost * almostMills(state, me) - wTheirAlmost * almostMills(state, them)
|
||||
score += wMobility * mobility(state, me) - wTheirMobility * mobility(state, them)
|
||||
score += LADDER * (midEdgeCount(state, me) - midEdgeCount(state, them))
|
||||
score -= wDanger * (onThree(state, me) - onThree(state, them))
|
||||
// Scored from the root seat's side; flip for the side to move.
|
||||
return if (state.turn == me) score else -score
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,6 +260,8 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
|
||||
const val INFINITY = 1_000_000
|
||||
const val WIN = 100_000
|
||||
const val NODE_BUDGET = 120_000
|
||||
const val HARD_NODE_BUDGET = 280_000
|
||||
const val EASY_TOLERANCE = 120
|
||||
|
||||
const val PIECE = 120
|
||||
const val MILL = 26
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package org.prolibertate.games.game.pirates
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -14,17 +18,73 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* from the Bulgars' side and negated for the pirates, so the two cannot drift
|
||||
* apart and start valuing the same position differently.
|
||||
*/
|
||||
class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState, PiratesMove> {
|
||||
class PiratesAi(
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val level: PiratesLevel? = null,
|
||||
) : GameAi<PiratesState, PiratesMove> {
|
||||
|
||||
private var nodeBudget = 0
|
||||
private var nodesUsed = 0
|
||||
|
||||
// Evaluation weights for the seat now choosing; the standard values unless
|
||||
// a style or level bends them. Set at the top of each chooseMove.
|
||||
private var wBulgar = BULGAR
|
||||
private var wStronghold = IN_STRONGHOLD
|
||||
private var wAdvance = ADVANCE
|
||||
private var wExposed = EXPOSED
|
||||
private var wRoom = PIRATE_ROOM
|
||||
|
||||
private fun tune(profile: AiProfile, seat: Int) {
|
||||
val bold = profile.style.boldness
|
||||
val spite = profile.style.spite
|
||||
var bulgar = 1.0
|
||||
var stronghold = 1.0
|
||||
var advance = 1.0
|
||||
var exposed = 1.0
|
||||
var room = 1.0
|
||||
if (seat == BULGAR_SEAT) {
|
||||
// A bold crowd pushes forward and risks being jumped; a cautious one stays tight.
|
||||
exposed -= 0.3 * bold
|
||||
advance += 0.3 * bold
|
||||
// A spoiler squeezes the pirates' room.
|
||||
room += 0.4 * spite
|
||||
} else {
|
||||
// A bold pirate hunts; a cautious one keeps its room to move.
|
||||
bulgar += 0.15 * bold
|
||||
room -= 0.3 * bold
|
||||
// A spoiler pays more attention to stopping the climb.
|
||||
stronghold += 0.4 * spite
|
||||
advance += 0.4 * spite
|
||||
}
|
||||
if (profile.level == AiLevel.EASY) exposed *= 0.5 // overlooks some threats
|
||||
wBulgar = (BULGAR * bulgar).roundToInt()
|
||||
wStronghold = (IN_STRONGHOLD * stronghold).roundToInt()
|
||||
wAdvance = (ADVANCE * advance).roundToInt()
|
||||
wExposed = (EXPOSED * exposed).roundToInt()
|
||||
wRoom = (PIRATE_ROOM * room).roundToInt()
|
||||
}
|
||||
|
||||
override fun chooseMove(state: PiratesState, seat: Int, legal: List<PiratesMove>): PiratesMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
val profile = profiles.of(seat)
|
||||
val base = level ?: state.options.level
|
||||
tune(profile, seat)
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val easy = profile.level == AiLevel.EASY
|
||||
val maxDepth = when (profile.level) {
|
||||
AiLevel.MEDIUM -> base.depth
|
||||
AiLevel.EASY -> minOf(base.depth, 2)
|
||||
AiLevel.HARD -> base.depth + 2
|
||||
}
|
||||
val slack = when (profile.level) {
|
||||
AiLevel.MEDIUM -> base.slack()
|
||||
AiLevel.EASY -> maxOf(base.slack(), 40)
|
||||
AiLevel.HARD -> 0
|
||||
}
|
||||
|
||||
nodeBudget = if (hard) HARD_NODE_BUDGET else NODE_BUDGET
|
||||
nodesUsed = 0
|
||||
|
||||
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
|
||||
@@ -32,18 +92,23 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
for (depth in 1..maxDepth) {
|
||||
val results = mutableListOf<Pair<PiratesMove, Int>>()
|
||||
val ordered = order(legal, preferred = best)
|
||||
var bestSoFar = -INFINITY
|
||||
for (move in ordered) {
|
||||
// A hard seat only needs the exact value of moves that tie or beat
|
||||
// the best so far, so everything else is searched against a window.
|
||||
val floor = if (hard && results.isNotEmpty()) bestSoFar - 1 else -INFINITY
|
||||
val score = -negamax(
|
||||
state = PiratesRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -INFINITY,
|
||||
beta = INFINITY,
|
||||
beta = -floor,
|
||||
ply = 1,
|
||||
)
|
||||
results += move to score
|
||||
if (score > bestSoFar) bestSoFar = score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
|
||||
@@ -51,9 +116,16 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
|
||||
best = results.maxBy { it.second }.first
|
||||
}
|
||||
|
||||
if (easy) {
|
||||
val ranked = scored.sortedByDescending { it.second }.map { it.first }
|
||||
val picked = profile.pick(ranked)
|
||||
if (picked != ranked.first() && picked in legal) return picked
|
||||
}
|
||||
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
val acceptable = scored.filter { it.second >= bestScore - slack }
|
||||
val chosen = acceptable[random.nextInt(acceptable.size)].first
|
||||
return if (chosen in legal) chosen else legal.first()
|
||||
}
|
||||
|
||||
private fun PiratesLevel.slack(): Int = when (this) {
|
||||
@@ -115,22 +187,22 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
|
||||
private fun evaluate(state: PiratesState): Int {
|
||||
var bulgarView = 0
|
||||
|
||||
bulgarView += state.count(BULGAR_SEAT) * BULGAR
|
||||
bulgarView += state.stronghold() * IN_STRONGHOLD
|
||||
bulgarView += state.count(BULGAR_SEAT) * wBulgar
|
||||
bulgarView += state.stronghold() * wStronghold
|
||||
|
||||
// How far up the board the crowd has come. The stronghold is at the
|
||||
// top, so a low row number is ground gained.
|
||||
for (point in 0 until POINTS) {
|
||||
if (state.board[point] != BULGAR_SEAT) continue
|
||||
bulgarView += (GRID - 1 - rowOf(point)) * ADVANCE
|
||||
bulgarView += (GRID - 1 - rowOf(point)) * wAdvance
|
||||
// A Bulgar with an empty point directly behind it on a line is one
|
||||
// a pirate can jump. Being unjumpable is most of staying alive.
|
||||
if (isExposed(state, point)) bulgarView -= EXPOSED
|
||||
if (isExposed(state, point)) bulgarView -= wExposed
|
||||
}
|
||||
|
||||
// Room for the pirates to work in, which is what the Bulgars are
|
||||
// slowly taking away.
|
||||
bulgarView -= pirateMobility(state) * PIRATE_ROOM
|
||||
bulgarView -= pirateMobility(state) * wRoom
|
||||
|
||||
return if (state.turn == BULGAR_SEAT) bulgarView else -bulgarView
|
||||
}
|
||||
@@ -160,6 +232,7 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
|
||||
const val INFINITY = 1_000_000
|
||||
const val WIN = 100_000
|
||||
const val NODE_BUDGET = 150_000
|
||||
const val HARD_NODE_BUDGET = 400_000
|
||||
|
||||
const val BULGAR = 100
|
||||
const val IN_STRONGHOLD = 55
|
||||
|
||||
@@ -1,63 +1,85 @@
|
||||
package org.prolibertate.games.game.president
|
||||
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic President opponent.
|
||||
* A President opponent whose strength and temperament come from the seat's profile.
|
||||
*
|
||||
* It sheds low cards early, spends the cheapest set that takes the pile, and
|
||||
* holds twos back for when they are worth something. It does not count cards.
|
||||
* MEDIUM (the default) sheds low cards early, spends the cheapest set that takes
|
||||
* the pile, and holds twos back for when they are worth something. It does not
|
||||
* count cards.
|
||||
*
|
||||
* EASY spends high cards freely when leading and sometimes plays a worse set or
|
||||
* passes when it should have played. HARD reads the round's log to count which
|
||||
* ranks are still out, leads so that its remaining hand is unbeatable, blocks an
|
||||
* opponent who is about to go out, and keeps its high cards back when a cheap
|
||||
* pile is not worth them.
|
||||
*/
|
||||
class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
class PresidentAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<PresidentState, PresidentMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/** Rank above which a card is hoarded rather than spent to lead. */
|
||||
val hoardAbove: Int,
|
||||
) {
|
||||
/** Plays whatever is cheapest, whenever it can. */
|
||||
EASY(hoardAbove = 99),
|
||||
|
||||
NORMAL(hoardAbove = 13),
|
||||
|
||||
/** Sits on high cards until late, then dumps them. */
|
||||
CAUTIOUS(hoardAbove = 11),
|
||||
}
|
||||
|
||||
override fun chooseMove(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
legal: List<PresidentMove>,
|
||||
): PresidentMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val plays = legal.filterIsInstance<PlayCards>()
|
||||
if (plays.isEmpty()) return legal.first()
|
||||
|
||||
return if (state.setSize == 0) chooseLead(state, seat, plays) else chooseFollow(state, plays, legal)
|
||||
val move: PresidentMove = when (profile.level) {
|
||||
AiLevel.MEDIUM -> standard(state, seat, plays, legal, hoardAbove = HOARD_ABOVE)
|
||||
AiLevel.EASY -> easy(state, seat, plays, legal, profile)
|
||||
AiLevel.HARD -> hard(state, seat, plays, legal, profile)
|
||||
}
|
||||
return if (move in legal) move else legal.first()
|
||||
}
|
||||
|
||||
private fun standard(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
plays: List<PlayCards>,
|
||||
legal: List<PresidentMove>,
|
||||
hoardAbove: Int,
|
||||
): PresidentMove =
|
||||
if (state.setSize == 0) {
|
||||
leadRanking(state, seat, plays, hoardAbove).first()
|
||||
} else {
|
||||
chooseFollow(state, plays, legal)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Standard plan
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Leading is a chance to shed. Prefer the biggest set of the lowest rank,
|
||||
* which clears junk fastest without spending anything valuable.
|
||||
*/
|
||||
private fun chooseLead(
|
||||
private fun leadRanking(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
plays: List<PlayCards>,
|
||||
): PresidentMove {
|
||||
hoardAbove: Int,
|
||||
): List<PlayCards> {
|
||||
val endgame = state.hands[seat].size <= 4
|
||||
val candidates = plays.filter { play ->
|
||||
endgame || presidentRank(play.cards.first()) <= difficulty.hoardAbove
|
||||
endgame || presidentRank(play.cards.first()) <= hoardAbove
|
||||
}.ifEmpty { plays }
|
||||
|
||||
return candidates
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ presidentRank(it.cards.first()) },
|
||||
{ -it.cards.size },
|
||||
)
|
||||
)
|
||||
.first()
|
||||
return candidates.sortedWith(
|
||||
compareBy<PlayCards>(
|
||||
{ presidentRank(it.cards.first()) },
|
||||
{ -it.cards.size },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,4 +104,188 @@ class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
|
||||
return cheapest
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Easy
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun easy(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
plays: List<PlayCards>,
|
||||
legal: List<PresidentMove>,
|
||||
profile: AiProfile,
|
||||
): PresidentMove {
|
||||
if (state.setSize == 0) {
|
||||
// Never hoards: leads aces and twos as readily as anything else.
|
||||
return profile.pick(leadRanking(state, seat, plays, hoardAbove = 99))
|
||||
}
|
||||
val best = chooseFollow(state, plays, legal)
|
||||
val alternatives = plays.sortedBy { presidentRank(it.cards.first()) }
|
||||
val ranked = buildList<PresidentMove> {
|
||||
add(best)
|
||||
addAll(alternatives.filter { it != best })
|
||||
if (PassTurn in legal && best != PassTurn) add(PassTurn)
|
||||
}
|
||||
return profile.pick(ranked)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hard
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Ranks still unaccounted for: not in this seat's hand and not played yet this round. */
|
||||
private class Counts(state: PresidentState, seat: Int) {
|
||||
private val unseen = HashMap<Int, Int>()
|
||||
|
||||
init {
|
||||
for (rank in 3..15) unseen[rank] = 4
|
||||
state.hands[seat].forEach { dec(presidentRank(it)) }
|
||||
val log = state.log
|
||||
val start = log.indexOfLast { ROUND_MARK.matches(it) } + 1
|
||||
for (i in start until log.size) {
|
||||
val line = log[i]
|
||||
val at = line.indexOf(" plays ")
|
||||
if (!line.startsWith("Seat ") || at < 0) continue
|
||||
line.substring(at + " plays ".length).removeSuffix(".").split(' ').forEach { token ->
|
||||
LABELS[token]?.let { dec(presidentRank(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dec(rank: Int) {
|
||||
unseen[rank] = maxOf(0, (unseen[rank] ?: 0) - 1)
|
||||
}
|
||||
|
||||
fun of(rank: Int): Int = unseen[rank] ?: 0
|
||||
|
||||
/** Could an opponent still hold a set of [size] that beats a set of [size] at [rank]? */
|
||||
fun beatable(rank: Int, size: Int, options: PresidentOptions): Boolean {
|
||||
if (options.twosClear && rank != 15 && of(15) >= size) return true
|
||||
if (options.fourOfAKindBomb && size == 4 && (3..15).any { it != rank && of(it) == 4 }) return true
|
||||
return (rank + 1..15).any { of(it) >= size }
|
||||
}
|
||||
}
|
||||
|
||||
private fun hard(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
plays: List<PlayCards>,
|
||||
legal: List<PresidentMove>,
|
||||
profile: AiProfile,
|
||||
): PresidentMove {
|
||||
val hand = state.hands[seat]
|
||||
// Going out beats any plan.
|
||||
plays.firstOrNull { it.cards.size == hand.size }?.let { return it }
|
||||
|
||||
val counts = Counts(state, seat)
|
||||
val others = (0 until state.options.playerCount).filter { it != seat && !state.isOut(it) }
|
||||
return if (state.setSize == 0) {
|
||||
hardLead(state, seat, plays, counts, others, profile)
|
||||
} else {
|
||||
hardFollow(state, seat, plays, legal, counts, others, profile)
|
||||
}
|
||||
}
|
||||
|
||||
/** An opponent who could empty their hand with a set the size of the one on top, or is the score leader close to out. */
|
||||
private fun outThreat(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
others: List<Int>,
|
||||
profile: AiProfile,
|
||||
): Boolean {
|
||||
if (others.any { state.handCounts[it] <= maxOf(1, state.setSize) }) return true
|
||||
if (profile.style.spite >= 0.5) {
|
||||
val leader = others.maxByOrNull { state.scores[it] }
|
||||
if (leader != null && state.handCounts[leader] <= 2) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun hardLead(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
plays: List<PlayCards>,
|
||||
counts: Counts,
|
||||
others: List<Int>,
|
||||
profile: AiProfile,
|
||||
): PresidentMove {
|
||||
val hand = state.hands[seat]
|
||||
val options = state.options
|
||||
val oneCardLeft = others.any { state.handCounts[it] == 1 }
|
||||
|
||||
fun unbeatableAfter(play: PlayCards): Boolean {
|
||||
val remaining = hand.toMutableList().also { left -> play.cards.forEach { left.remove(it) } }
|
||||
return remaining.groupBy { presidentRank(it) }
|
||||
.all { (rank, cards) -> !counts.beatable(rank, cards.size, options) }
|
||||
}
|
||||
|
||||
fun key(play: PlayCards): Double {
|
||||
val rank = presidentRank(play.cards.first())
|
||||
val size = play.cards.size
|
||||
var k = rank - 0.9 * size
|
||||
if (unbeatableAfter(play)) return k - 50.0
|
||||
// Leading aces and twos spends control that is worth more later.
|
||||
if (rank >= 14 && hand.size > 4) k += 20.0
|
||||
// Somebody is down to one card: a single would let them out if they can beat it.
|
||||
if (oneCardLeft && size == 1) k += 10.0 + (15 - rank) * 2.0
|
||||
// A bold seat is happier to lead its strong sets early.
|
||||
if (rank >= 11) k -= profile.style.boldness * 2.0
|
||||
return k
|
||||
}
|
||||
return plays.minBy { key(it) }
|
||||
}
|
||||
|
||||
private fun hardFollow(
|
||||
state: PresidentState,
|
||||
seat: Int,
|
||||
plays: List<PlayCards>,
|
||||
legal: List<PresidentMove>,
|
||||
counts: Counts,
|
||||
others: List<Int>,
|
||||
profile: AiProfile,
|
||||
): PresidentMove {
|
||||
val hand = state.hands[seat]
|
||||
val options = state.options
|
||||
val canPass = PassTurn in legal
|
||||
val threat = outThreat(state, seat, others, profile)
|
||||
|
||||
val withoutTwos = plays.filter { play -> play.cards.none { isTwo(it) } }
|
||||
val pool = withoutTwos.ifEmpty { plays }
|
||||
val cheapest = pool.minBy { presidentRank(it.cards.first()) }
|
||||
|
||||
if (threat) {
|
||||
// Block them: take the pile with the cheapest set nobody can beat,
|
||||
// or failing that the cheapest set there is, twos included.
|
||||
val sure = plays.filter { !counts.beatable(presidentRank(it.cards.first()), it.cards.size, options) }
|
||||
.minByOrNull { presidentRank(it.cards.first()) }
|
||||
return sure ?: plays.minBy { presidentRank(it.cards.first()) }
|
||||
}
|
||||
|
||||
val spendingTwo = cheapest.cards.any { isTwo(it) }
|
||||
if (spendingTwo) {
|
||||
val remaining = hand.toMutableList().also { left -> cheapest.cards.forEach { left.remove(it) } }
|
||||
val runOut = remaining.groupBy { presidentRank(it) }
|
||||
.all { (rank, cards) -> !counts.beatable(rank, cards.size, options) }
|
||||
val worthIt = state.setRank >= 13 || state.pile.size >= 4 || hand.size <= 4 || runOut
|
||||
if (!worthIt && canPass) return PassTurn
|
||||
return cheapest
|
||||
}
|
||||
|
||||
// A cheap pile is not worth a high card while the hand is still big.
|
||||
val gap = profile.bolder(6.0, 2.0)
|
||||
val cost = presidentRank(cheapest.cards.first()) - state.setRank
|
||||
if (canPass && hand.size > 5 && cost > gap && presidentRank(cheapest.cards.first()) >= 12) {
|
||||
return PassTurn
|
||||
}
|
||||
return cheapest
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HOARD_ABOVE = 13
|
||||
val ROUND_MARK = Regex("^Round \\d+\\.$")
|
||||
val LABELS: Map<String, Card> = Suit.entries.flatMap { suit ->
|
||||
Rank.standard.map { Card(it, suit) }
|
||||
}.associateBy { it.label }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.prolibertate.games.game.sequence
|
||||
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -8,18 +11,39 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* It scores every legal placement by how much it advances one of its own runs
|
||||
* and how badly it spoils an opponent's, holds its jacks back until they are
|
||||
* worth spending, and never looks at anyone's hand.
|
||||
*
|
||||
* Per seat, from [profiles]: Easy mostly ignores your runs, burns its one-eyed
|
||||
* jacks on anything and sometimes settles for a lesser square; Hard blocks hard,
|
||||
* saves jacks for real threats, always takes a completed run and always stops
|
||||
* one that is one square from done. Boldness leans towards building over
|
||||
* blocking and spite the other way round.
|
||||
*/
|
||||
class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
class SequenceAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<SequenceState, SequenceMove> {
|
||||
|
||||
enum class Difficulty(val blockWeight: Int, val jackPatience: Int) {
|
||||
/** Builds its own runs and mostly ignores yours. */
|
||||
EASY(blockWeight = 1, jackPatience = 0),
|
||||
/** The numbers one seat plays with, worked out from its level and style. */
|
||||
private class Tuning(
|
||||
val ownWeight: Double,
|
||||
val blockWeight: Double,
|
||||
val jackPatience: Double,
|
||||
val finishing: Boolean,
|
||||
)
|
||||
|
||||
NORMAL(blockWeight = 3, jackPatience = 3),
|
||||
|
||||
/** Blocks aggressively and saves jacks for real threats. */
|
||||
CAUTIOUS(blockWeight = 5, jackPatience = 4),
|
||||
private fun tuningOf(profile: AiProfile): Tuning {
|
||||
val (block, patience) = when (profile.level) {
|
||||
AiLevel.EASY -> 1.0 to 0.0
|
||||
AiLevel.MEDIUM -> 3.0 to 3.0
|
||||
AiLevel.HARD -> 5.0 to 4.0
|
||||
}
|
||||
val style = profile.style
|
||||
val easy = profile.level == AiLevel.EASY
|
||||
return Tuning(
|
||||
ownWeight = 4.0 + style.boldness,
|
||||
blockWeight = block - style.boldness * 0.8 + style.spite * 2.0,
|
||||
// Only a seat that is paying attention changes its mind about jacks.
|
||||
jackPatience = if (easy) patience else patience + style.boldness - style.spite,
|
||||
finishing = profile.level == AiLevel.HARD,
|
||||
)
|
||||
}
|
||||
|
||||
override fun chooseMove(
|
||||
@@ -28,15 +52,30 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
legal: List<SequenceMove>,
|
||||
): SequenceMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val tuning = tuningOf(profile)
|
||||
val team = state.teamOf(seat)
|
||||
|
||||
// Clearing a dead card is free value when nothing better is on offer.
|
||||
val exchange = legal.filterIsInstance<ExchangeDeadCard>().firstOrNull()
|
||||
if (exchange != null && legal.none { it is PlaceChip }) return exchange
|
||||
|
||||
val best = legal
|
||||
val placements = legal
|
||||
.filterIsInstance<PlaceChip>()
|
||||
.maxByOrNull { scorePlacement(state, it, team) }
|
||||
.map { it to scorePlacement(state, it, team, tuning) }
|
||||
.sortedByDescending { it.second }
|
||||
.map { it.first }
|
||||
val best = if (placements.isEmpty()) {
|
||||
null
|
||||
} else if (tuning.finishing) {
|
||||
// A run that completes, or a run of the opponent's that would, is
|
||||
// never something to be talked out of.
|
||||
placements.first()
|
||||
} else {
|
||||
// Easy only: the others never slip. Only the better few are in play,
|
||||
// so a slip is a weaker square rather than a wasted card.
|
||||
profile.pick(placements.take(4))
|
||||
}
|
||||
|
||||
val removal = legal
|
||||
.filterIsInstance<RemoveChip>()
|
||||
@@ -45,7 +84,7 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
// Spend a one-eyed jack only on a genuinely dangerous chip.
|
||||
if (removal != null) {
|
||||
val threat = threatAt(state, removal.cell, state.chips[removal.cell])
|
||||
if (threat >= difficulty.jackPatience) return removal
|
||||
if (threat >= tuning.jackPatience && !profile.slips()) return removal
|
||||
}
|
||||
|
||||
return best ?: removal ?: exchange ?: legal.first()
|
||||
@@ -56,14 +95,25 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
* opponent's run it interrupts. Wild jacks are discounted so they are not
|
||||
* burned on an ordinary square.
|
||||
*/
|
||||
private fun scorePlacement(state: SequenceState, move: PlaceChip, team: Int): Int {
|
||||
private fun scorePlacement(
|
||||
state: SequenceState,
|
||||
move: PlaceChip,
|
||||
team: Int,
|
||||
tuning: Tuning,
|
||||
): Double {
|
||||
val ours = runPotential(state, move.cell, team)
|
||||
val theirs = (0 until state.options.teamCount)
|
||||
.filter { it != team }
|
||||
.maxOfOrNull { runPotential(state, move.cell, it) }
|
||||
?: 0
|
||||
|
||||
var score = ours * 4 + theirs * difficulty.blockWeight
|
||||
var score = ours * tuning.ownWeight + theirs * tuning.blockWeight
|
||||
if (tuning.finishing) {
|
||||
// A potential of five through this square means placing here
|
||||
// completes the run (for whoever places it).
|
||||
if (ours >= RUN_LENGTH) score += 100.0
|
||||
if (theirs >= RUN_LENGTH) score += 60.0 else if (theirs == RUN_LENGTH - 1) score += 8.0
|
||||
}
|
||||
// Central squares sit on more possible lines than edge squares.
|
||||
val row = SequenceBoard.rowOf(move.cell)
|
||||
val col = SequenceBoard.colOf(move.cell)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.prolibertate.games.game.tayu
|
||||
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -18,17 +21,53 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
*
|
||||
* [level] overrides the table's own setting, which is what the tests use;
|
||||
* ordinarily it is left null and read from [TayuOptions.level].
|
||||
*
|
||||
* Per seat, from [profiles]: the table's setting says which terms count at all
|
||||
* (Gentle ignores the opponent entirely and that is respected at every level).
|
||||
* Within Full strength, Easy blocks and steers less and sometimes takes one of
|
||||
* its better few placements rather than the best; Hard blocks and steers harder.
|
||||
* Boldness weights its own axis against the opponent's, spite weights blocking.
|
||||
*/
|
||||
class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove> {
|
||||
class TayuAi(
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val level: TayuLevel? = null,
|
||||
) : GameAi<TayuState, TayuMove> {
|
||||
|
||||
constructor(level: TayuLevel) : this(AiProfiles.DEFAULT, level)
|
||||
|
||||
private class Weights(val own: Double, val block: Double, val reach: Double)
|
||||
|
||||
private fun weightsOf(setting: TayuLevel, profile: AiProfile): Weights {
|
||||
var own = setting.ownWeight.toDouble()
|
||||
var block = setting.blockWeight.toDouble()
|
||||
var reach = setting.reachWeight.toDouble()
|
||||
// Gentle is a table rule, not a skill level: it stays as it is.
|
||||
if (setting.blockWeight > 0) {
|
||||
when (profile.level) {
|
||||
AiLevel.EASY -> { block *= 0.4; reach *= 0.5 }
|
||||
AiLevel.MEDIUM -> Unit
|
||||
AiLevel.HARD -> { block *= 1.35; reach *= 1.5 }
|
||||
}
|
||||
own *= 1.0 + 0.15 * profile.style.boldness
|
||||
block *= 1.0 - 0.15 * profile.style.boldness + 0.4 * profile.style.spite
|
||||
}
|
||||
return Weights(own, block, reach)
|
||||
}
|
||||
|
||||
override fun chooseMove(state: TayuState, seat: Int, legal: List<TayuMove>): TayuMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
return legal.maxByOrNull { rate(state, seat, it, setting) } ?: legal.first()
|
||||
val weights = weightsOf(setting, profile)
|
||||
val rated = legal.map { it to rate(state, seat, it, weights) }
|
||||
if (profile.level != AiLevel.EASY) return rated.maxByOrNull { it.second }?.first ?: legal.first()
|
||||
// Easy: one of its better few placements, not always the best.
|
||||
val ranked = rated.sortedByDescending { it.second }.take(EASY_POOL).map { it.first }
|
||||
return profile.pick(ranked)
|
||||
}
|
||||
|
||||
private fun rate(state: TayuState, seat: Int, move: TayuMove, setting: TayuLevel): Int {
|
||||
private fun rate(state: TayuState, seat: Int, move: TayuMove, weights: Weights): Double {
|
||||
val after = TayuRules.previewPlacement(state, seat, move)
|
||||
val mine = state.axisOf(seat)
|
||||
val theirs = if (mine == Axis.NORTH_SOUTH) Axis.EAST_WEST else Axis.NORTH_SOUTH
|
||||
@@ -43,15 +82,15 @@ class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove>
|
||||
// Skipped rather than multiplied by zero: the gentle setting is the whole
|
||||
// reason to avoid walking the open mouths at all, and this runs for every
|
||||
// one of a thousand-odd candidate placements.
|
||||
val reach = if (setting.reachWeight == 0) {
|
||||
val reach = if (weights.reach == 0.0) {
|
||||
0
|
||||
} else {
|
||||
(pull(after, mine) - pull(after, theirs)) / REACH_SCALE
|
||||
}
|
||||
|
||||
return setting.ownWeight * ours -
|
||||
setting.blockWeight * opposing +
|
||||
setting.reachWeight * reach
|
||||
return weights.own * ours -
|
||||
weights.block * opposing +
|
||||
weights.reach * reach
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,5 +121,8 @@ class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove>
|
||||
private companion object {
|
||||
/** Brings the open-mouth term onto the same scale as the score term. */
|
||||
const val REACH_SCALE = 8
|
||||
|
||||
/** How many top-rated placements Easy chooses among. */
|
||||
const val EASY_POOL = 6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@ package org.prolibertate.games.game.wizard
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.cards.Decks
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -15,52 +20,99 @@ import kotlin.math.roundToInt
|
||||
* high it is and whether it is trump. In play it chases tricks while it is
|
||||
* short of its bid and ducks once it has them, which is the whole game.
|
||||
*/
|
||||
class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
class WizardAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<WizardState, WizardMove> {
|
||||
|
||||
enum class Difficulty(
|
||||
/**
|
||||
* Added to the expected-trick count before rounding. Optimists overbid
|
||||
* and go down; pessimists underbid and get caught taking tricks they
|
||||
* did not want.
|
||||
*/
|
||||
val bidBias: Double,
|
||||
) {
|
||||
EASY(bidBias = 0.35),
|
||||
NORMAL(bidBias = 0.0),
|
||||
CAUTIOUS(bidBias = -0.25),
|
||||
private companion object {
|
||||
/** Optimists overbid and go down; Easy leans this way. */
|
||||
const val EASY_BID_BIAS = 0.35
|
||||
val DECK: List<Card> = Decks.wizard()
|
||||
}
|
||||
|
||||
/** Cards this AI has watched played in the current round, per seat it plays. */
|
||||
private val memory = HashMap<Int, SeenCards>()
|
||||
|
||||
private class SeenCards {
|
||||
var key: Long = Long.MIN_VALUE
|
||||
val cards = HashSet<Card>()
|
||||
}
|
||||
|
||||
/** Public information only: cards that have appeared on the table this round. */
|
||||
private fun observe(state: WizardState, seat: Int): Set<Card> = synchronized(memory) {
|
||||
val seen = memory.getOrPut(seat) { SeenCards() }
|
||||
val key = state.seed * 1_000_003L + state.round
|
||||
if (key != seen.key || state.phase == WizardPhase.BIDDING) {
|
||||
seen.key = key
|
||||
seen.cards.clear()
|
||||
}
|
||||
state.trick.forEach { seen.cards.add(it.card) }
|
||||
state.completedTrick.forEach { seen.cards.add(it.card) }
|
||||
seen.cards.removeAll(state.hands[seat].toSet())
|
||||
seen.cards.toSet()
|
||||
}
|
||||
|
||||
override fun chooseMove(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
return when (state.phase) {
|
||||
WizardPhase.BIDDING -> chooseBid(state, seat, legal)
|
||||
WizardPhase.PLAYING -> choosePlay(state, seat, legal)
|
||||
else -> legal.first()
|
||||
val profile = profiles.of(seat)
|
||||
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
|
||||
val move = try {
|
||||
when (state.phase) {
|
||||
WizardPhase.BIDDING -> chooseBid(state, seat, legal, profile)
|
||||
WizardPhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
|
||||
else -> legal.first()
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
legal.first()
|
||||
}
|
||||
return if (move in legal) move else legal.first()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bidding
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseBid(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||
private fun chooseBid(
|
||||
state: WizardState,
|
||||
seat: Int,
|
||||
legal: List<WizardMove>,
|
||||
profile: AiProfile,
|
||||
): WizardMove {
|
||||
val hand = state.hands[seat]
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val easy = profile.level == AiLevel.EASY
|
||||
|
||||
val naming = legal.filterIsInstance<ChooseTrump>()
|
||||
if (naming.isNotEmpty()) {
|
||||
val hand = state.hands[seat]
|
||||
val best = naming.maxByOrNull { suitStrength(hand, it.suit) }
|
||||
return best ?: naming.first()
|
||||
return when {
|
||||
hard -> naming.maxByOrNull { hardExpected(state, hand, it.suit) }
|
||||
easy -> {
|
||||
val noisy = naming.associateWith { suitStrength(hand, it.suit) + profile.random.nextDouble(-0.8, 0.8) }
|
||||
profile.pick(naming.sortedByDescending { noisy.getValue(it) })
|
||||
}
|
||||
else -> naming.maxByOrNull { suitStrength(hand, it.suit) }
|
||||
} ?: naming.first()
|
||||
}
|
||||
|
||||
val bids = legal.filterIsInstance<MakeBid>()
|
||||
if (bids.isEmpty()) return legal.first()
|
||||
|
||||
val wanted = (expectedTricks(state.hands[seat], state.trump) + difficulty.bidBias)
|
||||
.roundToInt()
|
||||
.coerceIn(0, state.cardsThisRound)
|
||||
var expected = if (hard) hardExpected(state, hand, state.trump) else expectedTricks(hand, state.trump)
|
||||
if (hard) {
|
||||
// The last to bid knows how many tricks the others have left on the table.
|
||||
val others = state.bids.filterNotNull()
|
||||
if (others.size == state.options.playerCount - 1) {
|
||||
expected = expected * 0.75 + (state.cardsThisRound - others.sum()) * 0.25
|
||||
}
|
||||
}
|
||||
if (easy) expected += EASY_BID_BIAS + profile.random.nextDouble(-0.8, 0.8)
|
||||
// A gambler bids up, a cautious seat bids down.
|
||||
expected += profile.bolder(0.0, 0.3)
|
||||
|
||||
val wanted = expected.roundToInt().coerceIn(0, state.cardsThisRound)
|
||||
// Screw-the-dealer may have taken the number we wanted away, so settle
|
||||
// for the nearest one still on offer.
|
||||
return bids.minBy { abs(it.tricks - wanted) }
|
||||
val ranked = bids.sortedBy { abs(it.tricks - wanted) }
|
||||
return if (easy) profile.pick(ranked) else ranked.first()
|
||||
}
|
||||
|
||||
/** How much of a hand a given trump suit would be worth. */
|
||||
@@ -92,22 +144,88 @@ class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
return expected
|
||||
}
|
||||
|
||||
/**
|
||||
* The chance [card] takes a trick, from the cards that could still beat it.
|
||||
*
|
||||
* [pool] is every card that might be in somebody else's hand and [q] the
|
||||
* chance any one of them is, and each higher card only threatens the trick
|
||||
* if it lands in the same one, which is less likely the longer the round.
|
||||
*/
|
||||
private fun winProb(card: Card, pool: List<Card>, trump: Suit?, q: Double, tricks: Int): Double {
|
||||
if (isWizardCard(card)) return 1.0
|
||||
if (isJester(card)) return 0.0
|
||||
val wizards = pool.count { isWizardCard(it) && it != card }
|
||||
val ordinary = { c: Card -> !isWizardCard(c) && !isJester(c) }
|
||||
val trumpCard = trump != null && card.suit == trump
|
||||
val higher = pool.count { ordinary(it) && it.suit == card.suit && it.rank.order > card.rank.order }
|
||||
var danger = (higher + wizards).toDouble()
|
||||
if (!trumpCard && trump != null) {
|
||||
val voidFactor = 1.0 / (1.0 + 0.5 * (tricks - 1))
|
||||
danger += voidFactor * pool.count { ordinary(it) && it.suit == trump }
|
||||
}
|
||||
val sequencing = 1.0 / (1.0 + 0.35 * (tricks - 1))
|
||||
return (1.0 - (q * sequencing).coerceIn(0.0, 1.0)).pow(danger)
|
||||
}
|
||||
|
||||
/** Expected tricks from the cards that could still be out, calibrated so the average hand scores its fair share. */
|
||||
private fun hardExpected(state: WizardState, hand: List<Card>, trump: Suit?): Double {
|
||||
val n = state.cardsThisRound
|
||||
val players = state.options.playerCount
|
||||
val inPlay = DECK.filter { it != state.trumpCard }
|
||||
val mine = inPlay.filter { it !in hand }
|
||||
val q = ((players - 1) * n).toDouble() / mine.size.coerceAtLeast(1)
|
||||
|
||||
val own = hand.sumOf { winProb(it, mine, trump, q, n) }
|
||||
val average = inPlay.sumOf { winProb(it, inPlay, trump, q, n) } / inPlay.size
|
||||
val scale = if (average > 0.0) (1.0 / (players * average)).coerceIn(0.5, 2.0) else 1.0
|
||||
return own * scale
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Play
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlay(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
|
||||
private fun choosePlay(
|
||||
state: WizardState,
|
||||
seat: Int,
|
||||
legal: List<WizardMove>,
|
||||
profile: AiProfile,
|
||||
seen: Set<Card>,
|
||||
): WizardMove {
|
||||
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||
if (cards.size == 1) return PlayCard(cards.first())
|
||||
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val bid = state.bids[seat] ?: 0
|
||||
val stillNeeded = bid - state.tricksWon[seat]
|
||||
val tricksLeft = state.hands[seat].size
|
||||
// Chase while we are short, and only while there are enough tricks left
|
||||
// to be worth chasing.
|
||||
val wantsTrick = stillNeeded > 0 && stillNeeded <= tricksLeft
|
||||
// to be worth chasing. (The careful player keeps chasing even when the
|
||||
// bid is out of reach: every trick still cuts the penalty.)
|
||||
val wantsTrick = stillNeeded > 0 && (hard || stillNeeded <= tricksLeft)
|
||||
// A spoiler who has already blown their own bid takes tricks off anyone
|
||||
// who needs exactly one more.
|
||||
val spoiling = profile.style.spite >= 0.5 && stillNeeded < 0
|
||||
|
||||
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, state.trump, wantsTrick))
|
||||
val best = if (hard) {
|
||||
hardPlay(state, seat, cards, seen, wantsTrick, spoiling, stillNeeded, tricksLeft)
|
||||
} else {
|
||||
standardPlay(state, cards, wantsTrick, spoiling)
|
||||
}
|
||||
if (profile.level != AiLevel.EASY) return PlayCard(best)
|
||||
|
||||
// Easy sometimes burns a strong card at the wrong moment.
|
||||
val ranked = listOf(best) + (cards - best).sortedByDescending { it.rank.order }
|
||||
return PlayCard(profile.pick(ranked))
|
||||
}
|
||||
|
||||
private fun standardPlay(
|
||||
state: WizardState,
|
||||
cards: List<Card>,
|
||||
wantsTrick: Boolean,
|
||||
spoiling: Boolean,
|
||||
): Card {
|
||||
if (state.trick.isEmpty()) return chooseLead(cards, state.trump, wantsTrick)
|
||||
|
||||
val ledSuit = ledSuitOf(state.trick)
|
||||
val order = state.trick.size
|
||||
@@ -122,15 +240,99 @@ class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
|
||||
|
||||
return when {
|
||||
// Take it as cheaply as we can.
|
||||
wantsTrick && winners.isNotEmpty() -> PlayCard(winners.minBy { strengthOf(it) })
|
||||
wantsTrick && winners.isNotEmpty() -> winners.minBy { strengthOf(it) }
|
||||
// Spite: stop somebody from landing the bid they need.
|
||||
spoiling && winners.isNotEmpty() && victimNeedsOne(state) -> winners.minBy { strengthOf(it) }
|
||||
// Cannot take it, or do not want it: throw the least useful card.
|
||||
losers.isNotEmpty() -> PlayCard(losers.minBy { strengthOf(it) })
|
||||
losers.isNotEmpty() -> losers.minBy { strengthOf(it) }
|
||||
// Everything we hold wins, and we did not want the trick. Take it
|
||||
// with the weakest winner so the good cards stay for later.
|
||||
else -> PlayCard(cards.minBy { strengthOf(it) })
|
||||
else -> cards.minBy { strengthOf(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether whoever is winning the trick right now needs exactly this trick to make their bid. */
|
||||
private fun victimNeedsOne(state: WizardState): Boolean {
|
||||
val ledSuit = ledSuitOf(state.trick)
|
||||
val leading = state.trick
|
||||
.withIndex()
|
||||
.maxByOrNull { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
|
||||
?: return false
|
||||
val victim = leading.value.seat
|
||||
return (state.bids[victim] ?: return false) - state.tricksWon[victim] == 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The strong player's choice. It keeps track of which cards are gone to
|
||||
* judge how likely each of its own is to win, spends sure winners when it
|
||||
* needs tricks, and sheds its most dangerous cards onto tricks it will
|
||||
* lose anyway when it does not.
|
||||
*/
|
||||
private fun hardPlay(
|
||||
state: WizardState,
|
||||
seat: Int,
|
||||
cards: List<Card>,
|
||||
seen: Set<Card>,
|
||||
wantsTrick: Boolean,
|
||||
spoiling: Boolean,
|
||||
stillNeeded: Int,
|
||||
tricksLeft: Int,
|
||||
): Card {
|
||||
val hand = state.hands[seat]
|
||||
val unseen = DECK.filter { it != state.trumpCard && it !in seen && it !in hand }
|
||||
val oppCards = state.handCounts.sum() - hand.size
|
||||
val q = if (unseen.isEmpty()) 0.0 else (oppCards.toDouble() / unseen.size).coerceIn(0.0, 1.0)
|
||||
val trump = state.trump
|
||||
fun pWin(card: Card): Double = winProb(card, unseen, trump, q, tricksLeft)
|
||||
|
||||
if (state.trick.isEmpty()) {
|
||||
if (wantsTrick) {
|
||||
val scored = cards.map { it to pWin(it) }
|
||||
val bestOverall = scored.maxBy { it.second }
|
||||
// With tricks to spare, save the wizard for when it is needed.
|
||||
if (tricksLeft > stillNeeded) {
|
||||
val bestPlain = scored.filter { !isWizardCard(it.first) }.maxByOrNull { it.second }
|
||||
if (bestPlain != null && bestPlain.second >= 0.6) return bestPlain.first
|
||||
}
|
||||
return bestOverall.first
|
||||
}
|
||||
// Ducking: lead whatever is least likely to win, a jester best of all.
|
||||
return cards.minWith(compareBy<Card>({ pWin(it) }, { it.rank.order }))
|
||||
}
|
||||
|
||||
val ledSuit = ledSuitOf(state.trick)
|
||||
val order = state.trick.size
|
||||
val remaining = state.options.playerCount - order - 1
|
||||
val bestOnTable = state.trick
|
||||
.withIndex()
|
||||
.maxOf { (index, played) -> trickStrength(played.card, index, trump, ledSuit) }
|
||||
val strengthOf = { card: Card -> trickStrength(card, order, trump, ledSuit) }
|
||||
val winners = cards.filter { strengthOf(it) > bestOnTable }
|
||||
val losers = cards.filter { strengthOf(it) <= bestOnTable }
|
||||
|
||||
fun sure(card: Card): Boolean =
|
||||
remaining == 0 || unseen.none { trickStrength(it, order + 1, trump, ledSuit) > strengthOf(card) }
|
||||
|
||||
if (wantsTrick) {
|
||||
if (winners.isNotEmpty()) {
|
||||
val certain = winners.filter { sure(it) }
|
||||
return (certain.ifEmpty { winners }).minBy { strengthOf(it) }
|
||||
}
|
||||
// Cannot take this one: keep the strong cards for a trick that can be won.
|
||||
return losers.minBy { strengthOf(it) }
|
||||
}
|
||||
if (spoiling && winners.isNotEmpty() && victimNeedsOne(state)) {
|
||||
return winners.minBy { strengthOf(it) }
|
||||
}
|
||||
if (losers.isNotEmpty()) {
|
||||
// Unload the card that would be hardest to lose later, but keep the
|
||||
// jester: it is the one card that can never win.
|
||||
val nonJester = losers.filter { !isJester(it) }
|
||||
return (nonJester.ifEmpty { losers }).maxBy { strengthOf(it) }
|
||||
}
|
||||
return cards.minBy { strengthOf(it) }
|
||||
}
|
||||
|
||||
private fun chooseLead(cards: List<Card>, trump: Suit?, wantsTrick: Boolean): Card {
|
||||
if (wantsTrick) {
|
||||
// A led wizard is an unloseable trick.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Plays the dice by eye rather than by arithmetic.
|
||||
@@ -10,33 +15,53 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* enumerated the thirty-two subsets and applied them would be reading the roll
|
||||
* before making it — beating the player by seeing the future rather than by
|
||||
* playing well. Everything below decides from the dice already on the table.
|
||||
*
|
||||
* Per seat, from [profiles]: Easy sometimes holds a poor set of dice and
|
||||
* sometimes writes a lesser box than the best on offer. Hard works out, for
|
||||
* every way of holding dice, the *expected* value of what could be thrown
|
||||
* (averaging over all outcomes, never over the roll that will actually come),
|
||||
* valuing each box by what it scores less what that box is usually worth, with
|
||||
* the upper-section bonus tracked. Boldness chases the bonus and big combos
|
||||
* and rolls on with slimmer odds; caution takes a sure score sooner.
|
||||
*/
|
||||
object YahtzeeAi : GameAi<YahtzeeState, YahtzeeMove> {
|
||||
class YahtzeeAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
|
||||
GameAi<YahtzeeState, YahtzeeMove> {
|
||||
|
||||
override fun chooseMove(state: YahtzeeState, seat: Int, legal: List<YahtzeeMove>): YahtzeeMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val profile = profiles.of(seat)
|
||||
val rolls = legal.filterIsInstance<RollDice>()
|
||||
val writes = legal.filterIsInstance<ScoreIn>()
|
||||
|
||||
// Nothing on the table yet, so there is only one thing to do.
|
||||
if (!state.hasRolled) return rolls.firstOrNull() ?: writes.first()
|
||||
if (profile.level == AiLevel.HARD && writes.isNotEmpty()) {
|
||||
return chooseExpected(state, seat, rolls, writes, profile)
|
||||
}
|
||||
|
||||
val best = writes.maxByOrNull { scoreOf(it.category, state.dice) }
|
||||
val ranked = writes.sortedByDescending { scoreOf(it.category, state.dice) }
|
||||
// Easy sometimes writes the second or third best box rather than the best.
|
||||
val best = if (ranked.isEmpty()) null else profile.pick(ranked.take(EASY_BOXES))
|
||||
val bestScore = best?.let { scoreOf(it.category, state.dice) } ?: 0
|
||||
|
||||
// A hand already worth taking, or no rolls left, ends the turn.
|
||||
if (rolls.isEmpty()) return best ?: writes.first()
|
||||
if (bestScore >= WORTH_KEEPING) return best!!
|
||||
if (bestScore >= profile.bolder(WORTH_KEEPING.toDouble(), 4.0)) return best!!
|
||||
|
||||
val keep = whatToKeep(state.dice).toSet()
|
||||
val keep = if (profile.slips()) poorKeep(state.dice) else whatToKeep(state.dice).toSet()
|
||||
return rolls.firstOrNull { it.keep == keep } ?: rolls.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Good enough to stop throwing. Set at a full house, which is the point
|
||||
* where a hand is worth more than an average reroll of it.
|
||||
*/
|
||||
private const val WORTH_KEEPING = 25
|
||||
/** What a distracted player holds: the high dice, or the pair they notice second. */
|
||||
private fun poorKeep(dice: List<Int>): Set<Int> {
|
||||
val counts = countsOf(dice)
|
||||
val second = counts.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<Int, Int>> { it.value }.thenBy { it.key })
|
||||
.drop(1)
|
||||
.firstOrNull { it.value >= 2 }
|
||||
if (second != null) return dice.indices.filter { dice[it] == second.key }.toSet()
|
||||
return dice.indices.filter { dice[it] >= 4 }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* What to hold on to: a run if one is nearly there, otherwise the face there
|
||||
@@ -82,4 +107,213 @@ object YahtzeeAi : GameAi<YahtzeeState, YahtzeeMove> {
|
||||
face in wanted && taken.add(face)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Hard: expected values ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Values every box as what it scores now less what it is normally worth, so
|
||||
* that writing a zero in Ones costs little and writing one in Yahtzee costs a
|
||||
* lot, then looks two throws ahead by averaging over every possible outcome
|
||||
* of every way of holding dice.
|
||||
*/
|
||||
private fun chooseExpected(
|
||||
state: YahtzeeState,
|
||||
seat: Int,
|
||||
rolls: List<RollDice>,
|
||||
writes: List<ScoreIn>,
|
||||
profile: AiProfile,
|
||||
): YahtzeeMove {
|
||||
val valuation = Valuation(state, seat, profile.style.boldness)
|
||||
val dice = state.dice
|
||||
val stop = writes.maxByOrNull { valuation.gain(it.category, dice) } ?: writes.first()
|
||||
if (rolls.isEmpty()) return stop
|
||||
val stopValue = valuation.gain(stop.category, dice)
|
||||
|
||||
// terminal[i]: best write for the i-th multiset of dice with no throws left.
|
||||
val terminal = DoubleArray(DiceTables.multisets.size) { i ->
|
||||
val faces = DiceTables.facesOf(i)
|
||||
writes.maxOf { valuation.gain(it.category, faces) }
|
||||
}
|
||||
// Each throw still to come after the next one, worked backwards.
|
||||
var next = terminal
|
||||
repeat(state.rollsLeft - 1) {
|
||||
val previous = next
|
||||
next = DoubleArray(terminal.size) { i ->
|
||||
maxOf(terminal[i], bestKeep(i, previous).second)
|
||||
}
|
||||
}
|
||||
val here = DiceTables.indexOf(countVector(dice))
|
||||
val (keepCounts, expected) = bestKeep(here, next)
|
||||
val margin = -profile.style.boldness
|
||||
if (expected <= stopValue + margin) return stop
|
||||
|
||||
val remaining = keepCounts.copyOf()
|
||||
val keep = dice.indices.filter { position ->
|
||||
val face = dice[position] - 1
|
||||
if (remaining[face] > 0) {
|
||||
remaining[face]--
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}.toSet()
|
||||
return rolls.firstOrNull { it.keep == keep } ?: stop
|
||||
}
|
||||
|
||||
/** The best set of dice to hold from multiset [index], and what throwing the rest is worth. */
|
||||
private fun bestKeep(index: Int, after: DoubleArray): Pair<IntArray, Double> {
|
||||
var bestKeep = DiceTables.keepsOf(index).first()
|
||||
var best = Double.NEGATIVE_INFINITY
|
||||
for (keep in DiceTables.keepsOf(index)) {
|
||||
val thrown = DICE_COUNT - keep.sum()
|
||||
var key = 0
|
||||
for (face in 0 until 6) key += keep[face] * DiceTables.POWERS[face]
|
||||
var total = 0.0
|
||||
for (outcome in DiceTables.rerolls[thrown]) {
|
||||
total += outcome.probability * after[DiceTables.indexOfKey[key + outcome.key]]
|
||||
}
|
||||
if (total > best + 1e-9) {
|
||||
best = total
|
||||
bestKeep = keep
|
||||
}
|
||||
}
|
||||
return bestKeep to best
|
||||
}
|
||||
|
||||
private fun countVector(dice: List<Int>): IntArray {
|
||||
val counts = IntArray(6)
|
||||
dice.forEach { counts[it - 1]++ }
|
||||
return counts
|
||||
}
|
||||
|
||||
/** What a box is worth to this card right now, in points relative to its usual yield. */
|
||||
private class Valuation(state: YahtzeeState, seat: Int, private val boldness: Double) {
|
||||
private val card = state.cards[seat]
|
||||
private val yahtzeeBonus = state.options.yahtzeeBonus && card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE
|
||||
private val openUpper = YahtzeeCategory.entries.filter {
|
||||
it.section == YahtzeeSection.UPPER && !card.isFilled(it)
|
||||
}
|
||||
private val secured = card.upperSubtotal >= UPPER_BONUS_AT
|
||||
private val diff = YahtzeeCategory.entries
|
||||
.filter { it.section == YahtzeeSection.UPPER && card.isFilled(it) }
|
||||
.sumOf { (card[it] ?: 0) - 3 * it.face!! }
|
||||
private val bonusValue = UPPER_BONUS * (1.0 + 0.4 * boldness)
|
||||
|
||||
fun gain(category: YahtzeeCategory, dice: List<Int>): Double {
|
||||
val score = scoreOf(category, dice)
|
||||
var value = score - TYPICAL[category.ordinal]
|
||||
if (category.section == YahtzeeSection.UPPER && !secured) {
|
||||
val face = category.face!!
|
||||
val after = if (card.upperSubtotal + score >= UPPER_BONUS_AT) {
|
||||
1.0
|
||||
} else {
|
||||
chance(diff + score - 3 * face, openUpper - category)
|
||||
}
|
||||
value += bonusValue * (after - chance(diff, openUpper))
|
||||
}
|
||||
// The big combinations: a bold seat values them a little more.
|
||||
if (score > 0 && (category == YahtzeeCategory.LARGE_STRAIGHT ||
|
||||
category == YahtzeeCategory.YAHTZEE || category == YahtzeeCategory.FOUR_OF_A_KIND)
|
||||
) {
|
||||
value += boldness * 2.0
|
||||
}
|
||||
if (yahtzeeBonus && isYahtzee(dice)) value += YAHTZEE_BONUS
|
||||
return value
|
||||
}
|
||||
|
||||
/** Likelihood the upper section still reaches the bonus from [difference] with [open] to fill. */
|
||||
private fun chance(difference: Int, open: List<YahtzeeCategory>): Double {
|
||||
if (open.isEmpty()) return if (difference >= 0) 1.0 else 0.0
|
||||
val expected = open.sumOf { TYPICAL[it.ordinal] - 3 * it.face!! }
|
||||
val spread = 2.2 * sqrt(open.size.toDouble()) + 0.3
|
||||
return 1.0 / (1.0 + exp(-(difference + expected + 1.5) / spread))
|
||||
}
|
||||
}
|
||||
|
||||
/** Every multiset of five dice and every throw of n dice, worked out once. */
|
||||
private object DiceTables {
|
||||
class Outcome(val key: Int, val probability: Double)
|
||||
|
||||
val POWERS = intArrayOf(1, 6, 36, 216, 1296, 7776)
|
||||
val indexOfKey = IntArray(46656) { -1 }
|
||||
val multisets: List<IntArray> = vectors(DICE_COUNT)
|
||||
val rerolls: Array<List<Outcome>> = Array(DICE_COUNT + 1) { n ->
|
||||
val factorial = IntArray(6) { f -> (1..f).fold(1) { a, b -> a * b } }
|
||||
val total = Math.pow(6.0, n.toDouble())
|
||||
vectors(n).map { counts ->
|
||||
val ways = (1..n).fold(1) { a, b -> a * b } / counts.fold(1) { a, c -> a * factorial[c] }
|
||||
Outcome(keyOf(counts), ways / total)
|
||||
}
|
||||
}
|
||||
private val keeps: Array<List<IntArray>?> = arrayOfNulls(multisets.size)
|
||||
|
||||
init {
|
||||
multisets.forEachIndexed { i, counts -> indexOfKey[keyOf(counts)] = i }
|
||||
}
|
||||
|
||||
fun indexOf(counts: IntArray): Int = indexOfKey[keyOf(counts)]
|
||||
|
||||
fun facesOf(index: Int): List<Int> {
|
||||
val faces = ArrayList<Int>(DICE_COUNT)
|
||||
multisets[index].forEachIndexed { f, c -> repeat(c) { faces += f + 1 } }
|
||||
return faces
|
||||
}
|
||||
|
||||
/** Every distinct set of dice that can be held back from multiset [index]. */
|
||||
fun keepsOf(index: Int): List<IntArray> {
|
||||
keeps[index]?.let { return it }
|
||||
val counts = multisets[index]
|
||||
var all = listOf(IntArray(6))
|
||||
for (face in 0 until 6) {
|
||||
all = all.flatMap { partial ->
|
||||
(0..counts[face]).map { n -> partial.copyOf().also { it[face] = n } }
|
||||
}
|
||||
}
|
||||
keeps[index] = all
|
||||
return all
|
||||
}
|
||||
|
||||
private fun keyOf(counts: IntArray): Int {
|
||||
var key = 0
|
||||
for (face in 0 until 6) key += counts[face] * POWERS[face]
|
||||
return key
|
||||
}
|
||||
|
||||
/** Every way to split [size] dice over six faces. */
|
||||
private fun vectors(size: Int): List<IntArray> {
|
||||
val out = ArrayList<IntArray>()
|
||||
fun build(face: Int, left: Int, current: IntArray) {
|
||||
if (face == 5) {
|
||||
current[5] = left
|
||||
out += current.copyOf()
|
||||
return
|
||||
}
|
||||
for (n in 0..left) {
|
||||
current[face] = n
|
||||
build(face + 1, left - n, current)
|
||||
}
|
||||
}
|
||||
build(0, size, IntArray(6))
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Good enough to stop throwing. Set at a full house, which is the point
|
||||
* where a hand is worth more than an average reroll of it.
|
||||
*/
|
||||
const val WORTH_KEEPING = 25
|
||||
|
||||
/** How many of the best boxes Easy chooses among when it writes a score. */
|
||||
const val EASY_BOXES = 3
|
||||
|
||||
/**
|
||||
* What each box yields on average under strong play, in the order of
|
||||
* [YahtzeeCategory]: the cost of spending it.
|
||||
*/
|
||||
val TYPICAL = doubleArrayOf(
|
||||
2.11, 5.28, 8.57, 12.16, 15.69, 19.19, 21.66, 13.10, 22.59, 29.46, 32.71, 16.87, 22.01,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.prolibertate.games.game.yahtzee
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** What one finished game says about the local player's card. */
|
||||
@Serializable
|
||||
data class YahtzeeRecord(
|
||||
val total: Int,
|
||||
val gotBonus: Boolean,
|
||||
/** Yahtzees scored, counting the extra ones that earned the hundred. */
|
||||
val yahtzees: Int,
|
||||
/** Boxes written in as nothing. */
|
||||
val zeros: Int,
|
||||
val players: Int,
|
||||
val won: Boolean,
|
||||
)
|
||||
|
||||
/** Builds the record of [seat]'s card in a finished game. Ties for first count as a win. */
|
||||
fun recordOf(state: YahtzeeState, seat: Int): YahtzeeRecord {
|
||||
val card = state.cards[seat]
|
||||
val mine = state.totalFor(seat)
|
||||
val best = state.cards.indices.maxOf { state.totalFor(it) }
|
||||
return YahtzeeRecord(
|
||||
total = mine,
|
||||
gotBonus = card.upperBonus > 0,
|
||||
yahtzees = (if (card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE) 1 else 0) +
|
||||
state.yahtzeeBonuses[seat] / YAHTZEE_BONUS,
|
||||
zeros = card.boxes.count { it == 0 },
|
||||
players = state.cards.size,
|
||||
won = mine >= best,
|
||||
)
|
||||
}
|
||||
|
||||
/** A game set against the ones before it. [rank] is 1 for a new best. */
|
||||
data class YahtzeeComparison(
|
||||
val current: YahtzeeRecord,
|
||||
val previousGames: Int,
|
||||
val bestBefore: Int?,
|
||||
val averageBefore: Double?,
|
||||
val rank: Int,
|
||||
val gamesRanked: Int,
|
||||
val bonusRateBefore: Double?,
|
||||
val yahtzeesBefore: Int,
|
||||
val winRateBefore: Double?,
|
||||
)
|
||||
|
||||
fun compareToHistory(previous: List<YahtzeeRecord>, current: YahtzeeRecord): YahtzeeComparison =
|
||||
YahtzeeComparison(
|
||||
current = current,
|
||||
previousGames = previous.size,
|
||||
bestBefore = previous.maxOfOrNull { it.total },
|
||||
averageBefore = previous.takeIf { it.isNotEmpty() }?.map { it.total }?.average(),
|
||||
rank = 1 + previous.count { it.total > current.total },
|
||||
gamesRanked = previous.size + 1,
|
||||
bonusRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.gotBonus }.toDouble() / p.size },
|
||||
yahtzeesBefore = previous.sumOf { it.yahtzees },
|
||||
winRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.won }.toDouble() / p.size },
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.prolibertate.games.game.engine.AiDifficulty
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
@@ -33,6 +34,7 @@ class LobbyController(
|
||||
val connected: Boolean = false,
|
||||
val gameId: String? = null,
|
||||
val optionsJson: String = "{}",
|
||||
val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
|
||||
val hostName: String = "",
|
||||
val seats: List<PlayerSlot> = emptyList(),
|
||||
val discovered: List<DiscoveredHost> = emptyList(),
|
||||
@@ -88,6 +90,7 @@ class LobbyController(
|
||||
hostName: String,
|
||||
seatCount: Int,
|
||||
teamOf: (Int) -> Int,
|
||||
aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
|
||||
) {
|
||||
stop()
|
||||
val seats = (0 until seatCount).map { seat ->
|
||||
@@ -102,6 +105,7 @@ class LobbyController(
|
||||
hosting = true,
|
||||
gameId = gameId,
|
||||
optionsJson = optionsJson,
|
||||
aiDifficulty = aiDifficulty,
|
||||
hostName = hostName,
|
||||
seats = seats,
|
||||
message = "Waiting for players…",
|
||||
@@ -237,6 +241,7 @@ class LobbyController(
|
||||
seats = current.seats,
|
||||
optionsJson = current.optionsJson,
|
||||
seed = seed,
|
||||
aiDifficulty = current.aiDifficulty,
|
||||
)
|
||||
scope.launch { connections.values.forEach { it.send(StartGame(config)) } }
|
||||
_state.value = current.copy(started = config)
|
||||
|
||||
@@ -96,6 +96,27 @@ class MatchController<S : Any, M : Any>(
|
||||
|
||||
/** Host only: the true state. Clients never see this. */
|
||||
private var authoritative: S? = null
|
||||
|
||||
/** A position to carry on from instead of dealing, set by [restore] before [startAsHost]. */
|
||||
private var restored: S? = null
|
||||
|
||||
/**
|
||||
* Host only: told about every position the table reaches, so it can be kept
|
||||
* for later. Called under the table's lock, so it must return quickly — the
|
||||
* snapshot encodes its state only when asked, and the work of writing it
|
||||
* belongs to whoever is listening.
|
||||
*/
|
||||
var onHostState: ((HostSnapshot) -> Unit)? = null
|
||||
|
||||
/** One position of the table, as handed to [onHostState]. */
|
||||
class HostSnapshot(
|
||||
val finished: Boolean,
|
||||
val summary: String,
|
||||
private val encode: () -> String,
|
||||
) {
|
||||
/** The whole true state, encoded by the game's own rules. */
|
||||
fun stateJson(): String = encode()
|
||||
}
|
||||
private val lock = Mutex()
|
||||
private var sequence = 0
|
||||
|
||||
@@ -160,7 +181,7 @@ class MatchController<S : Any, M : Any>(
|
||||
check(role == Role.HOST) { "Not hosting" }
|
||||
clients.forEach { connection -> listenToPeer(connection) }
|
||||
scope.launch {
|
||||
val initial = runCatching { rules.initialState(config) }.getOrElse { error ->
|
||||
val initial = restored ?: runCatching { rules.initialState(config) }.getOrElse { error ->
|
||||
// A table the rules will not accept is a dead end, not a reason
|
||||
// to take the app down with it: this runs in the screen's own
|
||||
// scope, where nothing would catch it.
|
||||
@@ -175,6 +196,20 @@ class MatchController<S : Any, M : Any>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host: carry on from a saved position instead of dealing a new game.
|
||||
*
|
||||
* Call before [startAsHost]. Returns false, and changes nothing, if
|
||||
* [stateJson] cannot be read by this version of the game — a save made by an
|
||||
* older build, say — and the caller should deal a fresh table instead.
|
||||
*/
|
||||
fun restore(stateJson: String): Boolean {
|
||||
check(role == Role.HOST) { "Only a host can restore a game" }
|
||||
val state = runCatching { rules.decodeState(stateJson) }.getOrNull() ?: return false
|
||||
restored = state
|
||||
return true
|
||||
}
|
||||
|
||||
private fun listenToPeer(connection: Connection) {
|
||||
peers[connection.peerId] = connection
|
||||
peerJobs[connection.peerId] = scope.launch {
|
||||
@@ -349,7 +384,7 @@ class MatchController<S : Any, M : Any>(
|
||||
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
|
||||
return
|
||||
}
|
||||
if (move !in rules.legalMoves(current, seat)) {
|
||||
if (!rules.isLegalMove(current, seat, move)) {
|
||||
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
|
||||
return
|
||||
}
|
||||
@@ -424,7 +459,7 @@ class MatchController<S : Any, M : Any>(
|
||||
// throws on a move that is no longer legal, and there is nothing
|
||||
// above this to catch it.
|
||||
if (rules.currentSeat(live) != seat) return@withLock false
|
||||
if (move !in rules.legalMoves(live, seat)) return@withLock false
|
||||
if (!rules.isLegalMove(live, seat, move)) return@withLock false
|
||||
authoritative = rules.applyMove(live, seat, move)
|
||||
publish()
|
||||
true
|
||||
@@ -449,6 +484,16 @@ class MatchController<S : Any, M : Any>(
|
||||
}
|
||||
_finished.value = rules.isFinished(current)
|
||||
|
||||
if (role == Role.HOST) {
|
||||
onHostState?.invoke(
|
||||
HostSnapshot(
|
||||
finished = rules.isFinished(current),
|
||||
summary = rules.summary(current),
|
||||
encode = { rules.encodeState(current) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (slot in config.seats) {
|
||||
val peerId = slot.peerId ?: continue
|
||||
val connection = peers[peerId] ?: continue
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.prolibertate.games.net
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.prolibertate.games.game.engine.SavedGameStore
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Keeps the position of one table on disk as it is played.
|
||||
*
|
||||
* It is fed every position the host reaches ([offer]) and writes the newest one
|
||||
* off the main thread. Positions that arrive while a write is under way are
|
||||
* dropped in favour of the latest — a computer's turn can be several moves in a
|
||||
* second, and only where the game stands matters. A finished game is not kept:
|
||||
* its slot is cleared, so a won game is not offered back as one to resume.
|
||||
*
|
||||
* [flush] writes whatever has not been written yet, on the caller's thread. It
|
||||
* is what makes leaving the table, or the app going to the background, safe: the
|
||||
* background writer may not have caught up, and the process may not be given the
|
||||
* chance to let it.
|
||||
*/
|
||||
class MatchSaver(
|
||||
private val store: SavedGameStore,
|
||||
private val gameId: String,
|
||||
private val config: TableConfig,
|
||||
private val localSeat: Int,
|
||||
private val scope: CoroutineScope,
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
private val latest = MutableStateFlow<MatchController.HostSnapshot?>(null)
|
||||
private var worker: Job? = null
|
||||
private var written: MatchController.HostSnapshot? = null
|
||||
private var discarded = false
|
||||
|
||||
/** Starts the background writer. */
|
||||
fun start() {
|
||||
worker = scope.launch(Dispatchers.IO) {
|
||||
latest.filterNotNull().collectLatest { write(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands over the newest position. Cheap: the encoding happens on the writer. */
|
||||
fun offer(snapshot: MatchController.HostSnapshot) {
|
||||
latest.value = snapshot
|
||||
}
|
||||
|
||||
/** Writes the newest position now if the writer has not already. */
|
||||
fun flush() {
|
||||
latest.value?.let { write(it) }
|
||||
}
|
||||
|
||||
/** The game was ended on purpose: forget the saved position and write no more. */
|
||||
@Synchronized
|
||||
fun discard() {
|
||||
discarded = true
|
||||
worker?.cancel()
|
||||
worker = null
|
||||
runCatching { store.clear(gameId) }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
worker?.cancel()
|
||||
worker = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun write(snapshot: MatchController.HostSnapshot) {
|
||||
if (discarded || snapshot === written) return
|
||||
written = snapshot
|
||||
// Saving is a courtesy to the player: a full disk must not take the game
|
||||
// down with it.
|
||||
runCatching {
|
||||
if (snapshot.finished) {
|
||||
store.clear(gameId)
|
||||
} else {
|
||||
store.save(
|
||||
SavedMatch(
|
||||
gameId = gameId,
|
||||
config = config,
|
||||
localSeat = localSeat,
|
||||
stateJson = snapshot.stateJson(),
|
||||
savedAtMillis = clock(),
|
||||
summary = snapshot.summary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.prolibertate.games.score
|
||||
|
||||
/** The short list of names offered when a column is being named. */
|
||||
object RecentNames {
|
||||
const val LIMIT = 10
|
||||
|
||||
/**
|
||||
* [name] moved to the front of [names], trimmed, with any earlier copy
|
||||
* (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name
|
||||
* leaves the list as it was.
|
||||
*/
|
||||
fun with(names: List<String>, name: String): List<String> {
|
||||
val typed = name.trim()
|
||||
if (typed.isEmpty()) return names
|
||||
return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) {
|
||||
|
||||
private object Keys {
|
||||
val SHEET = stringPreferencesKey("sheet")
|
||||
val RECENT_NAMES = stringPreferencesKey("recent_names")
|
||||
}
|
||||
|
||||
// Unknown keys are ignored so a sheet written by a newer build can still be
|
||||
@@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) {
|
||||
?: ScoreSheet()
|
||||
}
|
||||
|
||||
/** The names last used on a sheet, most recent first, for the rename dialog. */
|
||||
val recentNames: Flow<List<String>> = context.scorekeeperDataStore.data.map { prefs ->
|
||||
prefs[Keys.RECENT_NAMES]
|
||||
?.let { stored ->
|
||||
runCatching { json.decodeFromString<List<String>>(stored) }.getOrNull()
|
||||
}
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun rememberName(name: String) {
|
||||
context.scorekeeperDataStore.edit { prefs ->
|
||||
val current = prefs[Keys.RECENT_NAMES]
|
||||
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
|
||||
.orEmpty()
|
||||
prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(sheet: ScoreSheet) {
|
||||
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
|
||||
}
|
||||
|
||||
@@ -31,8 +31,18 @@ sealed interface SheetOp {
|
||||
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
|
||||
@Serializable
|
||||
@SerialName("start")
|
||||
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
|
||||
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
|
||||
data class StartSheet(
|
||||
val count: Int,
|
||||
val mode: ScoringMode = ScoringMode.ROUNDS,
|
||||
/** Names for the first seats, in order; a seat past the end stays unnamed. */
|
||||
val names: List<String> = emptyList(),
|
||||
) : SheetOp {
|
||||
override fun applyTo(sheet: ScoreSheet): ScoreSheet {
|
||||
val seated = ScoreSheet.of(count, mode)
|
||||
return seated.players.foldIndexed(seated) { index, acc, player ->
|
||||
names.getOrNull(index)?.let { acc.renamed(player.id, it) } ?: acc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rubs everything out. */
|
||||
|
||||
@@ -24,6 +24,8 @@ data class Settings(
|
||||
val soundEnabled: Boolean = true,
|
||||
val animationSpeed: Float = 1.0f,
|
||||
val checkForUpdatesOnLaunch: Boolean = true,
|
||||
/** Multiplies the system font size across the whole app. */
|
||||
val textScale: Float = 1.0f,
|
||||
/**
|
||||
* Exactly what the user has typed, which may be blank while they are
|
||||
* mid-edit. Nothing substitutes a default into this value: doing so meant
|
||||
@@ -46,6 +48,8 @@ data class Settings(
|
||||
companion object {
|
||||
const val MIN_SPEED = 0.5f
|
||||
const val MAX_SPEED = 2.0f
|
||||
const val MIN_TEXT_SCALE = 0.8f
|
||||
const val MAX_TEXT_SCALE = 1.6f
|
||||
const val DEFAULT_PLAYER_NAME = "Player"
|
||||
}
|
||||
}
|
||||
@@ -56,6 +60,7 @@ class SettingsRepository(private val context: Context) {
|
||||
val SOUND = booleanPreferencesKey("sound_enabled")
|
||||
val SPEED = floatPreferencesKey("animation_speed")
|
||||
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
|
||||
val TEXT_SCALE = floatPreferencesKey("text_scale")
|
||||
val NAME = stringPreferencesKey("player_name")
|
||||
// There is deliberately no update-channel key. The channel is a
|
||||
// property of the installed APK, not a preference — an install that
|
||||
@@ -69,6 +74,8 @@ class SettingsRepository(private val context: Context) {
|
||||
soundEnabled = prefs[Keys.SOUND] ?: true,
|
||||
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
||||
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
||||
textScale = (prefs[Keys.TEXT_SCALE] ?: 1.0f)
|
||||
.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE),
|
||||
playerName = prefs[Keys.NAME] ?: "",
|
||||
)
|
||||
}
|
||||
@@ -83,6 +90,12 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setTextScale(scale: Float) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.TEXT_SCALE] = scale.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
|
||||
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.prolibertate.games.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeRecord
|
||||
import org.prolibertate.games.game.yahtzee.compareToHistory
|
||||
|
||||
private val Context.yahtzeeStatsStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "yahtzee_stats",
|
||||
)
|
||||
|
||||
/** The local player's finished Yahtzee games, newest last, kept to the last [KEEP]. */
|
||||
class YahtzeeStatsRepository(private val context: Context) {
|
||||
|
||||
private val key = stringPreferencesKey("games")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun decode(stored: String?): List<YahtzeeRecord> =
|
||||
stored?.let { runCatching { json.decodeFromString<List<YahtzeeRecord>>(it) }.getOrNull() }
|
||||
.orEmpty()
|
||||
|
||||
/** Compares [record] with every earlier game, then adds it to the history. */
|
||||
suspend fun finish(record: YahtzeeRecord): YahtzeeComparison {
|
||||
val previous = decode(context.yahtzeeStatsStore.data.first()[key])
|
||||
context.yahtzeeStatsStore.edit {
|
||||
it[key] = json.encodeToString((previous + record).takeLast(KEEP))
|
||||
}
|
||||
return compareToHistory(previous, record)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEEP = 500
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -29,6 +31,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -47,9 +51,15 @@ import org.prolibertate.games.net.ScorekeeperSession
|
||||
import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||
import org.prolibertate.games.ui.game.LocalCardSpeed
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
import org.prolibertate.games.update.Updater
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.prolibertate.games.game.engine.AiDifficulty
|
||||
import org.prolibertate.games.game.engine.SavedGameStore
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
|
||||
/** Everything the screens need, assembled once by the activity. */
|
||||
class AppEnv(
|
||||
@@ -60,6 +70,9 @@ class AppEnv(
|
||||
/** The shared score sheet, for the Scorekeeper to host or join. */
|
||||
val scoreSession: ScorekeeperSession,
|
||||
val peerId: String,
|
||||
/** Games in progress, kept between sessions. */
|
||||
val saves: SavedGameStore,
|
||||
val yahtzeeStats: YahtzeeStatsRepository,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -76,13 +89,23 @@ sealed interface Route {
|
||||
data class Section(val menu: GameMenu) : Route
|
||||
data object Settings : Route
|
||||
data object Scorekeeper : Route
|
||||
|
||||
/** Every saved game, to carry on from or delete. */
|
||||
data object Resume : Route
|
||||
data class Setup(val gameId: String) : Route
|
||||
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
|
||||
data class Lobby(
|
||||
val gameId: String,
|
||||
val optionsJson: String,
|
||||
val hosting: Boolean,
|
||||
val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
|
||||
) : Route
|
||||
data class Play(
|
||||
val gameId: String,
|
||||
val config: TableConfig,
|
||||
val hosting: Boolean,
|
||||
val localSeat: Int,
|
||||
/** A saved position to carry on from, as the game's rules encoded it; null deals a new game. */
|
||||
val resumeState: String? = null,
|
||||
) : Route
|
||||
}
|
||||
|
||||
@@ -129,7 +152,14 @@ fun AppRoot(env: AppEnv) {
|
||||
// Card motion reads its speed from here rather than from a parameter.
|
||||
// Every card screen animates, most of them several layers deep, and the
|
||||
// alternative was passing one unchanging number down through all of it.
|
||||
CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
|
||||
val density = LocalDensity.current
|
||||
val scaledDensity = remember(density, settings.textScale) {
|
||||
Density(density.density, density.fontScale * settings.textScale)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalCardSpeed provides settings.animationSpeed,
|
||||
LocalDensity provides scaledDensity,
|
||||
) {
|
||||
AppContent(
|
||||
env = env,
|
||||
settings = settings,
|
||||
@@ -167,6 +197,8 @@ private fun AppContent(
|
||||
// Dev builds show the games that are not finished yet; production
|
||||
// releases list only what can actually be played.
|
||||
showComingSoon = showComingSoon,
|
||||
hasSaved = savedGames(env, stack).isNotEmpty(),
|
||||
onResume = { push(Route.Resume) },
|
||||
onPickSection = { push(Route.Section(it)) },
|
||||
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
|
||||
onScorekeeper = { push(Route.Scorekeeper) },
|
||||
@@ -181,6 +213,25 @@ private fun AppContent(
|
||||
onBack = pop,
|
||||
)
|
||||
|
||||
is Route.Resume -> {
|
||||
// Bumped when a game is deleted, so the list is read again.
|
||||
var deletions by remember(route) { mutableIntStateOf(0) }
|
||||
val saved by produceState(emptyList<SavedMatch>(), route, deletions) {
|
||||
value = withContext(Dispatchers.IO) { env.saves.all() }
|
||||
}
|
||||
ResumeScreen(
|
||||
saved = saved,
|
||||
onResume = { match ->
|
||||
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
|
||||
},
|
||||
onDelete = { match ->
|
||||
env.saves.clear(match.gameId)
|
||||
deletions++
|
||||
},
|
||||
onBack = pop,
|
||||
)
|
||||
}
|
||||
|
||||
is Route.Settings -> SettingsScreen(
|
||||
env = env,
|
||||
settings = settings,
|
||||
@@ -194,15 +245,30 @@ private fun AppContent(
|
||||
onBack = { pop() },
|
||||
)
|
||||
|
||||
is Route.Setup -> GameSetupScreen(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.displayName,
|
||||
onBack = { pop() },
|
||||
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
|
||||
onHostOnline = { optionsJson ->
|
||||
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
||||
},
|
||||
)
|
||||
is Route.Setup -> {
|
||||
// Bumped when the saved game is thrown away, so the banner goes with it.
|
||||
var discards by remember(route) { mutableIntStateOf(0) }
|
||||
val saved by produceState<SavedMatch?>(null, route, discards) {
|
||||
value = withContext(Dispatchers.IO) { env.saves.load(route.gameId) }
|
||||
}
|
||||
GameSetupScreen(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.displayName,
|
||||
onBack = { pop() },
|
||||
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
|
||||
onHostOnline = { optionsJson, aiDifficulty ->
|
||||
push(Route.Lobby(route.gameId, optionsJson, hosting = true, aiDifficulty = aiDifficulty))
|
||||
},
|
||||
saved = saved,
|
||||
onResume = { match ->
|
||||
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
|
||||
},
|
||||
onDiscardSaved = {
|
||||
env.saves.clear(route.gameId)
|
||||
discards++
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is Route.Lobby -> LobbyScreen(
|
||||
env = env,
|
||||
@@ -231,6 +297,8 @@ private fun AppContent(
|
||||
@Composable
|
||||
fun MainMenuScreen(
|
||||
showComingSoon: Boolean,
|
||||
hasSaved: Boolean,
|
||||
onResume: () -> Unit,
|
||||
onPickSection: (GameMenu) -> Unit,
|
||||
onJoinGame: () -> Unit,
|
||||
onScorekeeper: () -> Unit,
|
||||
@@ -280,6 +348,14 @@ fun MainMenuScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Games left half-played, above everything else: coming back to
|
||||
// the app is mostly coming back to one.
|
||||
if (hasSaved) {
|
||||
Button(onClick = onResume, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Resume game")
|
||||
}
|
||||
}
|
||||
|
||||
// Below the games, and full width like them, so the column
|
||||
// stays one column all the way down.
|
||||
OutlinedButton(
|
||||
@@ -364,7 +440,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 +483,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 +507,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),
|
||||
@@ -481,3 +566,17 @@ fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved games, read off the main thread each time the menu is the screen
|
||||
* showing — which is when a game has just been left and its last position
|
||||
* written, so the list is not read until that has happened.
|
||||
*/
|
||||
@Composable
|
||||
private fun savedGames(env: AppEnv, stack: List<Route>): List<SavedMatch> {
|
||||
val onMenu = stack.last() is Route.Menu
|
||||
val saved by produceState(emptyList<SavedMatch>(), onMenu, stack.size) {
|
||||
value = if (onMenu) withContext(Dispatchers.IO) { env.saves.all() } else emptyList()
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.prolibertate.games.ui
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
/**
|
||||
* How a screen's "End game" button tells the table to forget the saved game.
|
||||
*
|
||||
* Leaving a table keeps its position so it can be resumed; ending one on purpose
|
||||
* does not. The two arrive at the screen as the same "go back", so the
|
||||
* difference is carried here instead: [PlayScreen] provides one handle, the match
|
||||
* it builds says what ending means for it by setting [onEnd], and each End-game
|
||||
* dialog calls [end] before it leaves.
|
||||
*/
|
||||
class EndGameHandle {
|
||||
/** Set by the table once it knows how to forget its own saved position. */
|
||||
var onEnd: () -> Unit = {}
|
||||
|
||||
fun end() = onEnd()
|
||||
}
|
||||
|
||||
val LocalEndGameHandle = staticCompositionLocalOf { EndGameHandle() }
|
||||
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
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
|
||||
@@ -27,14 +30,13 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.AiDifficulty
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||
import org.prolibertate.games.game.checkers.CheckersLevel
|
||||
import org.prolibertate.games.game.checkers.CheckersOptions
|
||||
import org.prolibertate.games.game.chess.ChessLevel
|
||||
import org.prolibertate.games.game.chess.ChessOptions
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||
@@ -46,17 +48,19 @@ import org.prolibertate.games.game.freecell.FreeCellOptions
|
||||
import org.prolibertate.games.game.pyramid.PyramidOptions
|
||||
import org.prolibertate.games.game.spider.SpiderOptions
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
|
||||
import org.prolibertate.games.game.meowdoku.MeowdokuGenerator
|
||||
import org.prolibertate.games.game.meowdoku.MeowdokuOptions
|
||||
import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||
import org.prolibertate.games.game.morris.MorrisLevel
|
||||
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.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.SeaScenario
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
import org.prolibertate.games.game.tayu.TayuOptions
|
||||
@@ -80,7 +84,11 @@ fun GameSetupScreen(
|
||||
playerName: String,
|
||||
onBack: () -> Unit,
|
||||
onPlayOffline: (TableConfig, Int) -> Unit,
|
||||
onHostOnline: (String) -> Unit,
|
||||
onHostOnline: (String, AiDifficulty) -> Unit,
|
||||
/** A game of this kind left half-played, if there is one. */
|
||||
saved: SavedMatch? = null,
|
||||
onResume: (SavedMatch) -> Unit = {},
|
||||
onDiscardSaved: () -> Unit = {},
|
||||
) {
|
||||
var euchre by remember { mutableStateOf(EuchreOptions()) }
|
||||
var sequence by remember { mutableStateOf(SequenceOptions()) }
|
||||
@@ -99,17 +107,20 @@ fun GameSetupScreen(
|
||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
||||
var hearts by remember { mutableStateOf(HeartsOptions()) }
|
||||
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
|
||||
var meowdoku by remember { mutableStateOf(MeowdokuOptions()) }
|
||||
var yahtzee by remember { mutableStateOf(YahtzeeOptions()) }
|
||||
var klondike by remember { mutableStateOf(KlondikeOptions()) }
|
||||
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) }
|
||||
// Pirates and Bulgars is not symmetrical, so which side you take is the
|
||||
// first thing about the game rather than a detail of it.
|
||||
var playPirates by remember { mutableStateOf(false) }
|
||||
var aiDifficulty by remember { mutableStateOf(AiDifficulty.MEDIUM) }
|
||||
|
||||
val optionsJson = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||
@@ -129,11 +140,13 @@ fun GameSetupScreen(
|
||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
||||
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
|
||||
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
|
||||
GameCatalog.MEOWDOKU -> setupJson.encodeToString(meowdoku)
|
||||
GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee)
|
||||
GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike)
|
||||
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)
|
||||
@@ -145,6 +158,10 @@ fun GameSetupScreen(
|
||||
) {
|
||||
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
|
||||
|
||||
if (saved != null) {
|
||||
SavedGameBanner(saved, onResume = { onResume(saved) }, onDiscard = onDiscardSaved)
|
||||
}
|
||||
|
||||
Divider()
|
||||
Text("House rules", fontWeight = FontWeight.Bold)
|
||||
|
||||
@@ -189,6 +206,8 @@ fun GameSetupScreen(
|
||||
|
||||
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
|
||||
|
||||
GameCatalog.MEOWDOKU -> MeowdokuOptionsEditor(meowdoku) { meowdoku = it }
|
||||
|
||||
GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it }
|
||||
|
||||
GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it }
|
||||
@@ -199,6 +218,8 @@ fun GameSetupScreen(
|
||||
|
||||
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
|
||||
|
||||
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
|
||||
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -219,6 +240,22 @@ fun GameSetupScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
if (!solo) {
|
||||
ChipRow(
|
||||
label = "Computer difficulty",
|
||||
values = AiDifficulty.entries.toList(),
|
||||
selected = aiDifficulty,
|
||||
display = { it.label },
|
||||
onSelect = { aiDifficulty = it },
|
||||
)
|
||||
if (aiDifficulty == AiDifficulty.MIXED) {
|
||||
Text(
|
||||
"Each computer player gets its own level.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Two games let the local player pick a seat rather than always
|
||||
// taking the first one: chess by colour, and Pirates and Bulgars by
|
||||
// which side of a very lopsided fight they fancy.
|
||||
@@ -227,15 +264,21 @@ fun GameSetupScreen(
|
||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||
else -> 0
|
||||
}
|
||||
if (saved != null) {
|
||||
Text(
|
||||
"Starting a new game replaces the one in progress.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
||||
onPlayOffline(
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat, aiDifficulty),
|
||||
localSeat,
|
||||
)
|
||||
}
|
||||
if (!solo) {
|
||||
PrimaryAction(text = "Host a game for others to join") {
|
||||
onHostOnline(optionsJson)
|
||||
onHostOnline(optionsJson, aiDifficulty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +292,7 @@ private fun offlineConfig(
|
||||
seatCount: Int,
|
||||
playerName: String,
|
||||
localSeat: Int = 0,
|
||||
aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
|
||||
): TableConfig = TableConfig(
|
||||
gameId = gameId,
|
||||
seats = (0 until seatCount).map { seat ->
|
||||
@@ -262,6 +306,7 @@ private fun offlineConfig(
|
||||
optionsJson = optionsJson,
|
||||
// Seeded from the clock so consecutive games are not identical.
|
||||
seed = System.currentTimeMillis(),
|
||||
aiDifficulty = aiDifficulty,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -303,6 +348,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 +374,90 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
|
||||
else -> seat
|
||||
}
|
||||
|
||||
/** The targets the setup screen offers for a Katan game with [expansions] on. */
|
||||
internal fun catanPointsChoices(expansions: Set<CatanExpansion>): List<Int> = when {
|
||||
// Seafarers adds two points for each new island, so the target is higher.
|
||||
CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14)
|
||||
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> listOf(8, 10, 13, 15)
|
||||
// Five is a short game for trying things out; ten is the published target.
|
||||
else -> listOf(5, 8, 10, 12)
|
||||
}
|
||||
|
||||
/** The published target for a game with [expansions] on. */
|
||||
internal fun catanDefaultPoints(expansions: Set<CatanExpansion>): Int = when {
|
||||
CatanExpansion.SEAFARERS in expansions -> 12
|
||||
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> 13
|
||||
else -> 10
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] with [expansion] switched on or off. The target moves to the new combination's own
|
||||
* default unless the one already chosen is on its menu too. Expansions cannot be combined yet,
|
||||
* so switching one on switches the other off.
|
||||
*/
|
||||
internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions {
|
||||
val expansions = if (on) setOf(expansion) else options.expansions - expansion
|
||||
return options.copy(
|
||||
expansions = expansions,
|
||||
pointsToWin = options.pointsToWin.takeIf {
|
||||
it in catanPointsChoices(expansions) && it != catanDefaultPoints(options.expansions)
|
||||
} ?: catanDefaultPoints(expansions),
|
||||
)
|
||||
}
|
||||
|
||||
private fun catanExpansionName(expansion: CatanExpansion): String = when (expansion) {
|
||||
CatanExpansion.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||
CatanExpansion.SEAFARERS -> "Seafarers"
|
||||
}
|
||||
|
||||
private fun catanExpansionBlurb(expansion: CatanExpansion): String = when (expansion) {
|
||||
CatanExpansion.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."
|
||||
CatanExpansion.SEAFARERS ->
|
||||
"Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " +
|
||||
"and the first settlement on each new island scores two extra points."
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CatanExpansion.entries.forEach { expansion ->
|
||||
ToggleRow(
|
||||
title = catanExpansionName(expansion),
|
||||
subtitle = catanExpansionBlurb(expansion),
|
||||
checked = expansion in options.expansions,
|
||||
onChange = { onChange(catanToggleExpansion(options, expansion, it)) },
|
||||
)
|
||||
}
|
||||
if (options.seafarers) {
|
||||
ChipRow(
|
||||
label = "Map",
|
||||
values = SeaScenario.entries,
|
||||
selected = options.scenario,
|
||||
display = { it.title },
|
||||
onSelect = { onChange(options.copy(scenario = it)) },
|
||||
)
|
||||
Text(options.scenario.blurb, 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.expansions),
|
||||
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)) {
|
||||
@@ -659,13 +791,6 @@ private fun ChessOptionsEditor(
|
||||
display = { if (it) "White" else "Black" },
|
||||
onSelect = onColour,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = ChessLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Fifty-move rule",
|
||||
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
|
||||
@@ -689,13 +814,6 @@ private fun ChessOptionsEditor(
|
||||
@Composable
|
||||
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = CheckersLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Flying kings",
|
||||
subtitle = "A king slides the length of a diagonal and takes from a distance, " +
|
||||
@@ -730,13 +848,6 @@ private fun BackgammonOptionsEditor(
|
||||
onChange: (BackgammonOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = BackgammonLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Count gammons",
|
||||
subtitle = "A loser who bore nothing off has lost double, and one still on the " +
|
||||
@@ -758,13 +869,6 @@ private fun MastermindOptionsEditor(
|
||||
onChange: (MastermindOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MastermindLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Colours",
|
||||
values = listOf(6, 8),
|
||||
@@ -829,13 +933,6 @@ private fun PiratesOptionsEditor(
|
||||
display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
|
||||
onSelect = onSide,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = PiratesLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "A pirate who can take must take",
|
||||
subtitle = "The rule that huffing exists to enforce, applied directly",
|
||||
@@ -861,13 +958,6 @@ private fun PiratesOptionsEditor(
|
||||
@Composable
|
||||
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MorrisLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Pieces each",
|
||||
values = listOf(3, 6, 9),
|
||||
@@ -1041,6 +1131,36 @@ private fun PegSolitaireOptionsEditor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MeowdokuOptionsEditor(
|
||||
options: MeowdokuOptions,
|
||||
onChange: (MeowdokuOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Board",
|
||||
values = (MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE).toList(),
|
||||
selected = options.side,
|
||||
display = { "$it × $it" },
|
||||
onSelect = { onChange(options.copy(side = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Hearts",
|
||||
values = (1..5).toList(),
|
||||
selected = options.hearts,
|
||||
display = { if (it == 1) "One" else "$it" },
|
||||
onSelect = { onChange(options.copy(hearts = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Hints",
|
||||
values = (0..3).toList(),
|
||||
selected = options.hints,
|
||||
display = { if (it == 0) "None" else "$it" },
|
||||
onSelect = { onChange(options.copy(hints = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -1141,3 +1261,30 @@ private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOpti
|
||||
onSelect = { onChange(options.copy(redeals = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The game that was left unfinished, with the two things to do about it. Shown
|
||||
* above the options so it is the first thing seen on coming back to a game.
|
||||
*/
|
||||
@Composable
|
||||
private fun SavedGameBanner(saved: SavedMatch, onResume: () -> Unit, onDiscard: () -> Unit) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("Game in progress", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
buildString {
|
||||
append("Saved ${agoText(System.currentTimeMillis(), saved.savedAtMillis)}")
|
||||
if (saved.summary.isNotBlank()) append(" · ${saved.summary}")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onResume) { Text("Resume") }
|
||||
TextButton(onClick = onDiscard) { Text("Discard") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ fun LobbyScreen(
|
||||
hostName = playerName,
|
||||
seatCount = seatCount,
|
||||
teamOf = { seat -> teamForSeat(route.gameId, seat, seatCount) },
|
||||
aiDifficulty = route.aiDifficulty,
|
||||
)
|
||||
} else {
|
||||
env.lobby.startDiscovery()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
package org.prolibertate.games.ui
|
||||
|
||||
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.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
|
||||
/**
|
||||
* Every game that can be carried on from, most recently played first, each with
|
||||
* a way to pick it up and a way to throw it away.
|
||||
*/
|
||||
@Composable
|
||||
fun ResumeScreen(
|
||||
saved: List<SavedMatch>,
|
||||
onResume: (SavedMatch) -> Unit,
|
||||
onDelete: (SavedMatch) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var deleting by remember { mutableStateOf<SavedMatch?>(null) }
|
||||
|
||||
ScreenScaffold(title = "Resume game", onBack = onBack) { modifier ->
|
||||
if (saved.isEmpty()) {
|
||||
Text("No games to resume.", modifier = modifier)
|
||||
} else {
|
||||
val now = System.currentTimeMillis()
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(saved, key = { it.gameId }) { match ->
|
||||
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(title, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
buildString {
|
||||
append("Saved ${agoText(now, match.savedAtMillis)}")
|
||||
if (match.summary.isNotBlank()) append(" · ${match.summary}")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { onResume(match) }) { Text("Resume") }
|
||||
TextButton(onClick = { deleting = match }) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleting?.let { match ->
|
||||
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleting = null },
|
||||
title = { Text("Delete this game?") },
|
||||
text = { Text("Your saved game of $title will be removed and cannot be resumed.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { deleting = null; onDelete(match) }) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { deleting = null }) { Text("Keep it") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.prolibertate.games.ui
|
||||
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
|
||||
/** How long ago something happened, in the words a person would use. */
|
||||
fun agoText(nowMillis: Long, thenMillis: Long): String {
|
||||
val seconds = ((nowMillis - thenMillis) / 1000).coerceAtLeast(0)
|
||||
val minutes = seconds / 60
|
||||
val hours = minutes / 60
|
||||
val days = hours / 24
|
||||
return when {
|
||||
seconds < 45 -> "just now"
|
||||
minutes < 2 -> "a minute ago"
|
||||
minutes < 60 -> "$minutes minutes ago"
|
||||
hours < 2 -> "an hour ago"
|
||||
hours < 24 -> "$hours hours ago"
|
||||
days < 2 -> "yesterday"
|
||||
else -> "$days days ago"
|
||||
}
|
||||
}
|
||||
|
||||
/** "Chess · saved 5 minutes ago" for a menu line. */
|
||||
fun savedLine(title: String, match: SavedMatch, nowMillis: Long): String =
|
||||
"$title · saved ${agoText(nowMillis, match.savedAtMillis)}"
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -114,6 +115,8 @@ fun ScorekeeperScreen(
|
||||
local = repository.sheet.first()
|
||||
loaded = true
|
||||
}
|
||||
val recentNames by repository.recentNames.collectAsState(initial = emptyList())
|
||||
val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } }
|
||||
|
||||
val shared by session.state.collectAsState()
|
||||
val role = shared.role
|
||||
@@ -220,7 +223,7 @@ fun ScorekeeperScreen(
|
||||
|
||||
role == Role.GUEST -> Column(modifier = modifier) {
|
||||
ConnectionBanner(shared = shared, onSync = { session.sync() })
|
||||
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
|
||||
Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed)
|
||||
}
|
||||
|
||||
joining && role == Role.NONE ->
|
||||
@@ -228,11 +231,16 @@ fun ScorekeeperScreen(
|
||||
|
||||
!sheet.started -> SetupPane(
|
||||
modifier = modifier,
|
||||
onStart = { count, mode -> change(StartSheet(count, mode)) },
|
||||
recentNames = recentNames,
|
||||
onStart = { count, mode, names ->
|
||||
change(StartSheet(count, mode, names))
|
||||
// Last first, so the list reads in seating order.
|
||||
names.reversed().forEach(nameUsed)
|
||||
},
|
||||
onJoin = { joining = true },
|
||||
)
|
||||
|
||||
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
|
||||
else -> Scoring(modifier, sheet, change, recentNames, nameUsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,9 +297,46 @@ fun ScorekeeperScreen(
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) {
|
||||
private fun SetupPane(
|
||||
modifier: Modifier,
|
||||
recentNames: List<String>,
|
||||
onStart: (Int, ScoringMode, List<String>) -> Unit,
|
||||
onJoin: () -> Unit,
|
||||
) {
|
||||
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
|
||||
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
|
||||
// -1 while the options are showing; otherwise the seat being named.
|
||||
var naming by remember { mutableStateOf(-1) }
|
||||
val names = remember { mutableStateListOf<String>() }
|
||||
|
||||
if (naming >= 0) {
|
||||
NamePane(
|
||||
modifier = modifier,
|
||||
seat = naming,
|
||||
count = count,
|
||||
name = names.getOrElse(naming) { "" },
|
||||
// Offered once only: two players on one sheet with the same name
|
||||
// are indistinguishable.
|
||||
suggestions = recentNames.filter { recent ->
|
||||
names.withIndex().none { (seat, taken) ->
|
||||
seat != naming && taken.trim().equals(recent.trim(), ignoreCase = true)
|
||||
}
|
||||
},
|
||||
onName = { typed ->
|
||||
while (names.size <= naming) names.add("")
|
||||
names[naming] = typed
|
||||
},
|
||||
onBack = { naming -= 1 },
|
||||
onNext = {
|
||||
if (naming + 1 < count) {
|
||||
naming += 1
|
||||
} else {
|
||||
onStart(count, mode, List(count) { names.getOrElse(it) { "" }.trim() })
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
@@ -339,33 +384,98 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Names come next: tap a column head to name it. Players can be " +
|
||||
"added, removed or dragged into a different order at any point " +
|
||||
"without losing anybody's score.",
|
||||
text = "You will be asked for each name next, or skip them and tap a " +
|
||||
"column head later. Players can be added, removed or dragged into " +
|
||||
"a different order at any point without losing anybody's score.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
PrimaryAction(text = "Start scoring") { onStart(count, mode) }
|
||||
PrimaryAction(text = "Next: name the players") { naming = 0 }
|
||||
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Join a shared sheet nearby")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One seat's name, asked for on its own; a blank leaves the seat as "Player N". */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun NamePane(
|
||||
modifier: Modifier,
|
||||
seat: Int,
|
||||
count: Int,
|
||||
name: String,
|
||||
suggestions: List<String>,
|
||||
onName: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(seat) { focus.requestFocus() }
|
||||
val last = seat + 1 >= count
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Player ${seat + 1} of $count", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onName,
|
||||
placeholder = { Text("Player ${seat + 1}") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = if (last) ImeAction.Done else ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }),
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focus),
|
||||
)
|
||||
if (suggestions.isNotEmpty()) {
|
||||
Text("Recent names", style = MaterialTheme.typography.labelMedium)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
suggestions.forEach { recent ->
|
||||
FilterChip(
|
||||
selected = recent.equals(name.trim(), ignoreCase = true),
|
||||
onClick = { onName(recent) },
|
||||
label = { Text(recent) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PrimaryAction(text = if (last) "Start scoring" else "Next") { onNext() }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
TextButton(onClick = { onName(""); onNext() }) { Text(if (last) "Skip and start" else "Skip") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The sheet: a column per player, a row per round, totals ruled off at the foot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
||||
private fun Scoring(
|
||||
modifier: Modifier,
|
||||
sheet: ScoreSheet,
|
||||
onOp: (SheetOp) -> Unit,
|
||||
recentNames: List<String>,
|
||||
onNameUsed: (String) -> Unit,
|
||||
) {
|
||||
if (sheet.mode == ScoringMode.TALLY) {
|
||||
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
|
||||
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
|
||||
} else {
|
||||
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
|
||||
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
||||
private fun Sheet(
|
||||
modifier: Modifier,
|
||||
sheet: ScoreSheet,
|
||||
onOp: (SheetOp) -> Unit,
|
||||
recentNames: List<String>,
|
||||
onNameUsed: (String) -> Unit,
|
||||
) {
|
||||
// One scroll state shared by the head, the rows and the totals, so all three
|
||||
// move together: a column and the name over it must never come apart. Every
|
||||
// row is built the same way and is therefore exactly the same width, which
|
||||
@@ -384,6 +494,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
||||
var lastTyped by remember { mutableStateOf(-1) }
|
||||
// The cell to put the cursor in when a round is opened by tapping it.
|
||||
var focusPlayer by remember { mutableStateOf(-1) }
|
||||
// Bumped when a round is finished, to put the cursor back in the first cell
|
||||
// of the row that is now being filled in.
|
||||
var nextRoundFocus by remember { mutableStateOf(0) }
|
||||
|
||||
val editing = if (correcting >= 0) correction else pending
|
||||
|
||||
@@ -513,7 +626,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
||||
)
|
||||
},
|
||||
trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) },
|
||||
) { _, player ->
|
||||
) { index, player ->
|
||||
if (correcting >= 0) {
|
||||
// A round is being corrected above; what is typed here
|
||||
// is kept, but only one row is edited at a time.
|
||||
@@ -521,6 +634,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
||||
} else {
|
||||
PointsField(
|
||||
text = pending[player.id].orEmpty(),
|
||||
autoFocus = index == 0 && nextRoundFocus > 0,
|
||||
focusRequest = nextRoundFocus,
|
||||
onText = {
|
||||
pending[player.id] = it
|
||||
lastTyped = player.id
|
||||
@@ -563,6 +678,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
||||
onOp(AddRound(amountsAsPoints(pending)))
|
||||
pending.clear()
|
||||
}
|
||||
nextRoundFocus += 1
|
||||
lastTyped = -1
|
||||
focusPlayer = -1
|
||||
},
|
||||
@@ -587,6 +703,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
||||
RenameDialog(
|
||||
sheet = sheet,
|
||||
player = renamed,
|
||||
recentNames = recentNames,
|
||||
onNameUsed = onNameUsed,
|
||||
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||
onRemove = {
|
||||
onOp(RemovePlayer(renamed.id))
|
||||
@@ -607,7 +725,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
|
||||
* sheet, so tapping one in the list takes it back off.
|
||||
*/
|
||||
@Composable
|
||||
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
|
||||
private fun TallySheet(
|
||||
modifier: Modifier,
|
||||
sheet: ScoreSheet,
|
||||
onOp: (SheetOp) -> Unit,
|
||||
recentNames: List<String>,
|
||||
onNameUsed: (String) -> Unit,
|
||||
) {
|
||||
var renaming by remember { mutableStateOf(-1) }
|
||||
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -676,6 +800,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
|
||||
RenameDialog(
|
||||
sheet = sheet,
|
||||
player = renamed,
|
||||
recentNames = recentNames,
|
||||
onNameUsed = onNameUsed,
|
||||
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||
onRemove = {
|
||||
onOp(RemovePlayer(renamed.id))
|
||||
@@ -961,9 +1087,14 @@ private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Uni
|
||||
|
||||
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
|
||||
@Composable
|
||||
private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) {
|
||||
private fun PointsField(
|
||||
text: String,
|
||||
onText: (String) -> Unit,
|
||||
autoFocus: Boolean = false,
|
||||
focusRequest: Int = 0,
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
|
||||
LaunchedEffect(autoFocus, focusRequest) { if (autoFocus) focus.requestFocus() }
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { onText(sanitiseAmount(it)) },
|
||||
@@ -1064,16 +1195,27 @@ private fun ActionBar(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
sheet: ScoreSheet,
|
||||
player: ScorePlayer,
|
||||
recentNames: List<String>,
|
||||
onNameUsed: (String) -> Unit,
|
||||
onName: (String) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Closing the dialog is what files the name away, so a name is remembered
|
||||
// once rather than once per keystroke.
|
||||
val finish = {
|
||||
onNameUsed(player.name)
|
||||
onDismiss()
|
||||
}
|
||||
val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() }
|
||||
val suggestions = recentNames.filter { it.trim().lowercase() !in taken }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = finish,
|
||||
title = { Text("Name this column") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -1084,6 +1226,21 @@ private fun RenameDialog(
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (suggestions.isNotEmpty()) {
|
||||
Text("Recent names", style = MaterialTheme.typography.labelMedium)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
suggestions.forEach { name ->
|
||||
FilterChip(
|
||||
selected = name.equals(player.name.trim(), ignoreCase = true),
|
||||
onClick = { onName(name) },
|
||||
label = { Text(name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = onRemove,
|
||||
// The last two columns stay: a sheet needs somebody on it.
|
||||
@@ -1091,7 +1248,7 @@ private fun RenameDialog(
|
||||
) { Text("Remove this player and their score") }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
confirmButton = { TextButton(onClick = finish) { Text("Done") } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.update.Release
|
||||
import org.prolibertate.games.update.UpdateChannel
|
||||
@@ -83,6 +84,26 @@ fun SettingsScreen(
|
||||
|
||||
Divider()
|
||||
|
||||
Column {
|
||||
Text("Text size", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "${(settings.textScale * 100).roundToInt()}% — " +
|
||||
"scales text throughout the app, on top of your phone's setting",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Slider(
|
||||
value = settings.textScale,
|
||||
onValueChange = { scale ->
|
||||
scope.launch { env.settingsRepository.setTextScale(scale) }
|
||||
},
|
||||
valueRange = Settings.MIN_TEXT_SCALE..Settings.MAX_TEXT_SCALE,
|
||||
// 10% increments across 80%-160%.
|
||||
steps = 7,
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Column {
|
||||
Text("Your name", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -378,7 +379,12 @@ private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -79,7 +80,12 @@ fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text(what) },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -314,7 +315,12 @@ private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -215,7 +216,12 @@ private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,22 @@ fun CrazyEightsScreen(
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
val dialogs: @Composable () -> Unit = {
|
||||
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) {
|
||||
PatienceEndDialog(
|
||||
what = "The game ends here. Anyone else at the table is told you have left.",
|
||||
onEnd = onExit,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
|
||||
// Playing an eight is two decisions: the card, then the suit it turns the
|
||||
@@ -68,13 +84,15 @@ fun CrazyEightsScreen(
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
|
||||
|
||||
|
||||
@@ -65,11 +65,28 @@ fun CribbageScreen(
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
val dialogs: @Composable () -> Unit = {
|
||||
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) {
|
||||
PatienceEndDialog(
|
||||
what = "The game ends here. Anyone else at the table is told you have left.",
|
||||
onEnd = onExit,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
@@ -79,7 +96,8 @@ fun CribbageScreen(
|
||||
// together. Forgotten whenever a new hand is dealt.
|
||||
var chosen by remember(current.handNumber) { mutableStateOf(emptySet<Card>()) }
|
||||
|
||||
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -72,18 +73,36 @@ fun EuchreScreen(
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
val dialogs: @Composable () -> Unit = {
|
||||
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) {
|
||||
PatienceEndDialog(
|
||||
what = "The game ends here. Anyone else at the table is told you have left.",
|
||||
onEnd = onExit,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val notice by controller.notice.collectAsState()
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val compact = maxWidth < 600.dp
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.17f else 0.12f)
|
||||
|
||||
@@ -69,18 +69,36 @@ fun GolfScreen(
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
val dialogs: @Composable () -> Unit = {
|
||||
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) {
|
||||
PatienceEndDialog(
|
||||
what = "The game ends here. Anyone else at the table is told you have left.",
|
||||
onEnd = onExit,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
var mode by remember { mutableStateOf(PlaceMode.SWAP) }
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val shortest = minOf(maxWidth, maxHeight)
|
||||
val ownCard = (shortest * 0.16f).coerceIn(40.dp, 84.dp)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -268,7 +269,12 @@ private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -74,18 +75,36 @@ fun KaiserScreen(
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
val dialogs: @Composable () -> Unit = {
|
||||
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) {
|
||||
PatienceEndDialog(
|
||||
what = "The game ends here. Anyone else at the table is told you have left.",
|
||||
onEnd = onExit,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val notice by controller.notice.collectAsState()
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val compact = maxWidth < 600.dp
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.15f else 0.11f)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -342,7 +343,12 @@ private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The deal is left as it stands.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -356,7 +357,12 @@ private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import org.prolibertate.games.game.meowdoku.MeowdokuMove
|
||||
import org.prolibertate.games.game.meowdoku.MeowdokuState
|
||||
import org.prolibertate.games.game.meowdoku.PlaceCat
|
||||
import org.prolibertate.games.game.meowdoku.Square
|
||||
import org.prolibertate.games.game.meowdoku.TakeHint
|
||||
import org.prolibertate.games.game.meowdoku.TogglePencil
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.theme.CardRed
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TITLE = "Meowdoku"
|
||||
|
||||
/**
|
||||
* How long the second tap of a double tap may take to arrive.
|
||||
*
|
||||
* 350 ms, the number the original game ships: long enough that a deliberate
|
||||
* one-two still reads as one question, short enough that nobody pairs two
|
||||
* taps they meant for two separate squares.
|
||||
*/
|
||||
private const val DOUBLE_TAP_WINDOW_MILLIS = 350L
|
||||
|
||||
/**
|
||||
* The region colours, one per cat the board can hold.
|
||||
*
|
||||
* The board never needs two regions of one colour — a side-ten board has ten
|
||||
* regions and the palette has ten — so a region's colour is just its id, and
|
||||
* nothing has to be computed to keep neighbours apart. The palette reads as
|
||||
* watercolour on purpose: cats are dark ink on top of it, and a square the
|
||||
* player has crossed over is too.
|
||||
*/
|
||||
private val RegionColours = listOf(
|
||||
Color(0xFFF2C6C2), // rose
|
||||
Color(0xFFF7E0A6), // butter
|
||||
Color(0xFFCDE8C5), // mint
|
||||
Color(0xFFC5E3F0), // sky
|
||||
Color(0xFFD8CDEB), // lilac
|
||||
Color(0xFFEBC9A0), // apricot
|
||||
Color(0xFFBFE3D0), // spruce
|
||||
Color(0xFFF0D7E3), // blossom
|
||||
Color(0xFFCFCFE8), // periwinkle
|
||||
Color(0xFFE3E7C1), // lime
|
||||
)
|
||||
|
||||
/**
|
||||
* The puzzle board, tapped the way the original's is.
|
||||
*
|
||||
* One tap crosses a square over; a second tap on the same square within
|
||||
* [DOUBLE_TAP_WINDOW_MILLIS] asks the board whether a cat lives there. Drag
|
||||
* strokes paint or erase crosses along the line the finger takes, which is
|
||||
* how a row gets crossed out in one gesture rather than eight taps. Cats are
|
||||
* permanent: tapping one does nothing, and a stroke skips over it.
|
||||
*/
|
||||
@Composable
|
||||
fun MeowdokuScreen(
|
||||
controller: MatchController<MeowdokuState, MeowdokuMove>,
|
||||
onNewPuzzle: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val places = legal.filterIsInstance<PlaceCat>().map { it.square }.toSet()
|
||||
val pencils = legal.filterIsInstance<TogglePencil>().map { it.square }.toSet()
|
||||
val finished by controller.finished.collectAsState()
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
current.solved -> "Found every cat" +
|
||||
if (current.wrongCats == 0) ", not one tap wasted." else ", with ${current.wrongCats} wrong taps."
|
||||
current.lost -> "Out of hearts, with ${current.side - current.catsFound} cats still hiding."
|
||||
else -> "${current.catsFound} of ${current.side} cats found. " +
|
||||
"Tap to cross a square, tap it again for a cat."
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Hearts " + "♥".repeat(current.heartsLeft) +
|
||||
" · Hints " + current.hintsLeft,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Board(
|
||||
state = current,
|
||||
// Moves are refused rather than hidden: an attempt or a note
|
||||
// the board would reject (a tap on a cat, mostly) has to die
|
||||
// here, in the gesture, where the player can be doing nothing
|
||||
// else the same instant.
|
||||
onNote = { square -> if (square in pencils) controller.submit(TogglePencil(square)) },
|
||||
onAttempt = { square -> if (square in places) controller.submit(PlaceCat(square)) },
|
||||
)
|
||||
|
||||
if (TakeHint in legal) {
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = { controller.submit(TakeHint) },
|
||||
label = { Text("Hint") },
|
||||
)
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onNewPuzzle) { Text("New puzzle") }
|
||||
TextButton(onClick = onExit) { Text("Leave") }
|
||||
}
|
||||
}
|
||||
|
||||
current.log.takeLast(2).forEach {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The board as one gesture surface.
|
||||
*
|
||||
* Cells are drawn by weight inside a square frame, but input is not per-cell:
|
||||
* a stroke is one continuous gesture, so the pointer works in board
|
||||
* coordinates and maps positions to squares itself. The cell list renders
|
||||
* underneath, and the live state is read through [rememberUpdatedState]
|
||||
* because a gesture outlives the recompositions it lands in.
|
||||
*/
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: MeowdokuState,
|
||||
onNote: (Square) -> Unit,
|
||||
onAttempt: (Square) -> Unit,
|
||||
) {
|
||||
val side = state.side
|
||||
val current by rememberUpdatedState(state)
|
||||
val note by rememberUpdatedState(onNote)
|
||||
val attempt by rememberUpdatedState(onAttempt)
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
// One cell's edge, so a cat is always about half a square whatever
|
||||
// board was dealt.
|
||||
val cell = maxWidth / side
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.pointerInput(side) {
|
||||
val cellPx = size.width.toFloat() / side
|
||||
var lastTap: Square? = null
|
||||
var lastTapAt = 0L
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val board = current
|
||||
val start = squareAt(down.position, cellPx, side)
|
||||
?: return@awaitEachGesture
|
||||
if (start in board.cats) return@awaitEachGesture
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (start == lastTap && now - lastTapAt < DOUBLE_TAP_WINDOW_MILLIS) {
|
||||
// The second tap of a pair: the cat attempt, and
|
||||
// no note — the cross it lands on was drawn by
|
||||
// this pair's first tap, and the board replaces it.
|
||||
lastTap = null
|
||||
attempt(start)
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
change?.consume()
|
||||
} while (change != null && change.pressed)
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
// The first tap: a note, and the stroke that may follow
|
||||
// paints the same note everywhere it goes.
|
||||
lastTap = start
|
||||
lastTapAt = now
|
||||
val erasing = start in board.marks
|
||||
note(start)
|
||||
val marks = board.marks.toMutableSet()
|
||||
marks.flip(start)
|
||||
var previous = start
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
change.consume()
|
||||
val here = squareAt(change.position, cellPx, side) ?: continue
|
||||
if (here == previous) continue
|
||||
for (square in path(previous, here)) {
|
||||
if (square in board.cats) continue
|
||||
// `marks` is the stroke's own mirror: the base
|
||||
// board plus every note this stroke has
|
||||
// already submitted. The state flow lags a
|
||||
// frame behind submissions, and a stroke that
|
||||
// crosses a square twice must leave it at the
|
||||
// stroke's target, not flip it back.
|
||||
//
|
||||
// Erasing means "flip everything still
|
||||
// crossed"; painting means "flip everything
|
||||
// still blank" — which is the same test read
|
||||
// two ways: flip while marked == erasing.
|
||||
if ((square in marks) == erasing) {
|
||||
note(square)
|
||||
marks.flip(square)
|
||||
}
|
||||
}
|
||||
previous = here
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.aspectRatio(1f)) {
|
||||
for (row in 0 until side) {
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
for (col in 0 until side) {
|
||||
CellView(
|
||||
square = Square(row, col),
|
||||
state = current,
|
||||
glyphSize = (cell.value * 0.55f).sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which square a pointer position is over, or null if it has left the board. */
|
||||
private fun squareAt(position: Offset, cellPx: Float, side: Int): Square? {
|
||||
val col = (position.x / cellPx).toInt()
|
||||
val row = (position.y / cellPx).toInt()
|
||||
if (row !in 0 until side || col !in 0 until side) return null
|
||||
return Square(row, col)
|
||||
}
|
||||
|
||||
/** Add the square, or drop it if it was already there — the stroke's own toggle. */
|
||||
private fun MutableSet<Square>.flip(square: Square) {
|
||||
if (!add(square)) remove(square)
|
||||
}
|
||||
|
||||
/**
|
||||
* The squares between two consecutive pointer reads, endpoints not repeated.
|
||||
*
|
||||
* A fast stroke publishes fewer positions than it crosses cells, so the line
|
||||
* between two reads is walked in whole-cell steps. [path] returns at most one
|
||||
* square per cell crossed, in order, which is what lets a stroke cross a row
|
||||
* in one drag without missing the squares between two events.
|
||||
*/
|
||||
private fun path(from: Square, to: Square): List<Square> {
|
||||
val dr = to.row - from.row
|
||||
val dc = to.col - from.col
|
||||
val steps = maxOf(abs(dr), abs(dc))
|
||||
if (steps <= 1) return listOf(to)
|
||||
return (1..steps).map { i ->
|
||||
Square(
|
||||
from.row + (dr * i.toFloat() / steps).roundToInt(),
|
||||
from.col + (dc * i.toFloat() / steps).roundToInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CellView(
|
||||
square: Square,
|
||||
state: MeowdokuState,
|
||||
glyphSize: TextUnit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val isCat = square in state.cats
|
||||
val isMark = square in state.marks
|
||||
val justWrong = state.lastWrong && state.lastSquare == square
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.border(1.dp, Color(0x33000000))
|
||||
.background(RegionColours[state.regionOf(square) % RegionColours.size])
|
||||
.then(
|
||||
if (justWrong) Modifier.border(3.dp, CardRed) else Modifier
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when {
|
||||
isCat -> Text("🐱", fontSize = glyphSize)
|
||||
isMark -> Text(
|
||||
"✕",
|
||||
color = Color(0xAA1C1B1F),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = glyphSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The board is left as it stands.") },
|
||||
confirmButton = {
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeftBoardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text(notice) },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -483,7 +484,12 @@ private fun LeaveBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -242,7 +243,12 @@ private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The board is left as it stands.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -371,7 +372,12 @@ private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"at the table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,11 +20,15 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
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.rotate
|
||||
@@ -66,16 +70,34 @@ fun PresidentScreen(
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
val dialogs: @Composable () -> Unit = {
|
||||
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) {
|
||||
PatienceEndDialog(
|
||||
what = "The game ends here. Anyone else at the table is told you have left.",
|
||||
onEnd = onExit,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
dialogs()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * 0.15f).coerceIn(44.dp, 88.dp)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -81,12 +82,29 @@ fun SequenceScreen(
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
val dialogs: @Composable () -> Unit = {
|
||||
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) {
|
||||
PatienceEndDialog(
|
||||
what = "The game ends here. Anyone else at the table is told you have left.",
|
||||
onEnd = onExit,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
var selected by remember { mutableStateOf<Card?>(null) }
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
@@ -97,7 +115,8 @@ fun SequenceScreen(
|
||||
val removals = legal.filterIsInstance<RemoveChip>().filter { it.card == selected }
|
||||
val highlighted = (placements.map { it.cell } + removals.map { it.cell }).toSet()
|
||||
|
||||
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight
|
||||
val boardSide = if (wide) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -668,7 +669,12 @@ private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
"table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,18 +75,36 @@ fun WizardScreen(
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
val dialogs: @Composable () -> Unit = {
|
||||
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) {
|
||||
PatienceEndDialog(
|
||||
what = "The game ends here. Anyone else at the table is told you have left.",
|
||||
onEnd = onExit,
|
||||
onKeepPlaying = { confirmingEnd = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val notice by controller.notice.collectAsState()
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
|
||||
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
dialogs()
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val cardWidth = (minOf(maxWidth, maxHeight) * 0.14f).coerceIn(40.dp, 88.dp)
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
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.BoxWithConstraints
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -29,12 +34,17 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.yahtzee.DICE_COUNT
|
||||
import org.prolibertate.games.game.yahtzee.RollDice
|
||||
import org.prolibertate.games.game.yahtzee.ScoreIn
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
|
||||
import org.prolibertate.games.game.yahtzee.recordOf
|
||||
import org.prolibertate.games.settings.YahtzeeStatsRepository
|
||||
import kotlin.math.roundToInt
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeMove
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeSection
|
||||
import org.prolibertate.games.game.yahtzee.YahtzeeState
|
||||
@@ -60,6 +70,7 @@ private const val TITLE = "Yahtzee"
|
||||
fun YahtzeeScreen(
|
||||
controller: MatchController<YahtzeeState, YahtzeeMove>,
|
||||
localSeat: Int,
|
||||
stats: YahtzeeStatsRepository,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
@@ -68,6 +79,11 @@ fun YahtzeeScreen(
|
||||
|
||||
val kept = remember { mutableStateListOf<Int>() }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
var confirmingZero by remember { mutableStateOf<YahtzeeCategory?>(null) }
|
||||
var comparison by remember { mutableStateOf<YahtzeeComparison?>(null) }
|
||||
// Only a game seen to finish is recorded: reopening a saved game that was
|
||||
// already over must not count it a second time.
|
||||
var sawInProgress by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
@@ -88,6 +104,15 @@ fun YahtzeeScreen(
|
||||
// refused, and the Throw button would look broken again.
|
||||
LaunchedEffect(current.turn, current.round) { kept.clear() }
|
||||
|
||||
LaunchedEffect(current.isOver) {
|
||||
if (!current.isOver) {
|
||||
sawInProgress = true
|
||||
} else if (sawInProgress) {
|
||||
sawInProgress = false
|
||||
comparison = stats.finish(recordOf(current, localSeat))
|
||||
}
|
||||
}
|
||||
|
||||
val yours = current.turn == localSeat && !current.isOver
|
||||
val canRoll = legal.any { it is RollDice }
|
||||
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
|
||||
@@ -137,8 +162,14 @@ fun YahtzeeScreen(
|
||||
localSeat = localSeat,
|
||||
writable = if (yours) writable else emptySet(),
|
||||
onWrite = {
|
||||
controller.submit(ScoreIn(it))
|
||||
kept.clear()
|
||||
// A zero is a box spent for nothing, and one mis-tap on a
|
||||
// dense card is all it takes, so ask first.
|
||||
if (scoreOf(it, current.dice) == 0) {
|
||||
confirmingZero = it
|
||||
} else {
|
||||
controller.submit(ScoreIn(it))
|
||||
kept.clear()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -149,9 +180,79 @@ fun YahtzeeScreen(
|
||||
|
||||
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
|
||||
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
|
||||
comparison?.let { StatsDialog(it, onExit) { comparison = null } }
|
||||
confirmingZero?.let { category ->
|
||||
ZeroScoreDialog(
|
||||
category = category,
|
||||
onConfirm = {
|
||||
confirmingZero = null
|
||||
controller.submit(ScoreIn(category))
|
||||
kept.clear()
|
||||
},
|
||||
onCancel = { confirmingZero = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsDialog(c: YahtzeeComparison, onLeave: () -> Unit, onClose: () -> Unit) {
|
||||
fun pct(v: Double?) = v?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
val headline = when {
|
||||
c.previousGames == 0 -> "Your first recorded game."
|
||||
c.rank == 1 -> "A new personal best!"
|
||||
else -> "${ordinal(c.rank)} best of ${c.gamesRanked} games."
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onClose,
|
||||
title = { Text("Game over: ${c.current.total} points") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(headline, fontWeight = FontWeight.Bold)
|
||||
StatLine("", "This game", "Before")
|
||||
StatLine("Score", "${c.current.total}", c.averageBefore?.let { "avg ${it.roundToInt()}, best ${c.bestBefore}" } ?: "—")
|
||||
StatLine("Upper bonus", if (c.current.gotBonus) "Yes" else "No", pct(c.bonusRateBefore))
|
||||
StatLine("Yahtzees", "${c.current.yahtzees}", "${c.yahtzeesBefore} total")
|
||||
StatLine("Zeroed boxes", "${c.current.zeros}", "")
|
||||
if (c.current.players > 1) {
|
||||
StatLine("Result", if (c.current.won) "Won" else "Lost", "won ${pct(c.winRateBefore)}")
|
||||
}
|
||||
Text(
|
||||
"Games before this one: ${c.previousGames}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onLeave) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onClose) { Text("Look at the card") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatLine(label: String, now: String, before: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(label, modifier = Modifier.weight(1.2f), fontWeight = FontWeight.Bold)
|
||||
Text(now, modifier = Modifier.weight(1f))
|
||||
Text(before, modifier = Modifier.weight(1.6f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ordinal(n: Int): String {
|
||||
val suffix = if (n % 100 in 11..13) "th" else when (n % 10) { 1 -> "st"; 2 -> "nd"; 3 -> "rd"; else -> "th" }
|
||||
return "$n$suffix"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZeroScoreDialog(category: YahtzeeCategory, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text("Score zero?") },
|
||||
text = { Text("These dice are worth 0 in ${category.label}. Write the zero there?") },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text("Score 0") } },
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Choose another") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiceRow(
|
||||
dice: List<Int>,
|
||||
@@ -189,12 +290,29 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
|
||||
.clickable(enabled = enabled) { onClick() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = face?.toString() ?: "·",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = WallaceRed,
|
||||
)
|
||||
if (face != null) {
|
||||
Canvas(modifier = Modifier.size(48.dp)) {
|
||||
val radius = size.minDimension * 0.08f
|
||||
pipPositions(face).forEach { (x, y) ->
|
||||
drawCircle(WallaceRed, radius, Offset(size.width * x, size.height * y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pip centres as fractions of the die face, in the usual casino layout. */
|
||||
private fun pipPositions(face: Int): List<Pair<Float, Float>> {
|
||||
val lo = 0.28f
|
||||
val mid = 0.5f
|
||||
val hi = 0.72f
|
||||
return when (face) {
|
||||
1 -> listOf(mid to mid)
|
||||
2 -> listOf(lo to lo, hi to hi)
|
||||
3 -> listOf(lo to lo, mid to mid, hi to hi)
|
||||
4 -> listOf(lo to lo, hi to lo, lo to hi, hi to hi)
|
||||
5 -> listOf(lo to lo, hi to lo, mid to mid, lo to hi, hi to hi)
|
||||
else -> listOf(lo to lo, hi to lo, lo to mid, hi to mid, lo to hi, hi to hi)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,28 +323,61 @@ private fun ScoreCard(
|
||||
writable: Set<YahtzeeCategory>,
|
||||
onWrite: (YahtzeeCategory) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
|
||||
state.cards.indices.forEach { seat ->
|
||||
Text(
|
||||
text = if (seat == localSeat) "You" else "S$seat",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
YahtzeeSection.entries.forEach { section ->
|
||||
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
|
||||
CardRow(state, localSeat, category, writable, onWrite)
|
||||
}
|
||||
if (section == YahtzeeSection.UPPER) {
|
||||
// Upper boxes and bonus on the left, lower boxes and total on the right, so
|
||||
// each half is wide enough for text that can actually be read. Each half
|
||||
// has a floor on its width; if two of them do not fit (many players, large
|
||||
// text) the pair scrolls sideways instead of squeezing.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val needed = (CARD_LABEL_WIDTH + CARD_SEAT_WIDTH * state.cards.size).dp
|
||||
val half = maxOf((maxWidth - CARD_GAP.dp) / 2, needed)
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(CARD_GAP.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.width(half)) {
|
||||
CardHeader(state, localSeat)
|
||||
CardSection(YahtzeeSection.UPPER, state, localSeat, writable, onWrite)
|
||||
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
|
||||
}
|
||||
Column(modifier = Modifier.width(half)) {
|
||||
CardHeader(state, localSeat)
|
||||
CardSection(YahtzeeSection.LOWER, state, localSeat, writable, onWrite)
|
||||
Divider()
|
||||
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CARD_LABEL_WIDTH = 120
|
||||
private const val CARD_SEAT_WIDTH = 56
|
||||
private const val CARD_GAP = 12
|
||||
|
||||
@Composable
|
||||
private fun CardHeader(state: YahtzeeState, localSeat: Int) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text("", modifier = Modifier.weight(2f))
|
||||
state.cards.indices.forEach { seat ->
|
||||
Text(
|
||||
text = if (seat == localSeat) "You" else "S$seat",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardSection(
|
||||
section: YahtzeeSection,
|
||||
state: YahtzeeState,
|
||||
localSeat: Int,
|
||||
writable: Set<YahtzeeCategory>,
|
||||
onWrite: (YahtzeeCategory) -> Unit,
|
||||
) {
|
||||
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
|
||||
CardRow(state, localSeat, category, writable, onWrite)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,12 +394,12 @@ private fun CardRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = open) { onWrite(category) }
|
||||
.padding(vertical = 2.dp),
|
||||
.padding(vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
text = category.label,
|
||||
modifier = Modifier.weight(2f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
state.cards.forEachIndexed { seat, card ->
|
||||
@@ -262,7 +413,7 @@ private fun CardRow(
|
||||
else -> "—"
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
@@ -271,18 +422,18 @@ private fun CardRow(
|
||||
|
||||
@Composable
|
||||
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(2f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
values.forEach {
|
||||
Text(
|
||||
text = "$it",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
@@ -295,7 +446,12 @@ private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
confirmButton = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
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
|
||||
import org.prolibertate.games.game.catan.Terrain
|
||||
|
||||
/**
|
||||
* 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,
|
||||
/** A placement lined up but not yet confirmed (or being dragged over): drawn as a solid ghost. */
|
||||
stagedVertex: Int? = null,
|
||||
stagedEdge: Int? = null,
|
||||
/** The seat that will own the staged piece, for its colour. */
|
||||
stagedSeat: Int? = null,
|
||||
) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val topo = state.topo
|
||||
val layout = remember(size, topo) {
|
||||
if (size.width > 0 && size.height > 0) {
|
||||
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat(), topo)
|
||||
} 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(topo))
|
||||
.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() || targets.ships.isNotEmpty() ->
|
||||
board.nearestEdge(at, reach, targets.edges + targets.ships)?.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)
|
||||
drawPirate(state, board, style)
|
||||
drawMerchant(state, board, style, measurer)
|
||||
drawRoads(state, board, style)
|
||||
drawShips(state, board, style)
|
||||
drawEdgeTargets(targets, board, style)
|
||||
drawShipTargets(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)
|
||||
drawStaged(stagedVertex, stagedEdge, stagedSeat, board, style)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terrain and tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun DrawScope.drawHexes(
|
||||
state: CatanState,
|
||||
board: BoardLayout,
|
||||
style: CatanStyle,
|
||||
measurer: TextMeasurer,
|
||||
) {
|
||||
for (hex in 0 until state.topo.HEX_COUNT) {
|
||||
val centre = board.hexCenter(hex)
|
||||
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
|
||||
val path = polygon(corners)
|
||||
if (state.terrains[hex] == Terrain.SEA) {
|
||||
// Open water: only a faint outline, so ships have a grid to sail on.
|
||||
drawPath(path, color = style.hexEdge.copy(alpha = 0.12f), style = Stroke(width = board.radius * 0.02f))
|
||||
continue
|
||||
}
|
||||
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 state.topo.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 state.topo.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),
|
||||
)
|
||||
}
|
||||
|
||||
/** The ghost of a placement waiting for ✓: a solid piece in the player's colour with a bright ring. */
|
||||
private fun DrawScope.drawStaged(vertex: Int?, edge: Int?, seat: Int?, board: BoardLayout, style: CatanStyle) {
|
||||
val colour = seat?.let { style.seats.getOrNull(it) } ?: style.target
|
||||
if (edge != null) {
|
||||
val (a, b) = board.edgeEnds(edge)
|
||||
val from = lerp(a, b, 0.1f)
|
||||
val to = lerp(a, b, 0.9f)
|
||||
drawLine(style.targetSelected, from.offset(), to.offset(), board.radius * style.roadWidth * 2.1f, StrokeCap.Round)
|
||||
drawLine(colour, from.offset(), to.offset(), board.radius * style.roadWidth * 1.3f, StrokeCap.Round)
|
||||
}
|
||||
if (vertex != null) {
|
||||
val at = board.vertex(vertex).offset()
|
||||
val radius = board.radius * style.targetRadius
|
||||
drawCircle(colour, radius * 1.3f, at)
|
||||
drawCircle(style.targetSelected, radius * 1.9f, at, style = Stroke(width = board.radius * 0.1f))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seafarers: ships, the pirate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A ship on each sea edge that has one: a hull along the edge with a sail amidships, in the owner's colour. */
|
||||
private fun DrawScope.drawShips(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
val sea = state.sea ?: return
|
||||
for (edge in sea.ships.indices) {
|
||||
val owner = sea.ships[edge]
|
||||
if (owner == NO_PLAYER) continue
|
||||
val (a, b) = board.edgeEnds(edge)
|
||||
val from = lerp(a, b, 0.2f)
|
||||
val to = lerp(a, b, 0.8f)
|
||||
val width = board.radius * style.roadWidth * 1.15f
|
||||
val colour = style.seats[owner % style.seats.size]
|
||||
drawLine(style.pieceOutline, from.offset(), to.offset(), width * 1.5f, StrokeCap.Round)
|
||||
drawLine(colour, from.offset(), to.offset(), width, StrokeCap.Round)
|
||||
// The sail: a small triangle standing off the hull, on the screen's upper side.
|
||||
val mid = lerp(a, b, 0.5f)
|
||||
val s = board.radius * 0.17f
|
||||
val sail = Path().apply {
|
||||
moveTo(mid.x - s * 0.1f, mid.y - s * 1.9f)
|
||||
lineTo(mid.x + s * 1.1f, mid.y - s * 0.2f)
|
||||
lineTo(mid.x - s * 0.1f, mid.y - s * 0.2f)
|
||||
close()
|
||||
}
|
||||
drawPath(sail, style.shipSail)
|
||||
drawPath(sail, style.pieceOutline, style = Stroke(width = board.radius * 0.03f))
|
||||
}
|
||||
}
|
||||
|
||||
/** A ring round each of the player's ships that can sail, so they read as tappable. */
|
||||
private fun DrawScope.drawShipTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
|
||||
for (edge in targets.ships) {
|
||||
val mid = board.edgeMidpoint(edge).offset()
|
||||
drawCircle(style.target, board.radius * 0.38f, mid, style = Stroke(width = board.radius * 0.07f))
|
||||
}
|
||||
}
|
||||
|
||||
/** The pirate: a black pawn with a flag, drawn beside the middle of its sea hex like the robber. */
|
||||
private fun DrawScope.drawPirate(state: CatanState, board: BoardLayout, style: CatanStyle) {
|
||||
val sea = state.sea ?: return
|
||||
val hex = board.hexCenter(sea.pirate)
|
||||
val x = hex.x
|
||||
val y = hex.y + board.radius * 0.1f
|
||||
val s = board.radius * style.robberSize
|
||||
val hull = Path().apply {
|
||||
moveTo(x - s * 1.0f, y)
|
||||
lineTo(x + s * 1.0f, y)
|
||||
lineTo(x + s * 0.6f, y + s * 0.7f)
|
||||
lineTo(x - s * 0.6f, y + s * 0.7f)
|
||||
close()
|
||||
}
|
||||
drawPath(hull, style.pirate)
|
||||
drawLine(style.pirate, Offset(x, y), Offset(x, y - s * 1.5f), strokeWidth = s * 0.12f)
|
||||
val flag = Path().apply {
|
||||
moveTo(x, y - s * 1.5f)
|
||||
lineTo(x + s * 0.9f, y - s * 1.15f)
|
||||
lineTo(x, y - s * 0.8f)
|
||||
close()
|
||||
}
|
||||
drawPath(flag, style.pirate)
|
||||
drawPath(hull, Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f))
|
||||
}
|
||||
@@ -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,675 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.ui.LocalEndGameHandle
|
||||
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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gold (Seafarers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A gold field paid out: choose the resource cards it gives. Not dismissable: the game
|
||||
* is waiting. The engine's list says which resources the bank can still pay.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CatanGoldDialog(
|
||||
legal: List<CatanMove>,
|
||||
onSubmit: (org.prolibertate.games.game.catan.PickGold) -> Unit,
|
||||
) {
|
||||
val count = CatanInteraction.goldCount(legal)
|
||||
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
val selection = picked.asCards()
|
||||
val move = CatanInteraction.goldMove(legal, selection)
|
||||
// A resource is on offer if some legal pick names it.
|
||||
val offered = legal.filterIsInstance<org.prolibertate.games.game.catan.PickGold>()
|
||||
.flatMap { it.cards }.toSet()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text(CatanText.GOLD_TITLE) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Gold! Choose $count resource cards (${selection.size} of $count chosen).")
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Resource.entries.filter { it in offered }.forEach { resource ->
|
||||
ResourceCounter(
|
||||
resource = resource,
|
||||
count = picked[resource] ?: 0,
|
||||
max = if (selection.size < count) count else (picked[resource] ?: 0),
|
||||
onChange = { n -> picked = picked + (resource to n.coerceIn(0, count)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text(CatanText.GOLD_TAKE) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 = {
|
||||
// Ending a game on purpose also forgets the saved position: only
|
||||
// leaving the table keeps it to be picked up again.
|
||||
val handle = LocalEndGameHandle.current
|
||||
TextButton(onClick = { handle.end(); 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,143 @@
|
||||
package org.prolibertate.games.ui.game.catan
|
||||
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.Topology
|
||||
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,
|
||||
/** The board this layout draws: the standard island unless the game is Seafarers. */
|
||||
val topo: Topology = CatanBoard.base,
|
||||
) {
|
||||
private val centreX = width / 2f
|
||||
private val centreY = height / 2f
|
||||
private val unitX = radius * SQRT3 / 2f
|
||||
private val unitY = radius / 2f
|
||||
|
||||
/** The lattice point drawn at the middle of the box: the middle of the frame. */
|
||||
private val midX = (topo.lattice[0] + topo.lattice[1]) / 2f
|
||||
private val midY = (topo.lattice[2] + topo.lattice[3]) / 2f
|
||||
|
||||
fun point(latticeX: Int, latticeY: Int): Pt =
|
||||
Pt(centreX + (latticeX - midX) * unitX, centreY - (latticeY - midY) * unitY)
|
||||
|
||||
fun hexCenter(hex: Int): Pt {
|
||||
val (lx, ly) = topo.hexCenter(hex)
|
||||
return point(lx, ly)
|
||||
}
|
||||
|
||||
fun vertex(vertex: Int): Pt {
|
||||
val (lx, ly) = topo.VERTEX_POS[vertex]
|
||||
return point(lx, ly)
|
||||
}
|
||||
|
||||
fun hexCorners(hex: Int): List<Pt> = topo.HEX_CORNERS[hex].map { vertex(it) }
|
||||
|
||||
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
|
||||
val (a, b) = topo.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)
|
||||
// Straight out from the middle of the island on the standard board; on a map of
|
||||
// islands, straight out from the land hex the harbour sits on.
|
||||
val from = if (topo.EDGE_HEXES[edge].size == 1) {
|
||||
Pt(centreX, centreY)
|
||||
} else {
|
||||
topo.EDGE_HEXES[edge].firstOrNull { topo.land[it] }?.let { hexCenter(it) } ?: Pt(centreX, centreY)
|
||||
}
|
||||
val dx = mid.x - from.x
|
||||
val dy = mid.y - from.y
|
||||
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 {
|
||||
|
||||
/** Empty space round the island, in hex radii, for the sea and the harbours. */
|
||||
const val MARGIN = 1.75f
|
||||
|
||||
/** A Seafarers frame already ends in a ring of sea, so it needs almost none. */
|
||||
private const val FRAME_MARGIN = 0.3f
|
||||
|
||||
private fun margin(topo: Topology) = if (topo.land.all { it }) MARGIN else FRAME_MARGIN
|
||||
|
||||
private fun widthInRadii(topo: Topology) =
|
||||
(topo.lattice[1] - topo.lattice[0]) * SQRT3 / 2f + 2 * margin(topo)
|
||||
|
||||
private fun heightInRadii(topo: Topology) =
|
||||
(topo.lattice[3] - topo.lattice[2]) / 2f + 2 * margin(topo)
|
||||
|
||||
/** Width ÷ height of the board including its margin, for sizing its box. */
|
||||
val ASPECT: Float = widthInRadii(CatanBoard.base) / heightInRadii(CatanBoard.base)
|
||||
|
||||
fun aspect(topo: Topology): Float = widthInRadii(topo) / heightInRadii(topo)
|
||||
|
||||
/** The largest layout that fits [width] × [height] pixels. */
|
||||
fun fit(width: Float, height: Float, topo: Topology = CatanBoard.base): BoardLayout {
|
||||
val radius = minOf(width / widthInRadii(topo), height / heightInRadii(topo))
|
||||
return BoardLayout(width, height, radius, topo)
|
||||
}
|
||||
}
|
||||
|
||||
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,474 @@
|
||||
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.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/*
|
||||
* The heads-up display pieces of the landscape Katan screen: the controls the
|
||||
* Catan Universe match screen uses (see docs/RE-catan-universe.md), redrawn in
|
||||
* 2D. Stateless, like everything in CatanPanels.kt: values in, lambdas out.
|
||||
*/
|
||||
|
||||
/** A round HUD button: a glyph or short word, optionally with a one-word caption. */
|
||||
@Composable
|
||||
fun HudRoundButton(
|
||||
glyph: String,
|
||||
modifier: Modifier = Modifier,
|
||||
caption: String? = null,
|
||||
enabled: Boolean = true,
|
||||
primary: Boolean = false,
|
||||
tint: Color = Color.Unspecified,
|
||||
diameter: Dp = 56.dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val container = when {
|
||||
tint != Color.Unspecified -> tint
|
||||
primary -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.secondaryContainer
|
||||
}
|
||||
val ink = if (primary || tint != Color.Unspecified) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
|
||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(diameter)
|
||||
.clip(CircleShape)
|
||||
.background(container.copy(alpha = if (enabled) 1f else 0.35f))
|
||||
.border(2.dp, Color(0x55000000), CircleShape)
|
||||
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(glyph, color = ink.copy(alpha = if (enabled) 1f else 0.6f), fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
// Always laid out, so the button column never changes height.
|
||||
Text(caption ?: " ", fontSize = 10.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The left-hand build column: one icon per piece. **Drag one onto the board** and
|
||||
* release over a lit spot to stage it; or tap to arm the board and tap a spot. The
|
||||
* column reports pointer positions in window coordinates so the screen can find the
|
||||
* spot under the finger whatever the board's zoom and pan.
|
||||
*/
|
||||
@Composable
|
||||
fun HudBuildColumn(
|
||||
kinds: List<BuildKind>,
|
||||
canBuild: Set<BuildKind>,
|
||||
armed: BuildKind?,
|
||||
modifier: Modifier = Modifier,
|
||||
onTap: (BuildKind) -> Unit,
|
||||
onDragStart: (BuildKind, Offset) -> Unit,
|
||||
onDrag: (Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(3.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
kinds.forEach { kind ->
|
||||
BuildChip(kind, enabled = kind in canBuild, armed = armed == kind, onTap, onDragStart, onDrag, onDragEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuildChip(
|
||||
kind: BuildKind,
|
||||
enabled: Boolean,
|
||||
armed: Boolean,
|
||||
onTap: (BuildKind) -> Unit,
|
||||
onDragStart: (BuildKind, Offset) -> Unit,
|
||||
onDrag: (Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
) {
|
||||
var coords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
val start by rememberUpdatedState(onDragStart)
|
||||
val move by rememberUpdatedState(onDrag)
|
||||
val end by rememberUpdatedState(onDragEnd)
|
||||
val tap by rememberUpdatedState(onTap)
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(shape)
|
||||
.background(
|
||||
when {
|
||||
armed -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = if (enabled) 1f else 0.35f)
|
||||
},
|
||||
)
|
||||
.border(2.dp, Color(0x55000000), shape)
|
||||
.onGloballyPositioned { coords = it }
|
||||
.pointerInput(enabled, kind) {
|
||||
if (!enabled) return@pointerInput
|
||||
detectDragGestures(
|
||||
onDragStart = { local ->
|
||||
coords?.localToWindow(local)?.let { start(kind, it) }
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
change.consume()
|
||||
coords?.localToWindow(change.position)?.let { move(it) }
|
||||
},
|
||||
onDragEnd = { end() },
|
||||
onDragCancel = { end() },
|
||||
)
|
||||
}
|
||||
.then(if (enabled) Modifier.clickable { tap(kind) } else Modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
CatanHudGlyphs.of(kind),
|
||||
fontSize = 22.sp,
|
||||
color = if (armed) Color.White else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
Text(CatanHudGlyphs.label(kind), fontSize = 10.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Glyphs and captions of the build column. Kept apart so the art can be swapped for drawables. */
|
||||
object CatanHudGlyphs {
|
||||
fun of(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.ROAD -> "━"
|
||||
BuildKind.SETTLEMENT -> "⌂"
|
||||
BuildKind.CITY -> "▲"
|
||||
BuildKind.WALL -> "▦"
|
||||
BuildKind.KNIGHT -> "♞"
|
||||
BuildKind.SHIP -> "⛵"
|
||||
}
|
||||
|
||||
fun label(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
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
}
|
||||
}
|
||||
|
||||
/** ✓ beside ✗: confirm or cancel a staged placement. Always laid out; enabled only while something is staged. */
|
||||
@Composable
|
||||
fun HudConfirmButtons(
|
||||
staged: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.Bottom) {
|
||||
HudRoundButton("✓", caption = CatanText.CONFIRM_BUILD, enabled = staged, tint = Color(0xFF2E7D32), diameter = 48.dp, onClick = onConfirm)
|
||||
HudRoundButton("✕", caption = CatanText.CANCEL_BUILD, enabled = staged, tint = Color(0xFFC62828), diameter = 48.dp, onClick = onCancel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pan with one finger, pinch to zoom, like the Universe board camera. The content
|
||||
* is drawn through a graphics layer, so taps inside it arrive already in the
|
||||
* content's own coordinates and the board's hit-testing needs no changes.
|
||||
*/
|
||||
@Composable
|
||||
fun ZoomPanBox(
|
||||
modifier: Modifier = Modifier,
|
||||
minScale: Float = 1f,
|
||||
maxScale: Float = 3f,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(0.dp))
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||
val newScale = (scale * zoom).coerceIn(minScale, maxScale)
|
||||
val centre = Offset(size.width / 2f, size.height / 2f)
|
||||
// The content point under the fingers stays under them while zooming; then pan.
|
||||
val world = (centroid - centre - offset) / scale
|
||||
val next = centroid - centre - world * newScale + pan
|
||||
val maxX = size.width * (newScale - 1f) / 2f
|
||||
val maxY = size.height * (newScale - 1f) / 2f
|
||||
offset = Offset(next.x.coerceIn(-maxX, maxX), next.y.coerceIn(-maxY, maxY))
|
||||
scale = newScale
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offset.x
|
||||
translationY = offset.y
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A small caption pill, for the drag hint and the staged-move text. */
|
||||
@Composable
|
||||
fun HudPill(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color(0xCC000000))
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The landscape screen, laid out like the Catan Universe match view:
|
||||
*
|
||||
* ```
|
||||
* ┌ player strip ───────────────────────────────────────────────┐
|
||||
* │ prompt + dice │
|
||||
* │ [build] the island (pinch / pan) [✓] [✕] │
|
||||
* │ [column] │
|
||||
* │ [log] ┌ your cards ┐ [buy][play] [⇄][●]│
|
||||
* └─────────────┴────────────┴───────────────────────────────────┘
|
||||
* ```
|
||||
*
|
||||
* Everything here is wiring around the existing panels; the island is handed in as [board].
|
||||
*/
|
||||
@Composable
|
||||
fun CatanHud(
|
||||
state: org.prolibertate.games.game.catan.CatanState,
|
||||
seats: CatanSeats,
|
||||
style: CatanStyle,
|
||||
actions: CatanActions,
|
||||
prompt: String,
|
||||
notice: String?,
|
||||
legal: List<org.prolibertate.games.game.catan.CatanMove>,
|
||||
localSeat: Int,
|
||||
targets: BoardTargets,
|
||||
pointsOf: (Int) -> Int,
|
||||
staged: org.prolibertate.games.game.catan.CatanMove?,
|
||||
dragKind: BuildKind?,
|
||||
dragAt: Offset?,
|
||||
showLog: Boolean,
|
||||
boardCoords: LayoutCoordinates?,
|
||||
building: BuildKind?,
|
||||
onBoardCoords: (LayoutCoordinates) -> Unit,
|
||||
onToggleLog: () -> Unit,
|
||||
onArm: (BuildKind) -> Unit,
|
||||
onDragStart: (BuildKind, Offset) -> Unit,
|
||||
onDrag: (Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onCancelStaged: () -> Unit,
|
||||
onRoll: () -> Unit,
|
||||
onEndTurn: () -> Unit,
|
||||
onEndFreeRoads: () -> Unit,
|
||||
onEndSmith: () -> Unit,
|
||||
onRepickSetup: () -> Unit,
|
||||
onBuyCard: () -> Unit,
|
||||
onPlayCard: () -> Unit,
|
||||
onTrade: () -> Unit,
|
||||
onImprove: () -> Unit,
|
||||
onProgress: () -> Unit,
|
||||
board: @Composable (Modifier) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
HudPlayerStrip(state, seats, pointsOf, style)
|
||||
// Cities & Knights: the ship and the improvement levels are as much part
|
||||
// of the game as the scores, so they sit with them rather than in a panel
|
||||
// this layout does not have room for.
|
||||
if (state.isCk) CatanBarbarians(state, Modifier.padding(horizontal = 8.dp), style)
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
// The island fills the whole area; controls float over its sea margin.
|
||||
ZoomPanBox(Modifier.fillMaxSize().padding(start = 64.dp, end = 64.dp, top = 30.dp, bottom = 44.dp)) {
|
||||
board(Modifier.onGloballyPositioned(onBoardCoords))
|
||||
}
|
||||
|
||||
// What is going on, and the dice. Two lines, always.
|
||||
Row(
|
||||
Modifier.align(Alignment.TopCenter).padding(horizontal = 64.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f))
|
||||
.padding(horizontal = 8.dp, vertical = 1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = notice ?: prompt,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
|
||||
}
|
||||
|
||||
// Left: pieces to drag onto the island.
|
||||
HudBuildColumn(
|
||||
kinds = actions.buildKinds,
|
||||
canBuild = actions.canBuild,
|
||||
armed = building,
|
||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 6.dp),
|
||||
onTap = onArm,
|
||||
onDragStart = onDragStart,
|
||||
onDrag = onDrag,
|
||||
onDragEnd = onDragEnd,
|
||||
)
|
||||
|
||||
// Right, middle: the card actions.
|
||||
Column(
|
||||
Modifier.align(Alignment.CenterEnd).padding(end = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (actions.ck) {
|
||||
HudRoundButton("⬆", caption = CatanText.IMPROVE, enabled = actions.canImprove, diameter = 44.dp, onClick = onImprove)
|
||||
HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayProgress, diameter = 44.dp, onClick = onProgress)
|
||||
} else {
|
||||
HudRoundButton("+", caption = CatanText.BUY_SHORT, enabled = actions.canBuyCard, diameter = 44.dp, onClick = onBuyCard)
|
||||
HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayCard, diameter = 44.dp, onClick = onPlayCard)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom centre: your cards.
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp, start = 80.dp, end = 270.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(6.dp),
|
||||
) { CatanHand(state, localSeat) }
|
||||
|
||||
// Bottom left: the log.
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = 6.dp, bottom = 4.dp)) {
|
||||
if (showLog) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
CatanLog(state, Modifier.padding(8.dp).height(150.dp).width(260.dp))
|
||||
}
|
||||
}
|
||||
HudRoundButton("☰", caption = CatanText.LOG_BUTTON, diameter = 40.dp, onClick = onToggleLog)
|
||||
}
|
||||
|
||||
// Bottom right: place / cancel a lined-up piece, trade, and the turn button.
|
||||
Row(
|
||||
Modifier.align(Alignment.BottomEnd).padding(end = 6.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
HudConfirmButtons(staged = staged != null, onConfirm = onConfirm, onCancel = onCancelStaged)
|
||||
HudRoundButton("⇄", caption = CatanText.TRADE, enabled = actions.canTrade, diameter = 48.dp, onClick = onTrade)
|
||||
PrimaryTurnButton(actions, onRoll, onEndTurn, onEndFreeRoads, onEndSmith, onRepickSetup)
|
||||
}
|
||||
|
||||
if (dragKind != null && dragAt == null) HudPill(CatanText.DRAG_HINT, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The one big button: roll, end the turn, finish roads, or step back from a setup choice. */
|
||||
@Composable
|
||||
private fun PrimaryTurnButton(
|
||||
actions: CatanActions,
|
||||
onRoll: () -> Unit,
|
||||
onEndTurn: () -> Unit,
|
||||
onEndFreeRoads: () -> Unit,
|
||||
onEndSmith: () -> Unit,
|
||||
onRepickSetup: () -> Unit,
|
||||
) {
|
||||
val (glyph, caption, enabled, click) = when {
|
||||
actions.canRoll -> Quad("🎲", CatanText.ROLL_SHORT, true, onRoll)
|
||||
actions.canEndFreeRoads -> Quad("✔", CatanText.DONE_SHORT, true, onEndFreeRoads)
|
||||
actions.canEndSmith -> Quad("✔", CatanText.DONE_SHORT, true, onEndSmith)
|
||||
actions.canRepickSetup -> Quad("↺", CatanText.CANCEL, true, onRepickSetup)
|
||||
else -> Quad("➜", CatanText.END_SHORT, actions.canEndTurn, onEndTurn)
|
||||
}
|
||||
HudRoundButton(glyph, caption = caption, enabled = enabled, primary = true, diameter = 72.dp, onClick = click)
|
||||
}
|
||||
|
||||
private data class Quad(val glyph: String, val caption: String, val enabled: Boolean, val click: () -> Unit)
|
||||
|
||||
/** Everyone's colour, name, points and card count in one thin row across the top. */
|
||||
@Composable
|
||||
private fun HudPlayerStrip(
|
||||
state: org.prolibertate.games.game.catan.CatanState,
|
||||
seats: CatanSeats,
|
||||
pointsOf: (Int) -> Int,
|
||||
style: CatanStyle,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
(0 until state.playerCount).forEach { seat ->
|
||||
val onTurn = seat == state.turn
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Row(
|
||||
Modifier.weight(1f)
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.border(if (onTurn) 2.dp else 1.dp, if (onTurn) MaterialTheme.colorScheme.primary else Color(0x33000000), shape)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(14.dp).clip(CircleShape).background(style.seats[seat]).border(1.dp, style.pieceOutline, CircleShape),
|
||||
)
|
||||
Text(seats.label(seat), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, modifier = Modifier.weight(1f))
|
||||
// Trade, politics, science: the three improvement levels.
|
||||
if (state.isCk) {
|
||||
Text(
|
||||
text = org.prolibertate.games.game.catan.Track.entries.joinToString(" ") {
|
||||
"${it.name.first()}${org.prolibertate.games.game.catan.CatanCk.improvement(state, seat, it)}"
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Text("${pointsOf(seat)} VP · ${state.resourceCount(seat)}🂠", fontSize = 12.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
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.BuildShip
|
||||
import org.prolibertate.games.game.catan.MovePirate
|
||||
import org.prolibertate.games.game.catan.MoveShip
|
||||
import org.prolibertate.games.game.catan.Topology
|
||||
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.PickGold
|
||||
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,
|
||||
|
||||
/** Seafarers: a ship, laid on a sea edge. */
|
||||
SHIP;
|
||||
|
||||
companion object {
|
||||
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
|
||||
|
||||
/** Seafarers adds the ship, listed after the road it sails beside. */
|
||||
val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, 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(),
|
||||
/** Seafarers: the player's ships that can sail this turn, tapped to pick one up. */
|
||||
val ships: 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() && ships.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 (or the pirate's) hex has more than one possible victim: ask which. */
|
||||
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>, val pirate: Boolean = false) : TapOutcome
|
||||
|
||||
/** Seafarers: one of the player's ships was tapped; its destination is tapped next. */
|
||||
data class PickShip(val edge: 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 {
|
||||
|
||||
// --- Staged placements and drag-to-build ------------------------------------
|
||||
|
||||
/** A place on the board a dragged piece is over: one crossing or one edge. */
|
||||
data class Spot(val vertex: Int? = null, val edge: Int? = null)
|
||||
|
||||
/**
|
||||
* The spot a finger at [at] (board pixels) is nearest to, among [targets]. [reach] is
|
||||
* generous (fingers cover what they point at), and edges are only offered when no
|
||||
* crossing is lit, matching what a tap would do.
|
||||
*/
|
||||
fun snap(layout: BoardLayout, targets: BoardTargets, at: Pt, reach: Float): Spot? = when {
|
||||
targets.tappableVertices.isNotEmpty() ->
|
||||
layout.nearestVertex(at, reach, targets.tappableVertices)?.let { Spot(vertex = it) }
|
||||
|
||||
targets.edges.isNotEmpty() -> layout.nearestEdge(at, reach, targets.edges)?.let { Spot(edge = it) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** The move a build tap or drop would make, if it is a placement that waits for ✓. */
|
||||
fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? {
|
||||
if (building == null || outcome !is TapOutcome.Submit) return null
|
||||
return outcome.move.takeIf {
|
||||
it is BuildRoad || it is BuildShip || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a staged move sits on the board, for drawing its ghost. */
|
||||
fun spotOf(move: CatanMove?): Spot = when (move) {
|
||||
is BuildRoad -> Spot(edge = move.edge)
|
||||
is BuildShip -> Spot(edge = move.edge)
|
||||
is BuildSettlement -> Spot(vertex = move.vertex)
|
||||
is BuildCity -> Spot(vertex = move.vertex)
|
||||
is BuildWall -> Spot(vertex = move.vertex)
|
||||
is HireKnight -> Spot(vertex = move.vertex)
|
||||
else -> Spot()
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
/** Seafarers: the ship that has been picked up and is waiting for a destination. */
|
||||
movingShip: 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>()
|
||||
val pirate = legal.filterIsInstance<MovePirate>()
|
||||
if (robber.isNotEmpty() || pirate.isNotEmpty()) {
|
||||
return BoardTargets(
|
||||
hexes = (robber.map { it.hex } + pirate.map { it.hex }).mapNotNull { state.topo.hexIndex[it] }.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()
|
||||
val ships = legal.filterIsInstance<BuildShip>().map { it.edge }.toSet()
|
||||
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads + ships)
|
||||
|
||||
if (movingShip != null) {
|
||||
return BoardTargets(
|
||||
edges = legal.filterIsInstance<MoveShip>().filter { it.from == movingShip }.map { it.to }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
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.SHIP -> BoardTargets(edges = ships)
|
||||
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,
|
||||
ships = legal.filterIsInstance<MoveShip>().map { it.from }.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 ----------------------------------------------
|
||||
|
||||
/** The gold pick that names exactly [selection], if the engine allows it. */
|
||||
fun goldMove(legal: List<CatanMove>, selection: List<Resource>): PickGold? {
|
||||
val want = selection.groupingBy { it }.eachCount()
|
||||
return legal.filterIsInstance<PickGold>().firstOrNull { it.cards.groupingBy { c -> c }.eachCount() == want }
|
||||
}
|
||||
|
||||
/** How many cards the gold pick on offer must name. */
|
||||
fun goldCount(legal: List<CatanMove>): Int = legal.filterIsInstance<PickGold>().firstOrNull()?.cards?.size ?: 0
|
||||
|
||||
/** 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,
|
||||
/** Seafarers: the ship picked up and waiting for a destination. */
|
||||
movingShip: Int? = 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
|
||||
}
|
||||
if (movingShip != null) {
|
||||
val move = MoveShip(movingShip, edge)
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
// Free roads (and free ships) need no build mode; a paid one does.
|
||||
val free = legal.any { it is EndFreeRoads }
|
||||
if (building == BuildKind.SHIP || (free && BuildShip(edge) in legal)) {
|
||||
val move = BuildShip(edge)
|
||||
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
|
||||
}
|
||||
if (building == null && !free) {
|
||||
// Nothing armed: tapping one of your own ships that can sail picks it up.
|
||||
return if (legal.any { it is MoveShip && it.from == edge }) TapOutcome.PickShip(edge) else TapOutcome.Ignored
|
||||
}
|
||||
val canBuild = building == BuildKind.ROAD || free
|
||||
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,
|
||||
/** The board the hex index belongs to (the standard island unless the game is Seafarers). */
|
||||
topo: Topology = CatanBoard.base,
|
||||
): TapOutcome {
|
||||
if (armed != null) return onArmedHexTap(hex, legal, armed)
|
||||
val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
|
||||
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
||||
val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
|
||||
return when {
|
||||
moves.isNotEmpty() -> if (moves.size == 1) {
|
||||
TapOutcome.Submit(moves.first())
|
||||
} else {
|
||||
TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
|
||||
}
|
||||
|
||||
pirates.isNotEmpty() -> if (pirates.size == 1) {
|
||||
TapOutcome.Submit(pirates.first())
|
||||
} else {
|
||||
TapOutcome.PickRobberVictim(coord, pirates.mapNotNull { it.victim }, pirate = true)
|
||||
}
|
||||
|
||||
else -> TapOutcome.Ignored
|
||||
}
|
||||
}
|
||||
|
||||
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 state.topo.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 BuildShip }) add(BuildKind.SHIP)
|
||||
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,636 @@
|
||||
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
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
}
|
||||
|
||||
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
|
||||
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
|
||||
}
|
||||
|
||||
@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,731 @@
|
||||
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.layout.padding
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.prolibertate.games.game.catan.BuildRoad
|
||||
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.platform.LocalConfiguration
|
||||
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.MoveShip
|
||||
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) }
|
||||
// Seafarers: the ship that has been picked up and is waiting for somewhere to sail.
|
||||
var movingShip by remember { mutableStateOf<Int?>(null) }
|
||||
var showImprove by remember { mutableStateOf(false) }
|
||||
var showProgress by remember { mutableStateOf(false) }
|
||||
// The landscape HUD: a placement waiting for its ✓, the piece being dragged from the
|
||||
// build column (and where the finger is, in window pixels), the log popup, and the board.
|
||||
var staged by remember { mutableStateOf<CatanMove?>(null) }
|
||||
var dragKind by remember { mutableStateOf<BuildKind?>(null) }
|
||||
var dragAt by remember { mutableStateOf<Offset?>(null) }
|
||||
var showLog by remember { mutableStateOf(false) }
|
||||
var boardCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
val config = LocalConfiguration.current
|
||||
// Landscape gets the heads-up display; portrait keeps the stacked layout.
|
||||
val wide = config.screenWidthDp > config.screenHeightDp * 1.1f
|
||||
val staging = wide
|
||||
|
||||
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 (staged != null && staged !in legal) staged = 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
|
||||
if (movingShip != null && legal.none { it is MoveShip && it.from == movingShip }) movingShip = 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
|
||||
staged = 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
|
||||
movingShip = null
|
||||
armed = null
|
||||
staged = null
|
||||
dragKind = null
|
||||
dragAt = 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, movingShip)
|
||||
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 = when {
|
||||
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
|
||||
current.isSea -> BuildKind.SEAFARERS
|
||||
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,
|
||||
movingShip = movingShip != null,
|
||||
)
|
||||
|
||||
val handle: (TapOutcome) -> Unit = { outcome ->
|
||||
val wait = if (staging) CatanInteraction.stageable(outcome, building) else null
|
||||
when {
|
||||
wait != null -> staged = wait
|
||||
else -> 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.PickShip -> movingShip = outcome.edge
|
||||
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) {
|
||||
// Phones (either way up) get the one-button build menu.
|
||||
val compactBuild = !wide || maxWidth < 600.dp
|
||||
|
||||
val hoverSpot = if (dragKind != null) dragSpot(boardCoords, dragAt, targets) else null
|
||||
val stagedSpot = CatanInteraction.spotOf(staged)
|
||||
val ghostVertex = hoverSpot?.vertex ?: stagedSpot.vertex
|
||||
val ghostEdge = hoverSpot?.edge ?: stagedSpot.edge
|
||||
|
||||
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, movingShip)) },
|
||||
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed, current.topo)) },
|
||||
modifier = boardModifier,
|
||||
style = style,
|
||||
rolledHexes = rolledHexes,
|
||||
selectedKnight = movingKnight ?: knightMenu,
|
||||
stagedVertex = ghostVertex,
|
||||
stagedEdge = ghostEdge,
|
||||
stagedSeat = localSeat,
|
||||
)
|
||||
}
|
||||
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) {
|
||||
CatanHud(
|
||||
state = current,
|
||||
seats = seats,
|
||||
style = style,
|
||||
actions = actions,
|
||||
prompt = prompt,
|
||||
notice = notice,
|
||||
legal = legal,
|
||||
localSeat = localSeat,
|
||||
targets = targets,
|
||||
pointsOf = pointsOf,
|
||||
staged = staged,
|
||||
dragKind = dragKind,
|
||||
dragAt = dragAt,
|
||||
showLog = showLog,
|
||||
boardCoords = boardCoords,
|
||||
building = building,
|
||||
onBoardCoords = { boardCoords = it },
|
||||
onToggleLog = { showLog = !showLog },
|
||||
onArm = { kind -> building = if (building == kind) null else kind; staged = null },
|
||||
onDragStart = { kind, at ->
|
||||
building = kind
|
||||
staged = null
|
||||
dragKind = kind
|
||||
dragAt = at
|
||||
},
|
||||
onDrag = { dragAt = it },
|
||||
onDragEnd = {
|
||||
val spot = dragSpot(boardCoords, dragAt, targets)
|
||||
dragKind = null
|
||||
dragAt = null
|
||||
val outcome = when {
|
||||
spot?.vertex != null -> CatanInteraction.onVertexTap(
|
||||
spot.vertex, legal, building, setupVertex,
|
||||
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
|
||||
)
|
||||
spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed, movingShip)
|
||||
else -> TapOutcome.Ignored
|
||||
}
|
||||
handle(outcome)
|
||||
},
|
||||
onConfirm = { staged?.let(submit) },
|
||||
onCancelStaged = { staged = null; building = null },
|
||||
onRoll = { submit(RollDice()) },
|
||||
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
|
||||
onEndFreeRoads = { legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit) },
|
||||
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
|
||||
onRepickSetup = { setupVertex = null },
|
||||
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
|
||||
onPlayCard = { showCards = true },
|
||||
onTrade = { showTrade = true },
|
||||
onImprove = { showImprove = true },
|
||||
onProgress = { showProgress = true },
|
||||
board = { m -> board(m) },
|
||||
)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
// The trade board slides up over the bottom of the island (base game; Cities &
|
||||
// Knights trades goods and still uses its dialogs).
|
||||
val offerToMe = current.pendingTrade?.takeIf {
|
||||
current.phase == CatanPhase.TRADE_PENDING && it.asking == localSeat && !current.isCk
|
||||
}
|
||||
val boardsAllowed = abandoned == null && !confirmingEnd && current.phase != CatanPhase.GAME_OVER
|
||||
if (boardsAllowed && offerToMe != null) {
|
||||
CatanTradeReplyBoard(
|
||||
state = current,
|
||||
seat = localSeat,
|
||||
offer = offerToMe,
|
||||
seats = seats,
|
||||
canAccept = CatanInteraction.tradeResponses(legal).first,
|
||||
onAccept = { submit(AcceptTrade) },
|
||||
onDecline = { submit(DeclineTrade) },
|
||||
onCounter = submit,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
style = style,
|
||||
)
|
||||
} else if (boardsAllowed && showTrade && !current.isCk) {
|
||||
CatanTradeBoard(
|
||||
state = current,
|
||||
seat = localSeat,
|
||||
seats = seats,
|
||||
legal = legal,
|
||||
onBank = submit,
|
||||
onOffer = submit,
|
||||
onClose = { showTrade = false },
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 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.GOLD && CatanRules.currentSeat(state) == localSeat && legal.isNotEmpty() ->
|
||||
CatanGoldDialog(legal, 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_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(
|
||||
if (victimChoice.pirate) {
|
||||
org.prolibertate.games.game.catan.MovePirate(victimChoice.hex, victim)
|
||||
} else {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/** The board spot under a dragged finger ([at], in window pixels), or null off the board. */
|
||||
private fun dragSpot(coords: LayoutCoordinates?, at: Offset?, targets: BoardTargets): CatanInteraction.Spot? {
|
||||
if (coords == null || at == null || !coords.isAttached) return null
|
||||
val local = coords.windowToLocal(at)
|
||||
val layout = CatanGeometry.fit(coords.size.width.toFloat(), coords.size.height.toFloat())
|
||||
return CatanInteraction.snap(layout, targets, Pt(local.x, local.y), layout.radius * 1.2f)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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,
|
||||
/** Seafarers: the pirate's flag, and the hull and sail of a ship. */
|
||||
val pirate: Color = Color(0xFF000000),
|
||||
val shipSail: Color = Color(0xFFFFFFFF),
|
||||
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),
|
||||
// Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow.
|
||||
Terrain.SEA to Color(0xFF3A7CA5),
|
||||
Terrain.GOLD to Color(0xFFF2B01E),
|
||||
),
|
||||
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,521 @@
|
||||
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"
|
||||
Terrain.SEA -> "Sea"
|
||||
Terrain.GOLD -> "Gold field"
|
||||
}
|
||||
|
||||
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 SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)."
|
||||
const val SHIP = "Ship"
|
||||
const val SHIP_COST_TEXT = "1 lumber + 1 wool"
|
||||
const val GOLD_TITLE = "Gold field"
|
||||
const val GOLD_TAKE = "Take"
|
||||
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"
|
||||
|
||||
// --- Trade board and HUD -----------------------------------------------------
|
||||
const val TAB_PLAYER = "Player"
|
||||
const val TAB_BANK = "Bank"
|
||||
const val YOU_RECEIVE_ROW = "You get"
|
||||
const val YOU_GIVE_ROW = "You give"
|
||||
const val TRADE_HINT_PLAYER = "Tap cards to build an offer. Tap a player to address it to them alone."
|
||||
const val TRADE_HINT_BANK = "Tap what you give, then what you want."
|
||||
const val TRADE_HINT_BANK_EMPTY = "You do not hold enough of any one resource to trade with the bank."
|
||||
const val TRADE_HINT_COUNTER = "Change the terms, then send them back."
|
||||
const val HIDE = "Hide"
|
||||
const val DENY = "Deny"
|
||||
const val CONFIRM_BUILD = "Place"
|
||||
const val CANCEL_BUILD = "Cancel"
|
||||
const val DRAG_HINT = "Drag a piece onto the board"
|
||||
const val LOG_BUTTON = "Log"
|
||||
const val CARDS_SHORT = "Cards"
|
||||
const val BUY_SHORT = "Buy"
|
||||
const val ROLL_SHORT = "Roll"
|
||||
const val END_SHORT = "End"
|
||||
const val DONE_SHORT = "Done"
|
||||
const val LOG_TITLE = "Table log"
|
||||
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,
|
||||
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */
|
||||
movingShip: Boolean = false,
|
||||
): 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) {
|
||||
if (state.isSea) SEA_ROBBER else "Move the robber. Tap a hex."
|
||||
} else {
|
||||
"$turn is moving the robber…"
|
||||
}
|
||||
|
||||
CatanPhase.GOLD ->
|
||||
if (mine) {
|
||||
"Gold! Choose ${state.sea?.gold?.firstOrNull()?.count ?: 1} resource cards."
|
||||
} else {
|
||||
"${seats.label(onClock ?: state.turn)} is choosing from the gold…"
|
||||
}
|
||||
|
||||
CatanPhase.PLAYING -> when {
|
||||
!mine -> "$turn is playing…"
|
||||
movingShip -> "Tap the sea edge to sail the ship to."
|
||||
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
|
||||
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.GOLD -> ""
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.alpha
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.CounterTrade
|
||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||
import org.prolibertate.games.game.catan.OfferTrade
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.TradeOffer
|
||||
|
||||
/*
|
||||
* The trade board: the 2D version of Catan Universe's trade panel (see
|
||||
* docs/RE-catan-universe.md). One panel slides up over the bottom of the board:
|
||||
*
|
||||
* header [Player | Bank] hint rival buttons
|
||||
* top row the cards you GET, tinted with the other side's colour
|
||||
* arrows
|
||||
* bottom row the cards you GIVE, tinted with your colour
|
||||
* right ▾ hide ✕ deny ✓ accept
|
||||
*
|
||||
* Like every Katan panel it decides nothing: what may be offered is asked of
|
||||
* [CatanInteraction], and what a bank trade costs comes from the engine's legal list.
|
||||
*/
|
||||
|
||||
private enum class TradeMode { PLAYER, BANK }
|
||||
|
||||
private fun Map<Resource, Int>.cards(): List<Resource> =
|
||||
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
|
||||
|
||||
/** The panel for starting a trade: with the bank, or an offer to one rival or everyone. */
|
||||
@Composable
|
||||
fun CatanTradeBoard(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
seats: CatanSeats,
|
||||
legal: List<CatanMove>,
|
||||
onBank: (MaritimeTrade) -> Unit,
|
||||
onOffer: (OfferTrade) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
) {
|
||||
var mode by remember { mutableStateOf(TradeMode.BANK) }
|
||||
var to by remember { mutableStateOf<Int?>(null) }
|
||||
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
|
||||
var bankGive by remember { mutableStateOf<Resource?>(null) }
|
||||
var bankGet by remember { mutableStateOf<Resource?>(null) }
|
||||
|
||||
val bank = CatanInteraction.bankTrades(legal)
|
||||
val giveCards = give.cards()
|
||||
val getCards = get.cards()
|
||||
val canSend = CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
|
||||
val bankMove = bankGive?.let { g -> bankGet?.takeIf { it in bank[g].orEmpty() }?.let { MaritimeTrade(g, it) } }
|
||||
|
||||
val rivals = (0 until state.playerCount).filter { it != seat }
|
||||
val headerRight: @Composable () -> Unit = {
|
||||
if (mode == TradeMode.PLAYER) {
|
||||
RivalButtons(rivals, to, style, seats) { to = if (to == it) null else it }
|
||||
}
|
||||
}
|
||||
|
||||
TradeBoardFrame(
|
||||
modifier = modifier,
|
||||
topTint = if (mode == TradeMode.PLAYER && to != null) style.seats[to!!] else Color.Gray,
|
||||
bottomTint = style.seats[seat],
|
||||
header = {
|
||||
TradeTab(CatanText.TAB_BANK, mode == TradeMode.BANK) { mode = TradeMode.BANK }
|
||||
TradeTab(CatanText.TAB_PLAYER, mode == TradeMode.PLAYER) { mode = TradeMode.PLAYER }
|
||||
},
|
||||
headline = when {
|
||||
mode == TradeMode.PLAYER -> CatanText.TRADE_HINT_PLAYER
|
||||
bank.isEmpty() -> CatanText.TRADE_HINT_BANK_EMPTY
|
||||
else -> CatanText.TRADE_HINT_BANK
|
||||
},
|
||||
headerRight = headerRight,
|
||||
topLabel = CatanText.YOU_RECEIVE_ROW,
|
||||
bottomLabel = CatanText.YOU_GIVE_ROW,
|
||||
topRow = {
|
||||
if (mode == TradeMode.PLAYER) {
|
||||
TradeCardRow(
|
||||
counts = get,
|
||||
limit = { 9 },
|
||||
onChange = { get = it },
|
||||
)
|
||||
} else {
|
||||
val choices = bankGive?.let { bank[it] }.orEmpty().toSet()
|
||||
TradeCardRow(
|
||||
counts = bankGet?.let { mapOf(it to 1) } ?: emptyMap(),
|
||||
limit = { if (it in choices) 1 else 0 },
|
||||
enabledOf = { it in choices },
|
||||
onChange = {},
|
||||
pick = { bankGet = if (bankGet == it) null else it },
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomRow = {
|
||||
if (mode == TradeMode.PLAYER) {
|
||||
TradeCardRow(
|
||||
counts = give,
|
||||
limit = { state.resources[seat][it] ?: 0 },
|
||||
onChange = { give = it },
|
||||
)
|
||||
} else {
|
||||
TradeCardRow(
|
||||
counts = bank.keys.associateWith { CatanInteraction.bankRatio(state, seat, it) },
|
||||
limit = { 0 },
|
||||
enabledOf = { it in bank },
|
||||
selected = bankGive,
|
||||
caption = { r -> if (r in bank) "${CatanInteraction.bankRatio(state, seat, r)} : 1" else " " },
|
||||
onChange = {},
|
||||
pick = { r ->
|
||||
bankGive = if (bankGive == r) null else r
|
||||
bankGet = null
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
acceptEnabled = if (mode == TradeMode.PLAYER) canSend else bankMove != null,
|
||||
onAccept = {
|
||||
if (mode == TradeMode.PLAYER) {
|
||||
onOffer(OfferTrade(to, giveCards, getCards))
|
||||
} else {
|
||||
bankMove?.let(onBank)
|
||||
}
|
||||
},
|
||||
denyEnabled = true,
|
||||
onDeny = {
|
||||
give = emptyMap()
|
||||
get = emptyMap()
|
||||
bankGive = null
|
||||
bankGet = null
|
||||
to = null
|
||||
},
|
||||
onHide = onClose,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel for an offer made to the local player: the same layout, read-only. ✓ accepts,
|
||||
* ✕ declines, and *Counter…* turns the rows editable, pre-filled with the mirror of the offer.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanTradeReplyBoard(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
offer: TradeOffer,
|
||||
seats: CatanSeats,
|
||||
canAccept: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
onCounter: (CounterTrade) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: CatanStyle = LocalCatanStyle.current,
|
||||
) {
|
||||
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()) }
|
||||
val counter = CounterTrade(give.cards(), get.cards())
|
||||
|
||||
TradeBoardFrame(
|
||||
modifier = modifier,
|
||||
topTint = style.seats[offer.by],
|
||||
bottomTint = style.seats[seat],
|
||||
header = {
|
||||
TradeTab(CatanText.COUNTER, countering) { countering = !countering }
|
||||
},
|
||||
headline = if (countering) {
|
||||
CatanText.TRADE_HINT_COUNTER
|
||||
} else {
|
||||
CatanText.tradeSummary(
|
||||
by = seats.label(offer.by),
|
||||
to = if (offer.to == null) "the table" else "you",
|
||||
give = offer.give,
|
||||
get = offer.get,
|
||||
)
|
||||
},
|
||||
headerRight = {},
|
||||
topLabel = CatanText.YOU_RECEIVE_ROW,
|
||||
bottomLabel = CatanText.YOU_GIVE_ROW,
|
||||
topRow = {
|
||||
if (countering) {
|
||||
TradeCardRow(get, limit = { state.resources[offer.by][it] ?: 0 }, onChange = { get = it })
|
||||
} else {
|
||||
TradeCardRow(offer.give.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {})
|
||||
}
|
||||
},
|
||||
bottomRow = {
|
||||
if (countering) {
|
||||
TradeCardRow(give, limit = { state.resources[seat][it] ?: 0 }, onChange = { give = it })
|
||||
} else {
|
||||
TradeCardRow(offer.get.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {})
|
||||
}
|
||||
},
|
||||
acceptEnabled = if (countering) {
|
||||
CatanInteraction.counterAllowed(state, seat, counter.give, counter.get)
|
||||
} else {
|
||||
canAccept
|
||||
},
|
||||
onAccept = { if (countering) onCounter(counter) else onAccept() },
|
||||
denyEnabled = true,
|
||||
onDeny = onDecline,
|
||||
onHide = null,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun TradeBoardFrame(
|
||||
topTint: Color,
|
||||
bottomTint: Color,
|
||||
header: @Composable () -> Unit,
|
||||
headline: String,
|
||||
headerRight: @Composable () -> Unit,
|
||||
topLabel: String,
|
||||
bottomLabel: String,
|
||||
topRow: @Composable () -> Unit,
|
||||
bottomRow: @Composable () -> Unit,
|
||||
acceptEnabled: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
denyEnabled: Boolean,
|
||||
onDeny: () -> Unit,
|
||||
onHide: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
|
||||
tonalElevation = 6.dp,
|
||||
shadowElevation = 12.dp,
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
header()
|
||||
Text(
|
||||
headline,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
// Two lines, always: the panel keeps its height whatever is said.
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
headerRight()
|
||||
}
|
||||
HorizontalDivider()
|
||||
TintedRow(topTint, topLabel, topRow)
|
||||
Text("⇣ ⇣ ⇣", modifier = Modifier.align(Alignment.CenterHorizontally), fontSize = 14.sp)
|
||||
TintedRow(bottomTint, bottomLabel, bottomRow)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 10.dp).width(72.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Always three slots, so the column does not shift when there is nothing to hide.
|
||||
if (onHide != null) {
|
||||
HudRoundButton("▾", caption = CatanText.HIDE, diameter = 48.dp, onClick = onHide)
|
||||
} else {
|
||||
Box(Modifier.size(48.dp))
|
||||
}
|
||||
HudRoundButton("✕", caption = CatanText.DENY, enabled = denyEnabled, tint = Color(0xFFC62828), diameter = 52.dp, onClick = onDeny)
|
||||
HudRoundButton("✓", caption = CatanText.ACCEPT, enabled = acceptEnabled, tint = Color(0xFF2E7D32), diameter = 60.dp, onClick = onAccept)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TintedRow(tint: Color, label: String, content: @Composable () -> Unit) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(tint.copy(alpha = 0.18f))
|
||||
.border(1.dp, tint.copy(alpha = 0.5f), shape)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(label, fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
||||
Box(Modifier.horizontalScroll(rememberScrollState())) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Five resource cards in a row. Tapping a card adds one (up to [limit]); the "−" under it
|
||||
* takes one back. A bank row passes [pick] instead, and a tap selects the card.
|
||||
*/
|
||||
@Composable
|
||||
private fun TradeCardRow(
|
||||
counts: Map<Resource, Int>,
|
||||
limit: (Resource) -> Int,
|
||||
onChange: (Map<Resource, Int>) -> Unit,
|
||||
enabledOf: (Resource) -> Boolean = { true },
|
||||
selected: Resource? = null,
|
||||
caption: ((Resource) -> String)? = null,
|
||||
pick: ((Resource) -> Unit)? = null,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Resource.entries.forEach { resource ->
|
||||
val n = counts[resource] ?: 0
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ResourceTile(
|
||||
resource = resource,
|
||||
count = n,
|
||||
selected = selected == resource,
|
||||
enabled = enabledOf(resource) && (pick != null || n > 0 || limit(resource) > 0),
|
||||
onClick = {
|
||||
if (pick != null) {
|
||||
pick(resource)
|
||||
} else if (n < limit(resource)) {
|
||||
onChange(counts + (resource to n + 1))
|
||||
}
|
||||
},
|
||||
)
|
||||
if (caption != null) {
|
||||
Text(caption(resource), fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
} else {
|
||||
// Always laid out so the row never changes height.
|
||||
Text(
|
||||
"−",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.alpha(if (n > 0 && pick == null && limit(resource) >= 0) 1f else 0f)
|
||||
.clickable(enabled = n > 0 && pick == null) {
|
||||
onChange(counts + (resource to n - 1))
|
||||
}
|
||||
.padding(horizontal = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TradeTab(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val shape = RoundedCornerShape(50)
|
||||
Text(
|
||||
label,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** One round button per rival: tap to address the offer to them alone, tap again for everyone. */
|
||||
@Composable
|
||||
private fun RivalButtons(
|
||||
rivals: List<Int>,
|
||||
chosen: Int?,
|
||||
style: CatanStyle,
|
||||
seats: CatanSeats,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
rivals.forEach { rival ->
|
||||
val picked = chosen == rival
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(style.seats[rival])
|
||||
.border(if (picked) 4.dp else 1.dp, if (picked) Color.White else Color(0x55000000), CircleShape)
|
||||
.clickable { onTap(rival) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(seats.label(rival).take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* `…prolibertategames.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", "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 = "ProLibertateGames"
|
||||
|
||||
/** 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,19 +106,36 @@ class CatalogTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every reference game in the catalogue can be played`() {
|
||||
// Every game the app ships is playable. The one exception is Katan,
|
||||
// listed so the slot exists and marked *Coming soon* until its engine
|
||||
// and screen land — `available = false` is what keeps an unfinished
|
||||
// game out of a production build while leaving it visible on a dev one.
|
||||
assertEquals(
|
||||
listOf(GameCatalog.CATAN),
|
||||
GameCatalog.all.filterNot { it.available }.map { it.id },
|
||||
)
|
||||
// It is also the only difference the two channels see in a menu.
|
||||
val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
|
||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
|
||||
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
|
||||
fun `every game in the catalogue can be played`() {
|
||||
// `available` is the release gate. Nothing is held back at the moment,
|
||||
// so a production menu lists every game; a game parked behind the gate
|
||||
// belongs in `devPreview`, and must say so rather than being quietly
|
||||
// unavailable everywhere.
|
||||
GameCatalog.all.filterNot { it.available }.forEach { game ->
|
||||
assertTrue("${game.id} is unavailable but not a dev preview", game.devPreview)
|
||||
}
|
||||
GameMenu.entries.forEach { menu ->
|
||||
val held = GameCatalog.inMenu(menu, includeComingSoon = true) -
|
||||
GameCatalog.inMenu(menu, includeComingSoon = false).toSet()
|
||||
held.forEach { assertTrue("${it.id} hidden from production without a preview flag", it.devPreview) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a preview game opens on a dev build and nowhere else`() {
|
||||
GameCatalog.all.filter { it.devPreview }.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))
|
||||
}
|
||||
// The two games that were once previews now open everywhere.
|
||||
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU).forEach { id ->
|
||||
val game = GameCatalog.byId(id)!!
|
||||
assertTrue("$id should 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
|
||||
@@ -160,6 +177,8 @@ class CatalogTest {
|
||||
GameCatalog.MASTERMIND,
|
||||
GameCatalog.BACKGAMMON,
|
||||
GameCatalog.PIRATES,
|
||||
GameCatalog.CATAN,
|
||||
GameCatalog.MEOWDOKU,
|
||||
),
|
||||
GameCatalog.playable.map { it.id }.toSet(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.prolibertate.games.game.backgammon
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.engine.AiDifficulty
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
|
||||
class BackgammonAiProfileTest {
|
||||
|
||||
private fun playout(ai: BackgammonAi, seed: Long, steps: Int): List<BackgammonMove> {
|
||||
var state = BackgammonRules.initialState(BackgammonOptions(), seed)
|
||||
val chosen = mutableListOf<BackgammonMove>()
|
||||
repeat(steps) {
|
||||
if (BackgammonRules.isFinished(state)) return chosen
|
||||
val seat = BackgammonRules.currentSeat(state) ?: return chosen
|
||||
val legal = BackgammonRules.legalMoves(state, seat)
|
||||
if (legal.isEmpty()) return chosen
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("illegal move $move", move in legal)
|
||||
chosen += move
|
||||
state = BackgammonRules.applyMove(state, seat, move)
|
||||
}
|
||||
return chosen
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyLevelAndStyleReturnsLegalMoves() {
|
||||
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD, AiDifficulty.MIXED)) {
|
||||
for (seed in 0L..5L) {
|
||||
val moves = playout(BackgammonAi(AiProfiles(difficulty, seed)), seed, 14)
|
||||
assertTrue(moves.isNotEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultProfilePlaysAsBefore() {
|
||||
val plain = playout(BackgammonAi(), 3L, 20)
|
||||
val medium = playout(BackgammonAi(AiProfiles(AiDifficulty.MEDIUM, 77L)), 3L, 20)
|
||||
assertEquals(plain, medium)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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.AiDifficulty
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.AiStyle
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** Computer seats at every level and style play legal, forward-moving games in each Katan rule set. */
|
||||
class CatanAiProfilesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/** Plays up to [cap] moves, checking every one; returns the last state. */
|
||||
private fun play(options: CatanOptions, seed: Long, profiles: AiProfiles, cap: Int): CatanState {
|
||||
val ai = CatanAi(profiles)
|
||||
var state = CatanRules.initialState(config(options, seed))
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state) && 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: chose $move outside the legal list in ${state.phase}", move in legal || CatanRules.isLegalMove(state, seat, move))
|
||||
assertTrue(
|
||||
"seed $seed: seat $seat chose the illegal move $move in ${state.phase}",
|
||||
CatanRules.isLegalMove(state, seat, move),
|
||||
)
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** A table seed on which some seat among the first three has [style] at [difficulty]. */
|
||||
private fun tableSeedFor(difficulty: AiDifficulty, style: AiStyle): Long =
|
||||
(1L..2_000L).first { s ->
|
||||
val p = AiProfiles(difficulty, s)
|
||||
(0 until 3).any { p.of(it).style == style }
|
||||
}
|
||||
|
||||
private fun forEveryProfile(rules: Set<CatanExpansion>, cap: Int) {
|
||||
val options = CatanOptions(
|
||||
playerCount = 3,
|
||||
pointsToWin = when {
|
||||
CatanExpansion.CITIES_AND_KNIGHTS in rules -> 13
|
||||
CatanExpansion.SEAFARERS in rules -> 12
|
||||
else -> 10
|
||||
},
|
||||
expansions = rules,
|
||||
)
|
||||
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) {
|
||||
for (style in AiStyle.entries) {
|
||||
val tableSeed = tableSeedFor(difficulty, style)
|
||||
val state = play(options, 40L + style.ordinal, AiProfiles(difficulty, tableSeed), cap)
|
||||
assertTrue(
|
||||
"$rules $difficulty $style: the game never got past setup",
|
||||
state.turnNumber > 3 || CatanRules.isFinished(state),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play the base game`() = forEveryProfile(emptySet(), 1_500)
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play Cities and Knights`() =
|
||||
forEveryProfile(setOf(CatanExpansion.CITIES_AND_KNIGHTS), 1_500)
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(setOf(CatanExpansion.SEAFARERS), 1_500)
|
||||
|
||||
@Test
|
||||
fun `a mixed table of four finishes a base game`() {
|
||||
val state = play(CatanOptions(playerCount = 4), 77L, AiProfiles(AiDifficulty.MIXED, 5L), 12_000)
|
||||
assertEquals(CatanPhase.GAME_OVER, state.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the default profile plays exactly as an explicit medium table does`() {
|
||||
for (rules in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
|
||||
val options = CatanOptions(playerCount = 3, pointsToWin = 10, expansions = rules)
|
||||
val plain = play(options, 9L, AiProfiles.DEFAULT, 600)
|
||||
val medium = play(options, 9L, AiProfiles(AiDifficulty.MEDIUM, 1234L), 600)
|
||||
assertEquals("$rules", plain, medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
class CatanAiTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/** Plays one table of engines out to a result, refusing every illegal move. */
|
||||
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 4_000): CatanState {
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(options, seed))
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("seed $seed ran past $cap moves", moves++ < cap)
|
||||
val seat = CatanRules.currentSeat(state) ?: break
|
||||
val move = ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat))
|
||||
assertTrue(
|
||||
"seed $seed: seat $seat chose the illegal move $move",
|
||||
CatanRules.isLegalMove(state, seat, move),
|
||||
)
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three engines finish a game at ten points`() {
|
||||
listOf(11L, 22L, 33L).forEach { seed ->
|
||||
val state = playOut(CatanOptions(), seed)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
val winner = state.winner
|
||||
assertTrue("seed $seed finished without a winner", winner != null)
|
||||
assertTrue(
|
||||
"seed $seed: the winner fell short of the target",
|
||||
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four engines finish a game at ten points`() {
|
||||
listOf(44L, 55L).forEach { seed ->
|
||||
val state = playOut(CatanOptions(playerCount = 4), seed)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
val winner = state.winner
|
||||
assertTrue("seed $seed finished without a winner", winner != null)
|
||||
assertTrue(
|
||||
"seed $seed: the winner fell short of the target",
|
||||
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A mid-game state on seat 0's turn, one card short of a settlement. */
|
||||
private fun offeringState(): CatanState =
|
||||
CatanRules.initialState(config(CatanOptions(), seed = 7L))
|
||||
.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
turn = 0,
|
||||
turnNumber = 1,
|
||||
// Lumber to spare, brick missing, and a neighbour visibly
|
||||
// holding brick. No roads or buildings, so no build is legal
|
||||
// and no port is open — the offer is the only move forward.
|
||||
resources = listOf(
|
||||
hand(Resource.LUMBER to 4, Resource.WOOL to 1, Resource.GRAIN to 1),
|
||||
hand(Resource.BRICK to 2),
|
||||
hand(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||
|
||||
@Test
|
||||
fun `the AI offers the trade that closes its build`() {
|
||||
val state = offeringState()
|
||||
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
|
||||
assertEquals(
|
||||
OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)),
|
||||
move,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the AI offers at most one trade a turn`() {
|
||||
// The same table, but the offer was already made and refused once.
|
||||
val state = offeringState().copy(
|
||||
log = listOf(
|
||||
"Seat 0 rolls 3 + 2.",
|
||||
"Seat 0 offers 2 lumber for a brick to seat 1.",
|
||||
"Seat 1 declines the offer.",
|
||||
),
|
||||
)
|
||||
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
|
||||
assertTrue("the AI re-offered within one turn: $move", move !is OfferTrade)
|
||||
assertEquals(EndTurn, move)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the AI answers a pending offer with accept or decline`() {
|
||||
val state = CatanRules.initialState(config(CatanOptions(), seed = 7L))
|
||||
.copy(
|
||||
phase = CatanPhase.TRADE_PENDING,
|
||||
turn = 0,
|
||||
turnNumber = 1,
|
||||
pendingTrade = TradeOffer(
|
||||
by = 0, to = 1,
|
||||
give = listOf(Resource.LUMBER, Resource.LUMBER),
|
||||
get = listOf(Resource.BRICK),
|
||||
),
|
||||
resources = listOf(
|
||||
hand(Resource.LUMBER to 3),
|
||||
hand(Resource.BRICK to 2, Resource.WOOL to 1),
|
||||
hand(),
|
||||
),
|
||||
)
|
||||
val legal = CatanRules.legalMoves(state, 1)
|
||||
val move = CatanAi().chooseMove(state, 1, legal)
|
||||
assertTrue("neither accept nor decline: $move", move == AcceptTrade || move == DeclineTrade)
|
||||
assertTrue(move in legal)
|
||||
|
||||
val after = CatanRules.applyMove(state, 1, move)
|
||||
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||
assertNull(after.pendingTrade)
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user