49 Commits
Author SHA1 Message Date
devadmin 2e66567643 Merge pull request 'Fix flaky ScorekeeperSessionTest' (#11) from fix/scorekeeper-test-flake into dev
CI / Build and check (push) Successful in 8m25s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-10-17 14:01:39 -06:00
Claude a8ff9863a6 Fix a race in ScorekeeperSessionTest's admittedGuest helper
CI / Build and check (pull_request) Successful in 8m32s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
A guest counts as connected before the host's sheet has arrived over the socket, so
tests that read the guest's players straight away could see an empty list. The helper
now waits for the guest to hold the host's sheet. 0 failures in 12 runs (was ~1 in 3).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 19:52:44 +00:00
devadmin bd12a7a70b Merge pull request 'Save state for every game between sessions' (#10) from feature/saved-games into dev
CI / Build and check (push) Successful in 8m28s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-17 13:19:05 -06:00
Claude e370aa0ceb Save state for every game between sessions
CI / Build and check (pull_request) Successful in 8m23s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
A game against the computer is kept as it is played (the host's true state plus its
table config, one file per game) and can be resumed from the main menu or the game's
setup screen. Leaving keeps the position; End game discards it; a finished game is
not kept; a networked table is not saved.

MatchController gains restore() and an onHostState listener; MatchSaver writes off
the main thread and flushes on leaving and ON_STOP. Tests cover the store, the saver,
resuming, and a save/resume round trip of every game in the catalogue at every move.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 19:04:47 +00:00
Claude 59a6ffc940 CLAUDE.md: local build, Gitea shipping flow and Katan rule sets
CI / Build and check (push) Successful in 8m6s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
CI / Build and check (pull_request) Successful in 8m13s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 18:31:29 +00:00
devadmin 98506c05b7 Merge pull request 'Katan: Cities & Knights' (#8) from feature/cities-and-knights into dev
CI / Build and check (push) Successful in 8m14s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-10-17 08:57:06 -06:00
Claude f9b793d56e Merge origin/dev into feature/cities-and-knights
CI / Build and check (pull_request) Successful in 8m3s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Brings in open trade offers and counter-offers. Cities & Knights gets the same
flow for goods: OfferGoods may be open to the table, CounterGoods answers an
offer, and AcceptCounter / DismissCounters are shared with the base game.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 14:45:39 +00:00
Claude c8368416c4 Katan: Cities & Knights docs, knight menu for any own knight, tidier player card
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 14:40:49 +00:00
Claude e344d1d817 Katan: Cities & Knights screen, setup chooser and UI coverage tests
The setup screen picks the rule set (base game or Cities & Knights, each with its
own point targets). The board draws knights, walls, metropolis pennants and the
merchant; the screen adds the barbarian strip, event die, commodity hand, improve
and progress dialogs, the knight menu, and dialogs for every chore.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 14:28:19 +00:00
Claude d88083679d Katan: Cities & Knights engine, AI and rules tests
A second rule set chosen at setup (CatanRuleSet): commodities, knights, walls,
barbarians and the event die, city improvements and metropolises, and the
54 progress cards. Everything lives behind CatanState.ck, so the base game's
state and rules are untouched. Multi-seat decisions run through one chore queue.

Also wraps three multi-part log lines so each event is one entry.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
2026-10-17 14:14:25 +00:00
devadmin 2d85df0de0 Merge pull request 'Katan: open trade offers and counter-offers' (#6) from feature/catan-open-trades-counter into dev
CI / Build and check (push) Successful in 7m39s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
CI / Build and check (pull_request) Successful in 7m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-15 14:32:57 -06:00
Claude d7d3c8a3ed Katan: open trade offers and counter-offers
CI / Build and check (pull_request) Successful in 7m28s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
- Offers can go to the whole table (asked in seat order, empty hands skipped) or one seat.
- The asked seat may accept (trade happens at once), decline, or counter with new terms.
- If nobody accepts and there are counters, the offerer takes one or turns them all down (new TRADE_COUNTERS phase).
- AI: fixes an inverted accept test (it weighed what it gives against what it gets backwards), picks the best counter.
- UI: Everyone option in the offer dialog, Counter… in the reply dialog, a counter-choice dialog.
- Tests and docs updated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
2026-10-15 20:24:45 +00:00
devadmin 12fec14034 Merge pull request 'Katan: rolled-hex highlight, compact Build menu, clearer harbours' (#5) from feature/catan-roll-highlight-build-menu-ports into dev
CI / Build and check (push) Successful in 7m31s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-15 12:19:08 -06:00
Claude 5e84119670 Katan: mark build-menu label lambda @Composable
CI / Build and check (pull_request) Successful in 7m29s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
2026-10-15 18:11:16 +00:00
Claude b90058afda Katan: highlight rolled hexes, one-button Build menu on small screens, clearer harbours
CI / Build and check (pull_request) Failing after 2m39s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
- Hexes that paid out for the turn's roll are lit (not on a 7, not the robber's hex, not before the roll).
- Compact layouts fold Road/Settlement/City into one Build button with a menu.
- Harbour markers are drawn with a resource pictogram (? for 3:1), larger, and sit a full hex radius off the coast so the piers show which vertices they serve.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
2026-10-15 17:40:48 +00:00
devadminandClaude Sonnet 5.5 ef69704e00 Merge pull request 'Katan: no roads past an opponent's settlement, piece limits, stable layout' (#4) from fix/catan-road-rule-and-ui-stability into dev
CI / Build and check (push) Successful in 7m35s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
Katan: no roads past an opponent's settlement, piece limits, stable layout

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-15 08:03:25 -06:00
Claude 3b0b307997 Katan: no roads past an opponent's settlement, piece limits, stable layout
CI / Build and check (pull_request) Successful in 7m22s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Rules
- A road may only start from a crossing where the player has a building, or
  where one of their roads ends AND no opponent's settlement or city stands.
  roadConnects ignored opponents' buildings, so a road (the computer's included)
  could be laid straight past a settlement. Also applies to Road Building.
- Enforce the piece supply: 15 roads, 5 settlements, 4 cities. It was
  unlimited.
- Tests: building past a settlement and a city, free roads, own settlement not
  blocking, and running out of each piece.

UI
- The layout rescaled the board whenever it became the player's turn, because
  the action buttons only existed then. Everything outside the board now has a
  fixed height: the action bar always shows every button (disabled, not absent),
  the prompt is pinned to two lines, player cards always draw every line, and
  the dice total is always present. Rule recorded in docs/UI-catan.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-15 13:55:03 +00:00
devadminandClaude Sonnet 5.5 87b00ad84f Merge pull request 'Katan: playable screen, setup options, dev-preview gate' (#3) from feature/catan-ui into dev
CI / Build and check (push) Successful in 19m17s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
Katan: playable screen, setup options, dev-preview gate

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 20:52:24 -06:00
Claude 30dcf026bd Katan: playable screen, setup options, dev-preview catalogue gate
CI / Build and check (pull_request) Successful in 20m42s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Adds the Katan screen on top of the WALA-119 engine, built in separable layers
so the look, wording, layout and interaction can each be changed alone:

- CatanStyle: every colour and proportion (light/dark), via LocalCatanStyle
- CatanText: every word on screen (pure functions of state)
- CatanGeometry: lattice -> pixels and tap hit-testing (pure, unit-tested)
- CatanInteraction: what is tappable and what a tap means, derived only from the
  engine's legal-move list (pure, unit-tested)
- CatanBoardView / CatanPanels / CatanDialogs: stateless composables
- CatanScreen: wiring to MatchController, portrait and landscape layouts
- CatanPreviews: Android Studio previews of real positions
- docs/UI-catan.md: how to adapt each layer, rules of the road, known gaps

Wiring: PlayScreen branch, GameSetupScreen options (3-4 players, game to
5/8/10/12), and a GameDescriptor.devPreview flag so dev builds can open Katan
while production still hides it until two-device play is verified.

Tests: CatanGeometryTest, CatanInteractionTest, CatanTextTest, CatalogTest.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 15:53:15 +00:00
adrianoftyriel 0b74b1b2b1 Merge pull request #40 from adrianoftyriel/cf-first-deploy-fix
Fix first Cloudflare deploy: add first_deploy dispatch input
2026-10-14 09:39:58 -06:00
Claude 4369d15cec Add first_deploy dispatch input so the first Cloudflare deploy can bootstrap
The domain answers 403 (error 1014) until the Pages project exists, which
made the carry-over fetch abort the very deploy that would create it.

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 15:20:47 +00:00
adrianoftyrielandPaperclip 3a930f5423 Katan rules engine + AI (WALA-119)
CI / Build and check (pull_request) Failing after 8s
The island game as a pure-Kotlin state machine under game/catan/, wired to
the upstream GameRules / GameAi contract and fully unit-tested on the JVM.

- game/catan/: CatanModel (state, moves, phases), CatanBoard (19-hex
  geometry, seeded island generation with the 6/8 separation rule, shared
  vertex/edge graph), CatanRules (setup, production, the seven and the
  robber, domestic and maritime trade, building, development cards,
  Longest Road, Largest Army, win detection), CatanAi (a heuristic opponent
  that reads only public information).
- GameRules.isLegalMove: a defaulted structural legality check so a game
  with a combinatorial move space (Catan's trade offers) is gated the same
  way as one with an enumerated move list; MatchController uses it.
- GameCatalog.CATAN: the catalogue entry, listed but not yet available
  (descriptor and screen land with the screen task).
- Tests: 51 across CatanBoardTest, CatanRulesTest and CatanAiTest —
  geometry invariants, generation balance, setup, production, the
  seven/robber, both trade types, development-card timing, Longest Road
  edge cases, Largest Army, win detection, and AI games played to a result.
- docs/RULES-catan.md, in the per-game style, with the rule choices and
  the known gaps called out.

Two real engine bugs found and fixed while getting the suite green:
VERTEX_EDGES counted each shared edge once per adjacent hex, so an
interior vertex looked like it had six edges (which also double-counted
segments in the Longest Road search and duplicated setup moves); and the
applyRoll seven-transition was a discarded expression, so a seven never
left AWAIT_ROLL. Three test fixtures rolled from the PLAYING phase and two
assumed a two-seat rotation; corrected to the phases and seat order the
engine actually uses.

Verified: ./gradlew :app:testDebugUnitTest --offline — 679 tests, 0 failures.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 15:13:36 +00:00
adrianoftyrielandPaperclip 4d7cd6182f Rename the installed Android package to ca.wallacenet.prolibertategames
Build and Release APK / Build APK and publish release (push) Failing after 7s
Follows the Gitea repo rename: the applicationId (and its .dev variant)
now matches the repo, the launcher name and the docs, instead of carrying
the abandoned Pilgrims of Katan identity.

The JVM namespace stays org.prolibertate.games, so no source file moves;
only the installed identity changes.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 14:47:13 +00:00
adrianoftyrielandPaperclip b58b4251dc Rename the Gitea repo references to ProLibertateGames
Build and Release APK / Build APK and publish release (push) Canceled after 0s
The repo was renamed from PilgrimsOfKatan to ProLibertateGames on the
Gitea instance. Update every reference so nothing points at the old name:

- OTA updater: the repo constant and the User-Agent
- publish-release.sh: the default repo, the asset name, the release title
- CI/release workflows: the artifact names and the staged APK name
- settings.gradle.kts: the Gradle project name
- Protocol.kt / LanTransport.kt: the mDNS service label and the Wi-Fi lock tag
- docs (README, CLAUDE.md, docs/CI.md) and the update-policy test fixtures

The installed applicationId is left unchanged in this commit.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 14:21:43 +00:00
adrianoftyrielandPaperclip 5c797c8807 Restore the Pro Libertate Games title and splash; list Katan under Board Games
Build and Release APK / Build APK and publish release (push) Canceled after 0s
The bootstrap rebranded the app to Pilgrims of Katan (WALA-118 review);
the decision is to keep the upstream's own identity instead.

- Title and splash: the upstream originals restored exactly — the Wallace
  tartan splash under the "Pro Libertate Games" uncial wordmark
  (SplashScreen.kt, Tartan.kt), the tartan launcher icon, and the
  Theme.ProLibertateGames window theme. The game screens go back to the
  upstream WallaceGold/WallaceRed accents the tartan is woven from.
- Names and colours: launcher names back to "Pro Libertate Games" /
  "Pro Libertate Games DEV"; the channel colours back to the clan red /
  hunting green setts (resValue tartan_field and friends). The installed
  identity stays ca.wallacenet.pilgrimsofkatan (+ .dev) — that is the
  package, not the brand.
- Catalogue: Katan is listed under Board Games (3-4 players, original
  blurb, no third-party marks) with available = false, so dev builds show
  it as "Coming soon" and a production build hides it until its engine
  and screen land.
- Docs: README/CLAUDE.md/docs/CI.md updated to the restored identity;
  CatalogTest's invariant now names Katan as the one deliberate exception
  rather than asserting nothing is unfinished.

Structural bootstrap untouched: applicationId, OTA repointed at the Gitea
releases, CI workflows, version stamping.

Verified: ./gradlew assembleDebug and testDebugUnitTest green — 628 tests,
0 failures.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 02:15:30 +00:00
adrianoftyrielandPaperclip 05c443a4b5 Publish: verify the release's stored state after attaching the APK
Build and Release APK / Build APK and publish release (push) Canceled after 0s
A live publish on git.wallacenet.ca kept coming back invisible to the updater:
the create accepted "draft": false, and the release was still stored as a
draft once the APK was attached — so the anonymous list the app reads came
back empty even though every publish step reported success.

The script now attaches first, then reads the release back and PATCHes the
state if it did not stick, failing loudly if the correction is refused. A
draft is not a channel a preview build can silently become.

Also lists all 22 catalogue games in the README's table; seven were missing,
which read as "the app has 15 games".

Verified against the live instance: anonymous list shows
v1.4.1001-dev as a published prerelease with a tag-named asset, the
production /releases/latest correctly returns 404 while only a prerelease
exists, and an anonymous download through the URL the app builds matches the
built APK byte for byte.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:27:33 +00:00
adrianoftyrielandPaperclip a827a38ae6 OTA: name release assets after their tag, download from one host
Build and Release APK / Build APK and publish release (push) Canceled after 0s
Two things the first live release check on git.wallacenet.ca showed:

- The publish script uploaded the APK under whatever the build called it
  (app-debug.apk), which is the name the updater only finds by its fallback
  rule. The script now names the asset after its tag, so the primary rule —
  "the asset whose name contains the tag" — is the one that fires.
- The instance answers for two host names and reports asset URLs on the one
  it treats as canonical, so the app would have fetched its release list from
  git.wallacenet.ca and its APK from elsewhere. onReportedHost() now keeps the
  path the server chose and puts the download on the host the app already
  trusts, tested in UpdatePolicyTest.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:13:09 +00:00
adrianoftyrielandPaperclip fad4778ad8 Bootstrap Pilgrims of Katan: rebrand, Gitea CI and OTA
Build and Release APK / Build APK and publish release (push) Canceled after 0s
Import of ProLibertateGames (upstream main, GPL-3.0) rebranded for
Wallacenet and repointed at git.wallacenet.ca.

- Identity: applicationId ca.wallacenet.pilgrimsofkatan (+ .dev for the dev
  channel), launcher names "Pilgrims of Katan" / "Pilgrims of Katan DEV",
  neutral hexagon branding replacing the Wallace tartan. Uncial Antiqua keeps
  its OFL licence, and the GPL-3.0 LICENSE plus upstream attribution stay.
- OTA: Updater.kt now reads this repo's own Gitea releases
  (/api/v1/repos/devadmin/PilgrimsOfKatan/releases...) and BuildConfig.
  OTA_ENABLED is on; Gitea answers the same JSON shape the code already parsed.
- Build numbers: versionCode = CI run number + plgVersionRunOffset (1000),
  with the tag and versionName derived from the same figure. The offset is
  permanent, because Gitea Actions restarts run numbers at 1 and a reset would
  otherwise make Android reject the next build as a downgrade.
- CI: ci.yml publishes a dev prerelease from dev, release.yml a production
  release from main, both through scripts/publish-release.sh (Gitea REST API,
  runnable by hand until an Actions runner is registered).
- Docs: docs/CI.md records the channel, numbering and runner decisions;
  docs/README.md seeds the per-game RULES-* conventions; CLAUDE.md carries the
  agent ground rules.

Verified: ./gradlew assembleDebug and testDebugUnitTest green on JDK 17 /
compileSdk 34, both variants stamped, installing side by side under the new
identity.

Co-Authored-By: Paperclip <[email protected]>
2026-10-13 22:03:27 +00:00
adrianoftyriel d27c2db894 Merge pull request #38 from adrianoftyriel/dev
Release: multi-device scorekeeper sharing and tally scoring
2026-10-12 22:04:30 -06:00
adrianoftyriel e6ce1aebd5 Merge pull request #33 from adrianoftyriel/dev
Release 1.4
2026-10-09 14:13:52 -06:00
75 changed files with 15945 additions and 694 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Assembles the Cloudflare Pages site for plg.wallacenet.ca into ./pages-out.
#
# Usage: stage-pages-site.sh <prod|dev> <tag> <apk-path>
#
# A Pages deploy replaces the whole site, and both channels live in one site,
# so this stages the channel being published and then re-fetches the *other*
# channel's current files from the live site so the deploy does not erase them.
set -euo pipefail
channel="$1"; tag="$2"; apk="$3"
base="${PAGES_BASE_URL:-https://plg.wallacenet.ca}"
out="pages-out"
limit=$((25 * 1024 * 1024)) # Cloudflare Pages: 25 MiB per file
case "$channel" in prod) other=dev ;; dev) other=prod ;; *) echo "bad channel: $channel" >&2; exit 2 ;; esac
rm -rf "$out"
mkdir -p "$out/$channel" "$out/$other"
cp site/index.html site/404.html site/_headers "$out/"
name="$(basename "$apk")"
cp "$apk" "$out/$channel/$name"
cp "$apk" "$out/$channel/ProLibertateGames.apk"
# node rather than jq: the Gitea runner's image has node but not jq.
node -e 'console.log(JSON.stringify({tag: process.argv[1], apkName: process.argv[2], apkUrl: process.argv[3], channel: process.argv[4]}, null, 2))' \
"$tag" "$name" "$base/$channel/$name" "$channel" > "$out/$channel/latest.json"
# PLG_SKIP_OTHER=1 is for the very first deploy, when the site does not exist
# yet and the domain answers 403 (Cloudflare error 1014) instead of 404.
if [ -n "${PLG_SKIP_OTHER:-}" ]; then
echo "PLG_SKIP_OTHER set; not carrying over $other."
rm -rf "$out/$other"
find "$out" -type f | sort
exit 0
fi
# Carry the other channel over. 404 means it was never published; any other
# failure must stop the deploy rather than silently dropping that channel.
code="$(curl -sS -o "$out/$other/latest.json" -w '%{http_code}' "$base/$other/latest.json" || true)"
: "${code:=000}"
# Cloudflare error 1014 (403 + "error code: 1014") means the domain's CNAME
# points at a Pages project that does not exist yet, so there is nothing to
# preserve — the same as a 404, and what a push-triggered first deploy sees.
if [ "$code" = 403 ] && grep -q 'error code: 1014' "$out/$other/latest.json" 2>/dev/null; then
code=404
fi
if [ "$code" = 404 ]; then
echo "No $other channel published yet; skipping."
rm -rf "$out/$other"
elif [ "$code" = 200 ] && ! other_name="$(node -p 'const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if (!j.apkName) process.exit(1); j.apkName' "$out/$other/latest.json" 2>/dev/null)"; then
# A 200 that is not our JSON: Pages without a 404.html answers unknown paths
# with index.html. Nothing is published on that channel.
echo "No $other channel published yet (200 but no latest.json); skipping."
rm -rf "$out/$other"
elif [ "$code" = 200 ]; then
curl -fsS -o "$out/$other/$other_name" "$base/$other/$other_name"
cp "$out/$other/$other_name" "$out/$other/ProLibertateGames.apk"
else
echo "Fetching $other/latest.json returned HTTP $code; refusing to deploy." >&2
exit 1
fi
find "$out" -name '*.apk' -size +"${limit}c" -print | grep . >&2 && {
echo "APK exceeds Cloudflare Pages' 25 MiB per-file limit." >&2; exit 1; } || true
find "$out" -type f | sort
+68 -14
View File
@@ -16,6 +16,11 @@ on:
pull_request: pull_request:
branches: [ main, dev ] branches: [ main, dev ]
workflow_dispatch: workflow_dispatch:
inputs:
first_deploy:
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
type: boolean
default: false
# Needed to publish the dev-channel prerelease below. # Needed to publish the dev-channel prerelease below.
permissions: permissions:
@@ -30,6 +35,8 @@ jobs:
build: build:
name: Build and check name: Build and check
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.stage.outputs.version }}
env: env:
# Stamp the APK as a dev-channel build exactly when it is a build of the # Stamp the APK as a dev-channel build exactly when it is a build of the
# dev branch, which is exactly when it can be published below. Pull # dev branch, which is exactly when it can be published below. Pull
@@ -73,6 +80,19 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
- name: Upload JVM crash logs
# A JVM crash writes hs_err_pid*.log into the workspace, which dies
# with the job container; keep it so the failing frame can be read.
if: failure()
uses: actions/upload-artifact@v4
with:
name: ProLibertateGames-jvm-crash
path: |
hs_err_pid*.log
replay_pid*.log
if-no-files-found: ignore
retention-days: 14
- name: Upload lint and test reports - name: Upload lint and test reports
# Reports are most useful precisely when a step above failed. # Reports are most useful precisely when a step above failed.
if: always() if: always()
@@ -101,27 +121,61 @@ jobs:
# it cannot name different versions. # it cannot name different versions.
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
test -n "$series" test -n "$series"
version="v${series}.${{ github.run_number }}-dev" offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$offset"
# 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" src="app/build/outputs/apk/debug/app-debug.apk"
dest="ProLibertateGames-${version}.apk" dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest" cp "$src" "$dest"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT"
- name: Publish dev channel prerelease - name: Upload staged dev APK for the Pages deploy
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev'
uses: actions/upload-artifact@v4
with:
name: plg-dev-staged
path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error
retention-days: 1
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
# the only release channel: the in-app updater reads latest.json from there.
#
# Both workflows deploy into one Pages site, and a deploy replaces the whole
# site, so they share a concurrency group and never run at the same time.
deploy-pages:
name: Deploy to Cloudflare Pages
needs: build
if: github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
concurrency:
group: plg-pages-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download staged APK
uses: actions/download-artifact@v4
with:
name: plg-dev-staged
path: staged
- name: Assemble site
env: env:
GH_TOKEN: ${{ github.token }} 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: | run: |
set -euo pipefail set -euo pipefail
gh release create "${{ steps.stage.outputs.version }}" \ # Creates the project on first run; fails harmlessly if it exists.
"${{ steps.stage.outputs.apk }}" \ npx --yes wrangler@3 pages project create plg --production-branch main || true
--repo "${{ github.repository }}" \ # --branch main makes this the production deployment, which is what
--target "${{ github.sha }}" \ # plg.wallacenet.ca serves, whichever branch the workflow ran on.
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \ npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
--prerelease \
--notes "Automated **dev channel** build from \`${{ github.sha }}\` on \`dev\`.
Preview build: newer than production, and less tested. Switch to the
Dev update channel in Settings to be offered these. Marked as a
prerelease so production-channel installs never see it."
+48 -17
View File
@@ -6,6 +6,11 @@ on:
push: push:
branches: [ main ] branches: [ main ]
workflow_dispatch: workflow_dispatch:
inputs:
first_deploy:
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
type: boolean
default: false
# Allow the built-in token to create releases and upload the APK. # Allow the built-in token to create releases and upload the APK.
permissions: permissions:
@@ -24,6 +29,8 @@ jobs:
# anything else is a no-op rather than shipping unreviewed code to everyone. # anything else is a no-op rather than shipping unreviewed code to everyone.
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.stage.outputs.version }}
env: env:
# Stamps the APK as a production-channel build, versioned with the run # Stamps the APK as a production-channel build, versioned with the run
# number that matches the tag published below. # number that matches the tag published below.
@@ -54,7 +61,10 @@ jobs:
# it cannot name different versions. # it cannot name different versions.
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
test -n "$series" test -n "$series"
version="v${series}.${{ github.run_number }}" offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$offset"
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
version="v${series}.$(( ${{ github.run_number }} + offset ))"
src="app/build/outputs/apk/debug/app-debug.apk" src="app/build/outputs/apk/debug/app-debug.apk"
dest="ProLibertateGames-${version}.apk" dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest" cp "$src" "$dest"
@@ -69,25 +79,46 @@ jobs:
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ProLibertateGames-apk name: plg-prod-staged
path: ${{ steps.stage.outputs.apk }} path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error if-no-files-found: error
- name: Create GitHub release # Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
# the only release channel: the in-app updater reads latest.json from there.
#
# Both workflows deploy into one Pages site, and a deploy replaces the whole
# site, so they share a concurrency group and never run at the same time.
deploy-pages:
name: Deploy to Cloudflare Pages
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: plg-pages-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download staged APK
uses: actions/download-artifact@v4
with:
name: plg-prod-staged
path: staged
- name: Assemble site
env: env:
GH_TOKEN: ${{ github.token }} 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: | run: |
set -euo pipefail set -euo pipefail
gh release create "${{ steps.stage.outputs.version }}" \ # Creates the project on first run; fails harmlessly if it exists.
"${{ steps.stage.outputs.apk }}" \ npx --yes wrangler@3 pages project create plg --production-branch main || true
"ProLibertateGames.apk" \ # --branch main makes this the production deployment, which is what
--repo "${{ github.repository }}" \ # plg.wallacenet.ca serves, whichever branch the workflow ran on.
--target "${{ github.sha }}" \ npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \
--notes "Automated release built from \`${{ github.sha }}\` on push to \`main\`.
Download **${{ steps.stage.outputs.apk }}** below and install it on an Android device (you may need to allow *Install unknown apps*). This is a debug-signed build.
Both files below are the same build. \`ProLibertateGames.apk\` is a copy under a fixed name, so this link always fetches the newest release and can be bookmarked or shared:
https://github.com/${{ github.repository }}/releases/latest/download/ProLibertateGames.apk"
+42 -53
View File
@@ -1,72 +1,61 @@
# Working on this repo with Claude # Working on this repo with Claude
## Nothing a session does triggers CI. Dispatch it explicitly. 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.
GitHub creates **no workflow run** for anything this sandbox does — pushing a ## Build, test and lint locally
branch, opening a pull request, or merging one. All of it goes through the same
integration, and GitHub suppresses event-triggered runs from it. The pull
request will say it was opened by the repo owner; that is only how the UI
attributes it, and it does not change the suppression.
This was measured, not assumed: 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.
| Action | Result | ```
| --- | --- | export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
| Human pushes a Claude-authored commit (`d165e5ef`, `469c900e`) | CI runs | export ANDROID_HOME=$HOME/android-sdk # platform 34, build-tools 34.0.0
| Session pushes `dev` / `main` (`43394b8`, `742add4`) | no run | ./gradlew testDebugUnitTest lintDebug assembleDebug
| Session opens a PR against `dev` (#1) | no run | ```
| Session calls `workflow_dispatch` | **runs** |
`workflow_dispatch` works because it creates a run directly instead of relying The Katan tests run in seconds; a clean build takes a few minutes. Run the full
on an event. It is the only mechanism available from a session, so every build suite, lint and `assembleDebug` before pushing: they are what CI runs. There is
has to be asked for by hand. no KVM, so no emulator; the engine and the UI logic are covered by unit tests
(see `docs/UI-catan.md`).
## Verify before merging, without publishing ## Shipping: branch, PR, CI, merge
`ci.yml` builds, tests and lints on any ref, but its staging and publishing 1. Branch from `dev`; before pushing, merge `origin/dev` in (it moves between
steps are gated on `github.ref == 'refs/heads/dev'`. Dispatching it on a sessions).
**feature branch** therefore gives the full check — build, unit tests, Android 2. Plain `git push` has no credentials. Use the Gitea token as a one-off header:
lint — and publishes nothing. `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`
So the working loop is: (same token, `Authorization: token ...`). CI (`ci.yml`) runs on the PR: build,
unit tests and Android lint; it publishes nothing. Poll
1. Commit and push the feature branch. `/actions/runs` until it completes (about 7 minutes).
2. Dispatch `ci.yml` on that branch. Wait for green. This is the real review 4. Merge with `POST .../pulls/N/merge {"Do":"merge"}`. The push to `dev` runs
gate, and it is the only verification that exists (see below). `ci.yml` again and publishes the build (about 9 minutes).
3. Open the PR through the API and merge it there, so the change is reviewable
and the merge commit is honest about what happened. Neither step builds
anything.
4. Dispatch the target branch's workflow to actually build and publish:
`ci.yml` on `dev`, `release.yml` on `main`.
Skipping step 4 leaves a branch whose head was never built and no release.
## Merging is a shipping action ## Merging is a shipping action
Both long-lived branches publish, so a merge is a release, not just an Both long-lived branches publish, so a merge is a release, not just an
integration: integration:
- **`dev`** → `ci.yml` publishes a `v<series>.N-dev` **prerelease**, offered by the - **`dev`** publishes a `v<series>.N-dev` build to
in-app updater to anyone on the Dev channel. `https://plg.wallacenet.ca/dev/latest.json`, offered by the in-app updater to
- **`main`** → `release.yml` publishes a **production** release, which the anyone on the Dev channel.
updater installs for everyone on the Production 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.
Confirm before shipping to `main`. Note that `release.yml` runs `assembleDebug` ## Katan
only — no tests, no lint — so a `main` release is only as verified as the `dev`
build it replays.
## Gradle does not run in the sandbox The island game is catalogue id `catan`, shown as **Katan**. It has two rule
sets chosen at setup: the base game (`docs/RULES-catan.md`) and Cities & Knights
`./gradlew` cannot resolve the Android plugin here: the proxy blocks (`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`dl.google.com` with a 403. No local build, no local test run, no local lint. A `CatanState.ck`, which is null in the base game). The screen is layered; see
dispatched CI run is the only way to find out whether a change compiles. `docs/UI-catan.md` before changing it.
## A permanent fix, if you want one
The suppression is a property of the sandbox's credential, so it cannot be
fixed from inside a session. Giving the environment a personal access token to
push with would make pushes and PRs behave normally for everyone. Until then,
the dispatch step above is the workaround.
## Bash output is filtered before it reaches the model ## Bash output is filtered before it reaches the model
+37 -2
View File
@@ -198,9 +198,39 @@ production package instead.
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater 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. 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 ### Installing without the app
The newest production build is always at this link, which never changes: 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** **https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk**
@@ -342,6 +372,10 @@ game:
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**: - [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
the game is out of print and the rulebook could not be obtained, so the the game is out of print and the rulebook could not be obtained, so the
document says which parts are attested and which were derived document says which parts are attested and which were derived
- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the
rules engine, 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 ## Layout
@@ -364,10 +398,11 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
mastermind/ Mastermind codes, scoring, and a code breaker mastermind/ Mastermind codes, scoring, and a code breaker
pirates/ Pirates and Bulgars — the cross board, the hunt, search pirates/ Pirates and Bulgars — the cross board, the hunt, search
tayu/ Ta Yü tile geometry, river rules, scoring, AI tayu/ Ta Yü tile geometry, river rules, scoring, AI
catan/ Katan island geometry, production, trade, dev cards, Cities & Knights, heuristic AI
net/ wire protocol, LAN transport, lobby, match driver net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
update/ GitHub Releases OTA updater, and the channel rule — pure, tested update/ Cloudflare Pages OTA updater, and the channel rule — pure, tested
ui/ Compose screens ui/ Compose screens
``` ```
+9 -3
View File
@@ -16,6 +16,12 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0 val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
// See plgVersionRunOffset in gradle.properties: the stamped build number is the
// 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
val buildNumber = runNumber + runOffset
// The version series, from gradle.properties. Both workflows read the same // The version series, from gradle.properties. Both workflows read the same
// property when they name a release tag, so the tag and the APK inside it // property when they name a release tag, so the tag and the APK inside it
// cannot disagree about which series they belong to. // cannot disagree about which series they belong to.
@@ -51,10 +57,10 @@ android {
// versionCode stays the run number and nothing else, so it keeps // versionCode stays the run number and nothing else, so it keeps
// climbing across a change of series: bumping 1.0 to 1.1 must not make // climbing across a change of series: bumping 1.0 to 1.1 must not make
// an update look like a downgrade to Android. // an update look like a downgrade to Android.
versionCode = if (stampVersion) runNumber else 1 versionCode = if (stampVersion) buildNumber else 1
versionName = when { versionName = when {
stampVersion && buildChannel == "production" -> "$versionSeries.$runNumber" stampVersion && buildChannel == "production" -> "$versionSeries.$buildNumber"
stampVersion -> "$versionSeries.$runNumber-dev" stampVersion -> "$versionSeries.$buildNumber-dev"
isDevBuild -> "$versionSeries.0-dev" isDevBuild -> "$versionSeries.0-dev"
else -> "$versionSeries.0" else -> "$versionSeries.0"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- OTA updates from GitHub Releases, plus LAN play over TCP sockets. --> <!-- 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.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Network Service Discovery needs multicast to see hosts on the LAN. --> <!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
@@ -14,7 +14,9 @@ import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme import org.prolibertate.games.ui.theme.ProLibertateTheme
import org.prolibertate.games.update.Updater import org.prolibertate.games.update.Updater
import java.io.File
import java.util.UUID import java.util.UUID
import org.prolibertate.games.game.engine.SavedGameStore
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -40,6 +42,7 @@ class MainActivity : ComponentActivity() {
deviceId = peerId, deviceId = peerId,
), ),
peerId = peerId, peerId = peerId,
saves = SavedGameStore(File(filesDir, "saved-games")),
) )
setContent { setContent {
@@ -42,7 +42,20 @@ data class GameDescriptor(
val teamBased: Boolean, val teamBased: Boolean,
val blurb: String, val blurb: String,
val available: Boolean, 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. * The one menu this game is listed under.
* *
@@ -94,6 +107,13 @@ object GameCatalog {
const val MORRIS = "morris" const val MORRIS = "morris"
const val PIRATES = "pirates" const val PIRATES = "pirates"
/**
* The island game the collection is named for. Listed under Board Games
* and marked *Coming soon*; the engine and its screen land with the game
* task, and `available` flips on once two-device play is verified.
*/
const val CATAN = "catan"
val all: List<GameDescriptor> = listOf( val all: List<GameDescriptor> = listOf(
GameDescriptor( GameDescriptor(
id = EUCHRE, id = EUCHRE,
@@ -333,6 +353,21 @@ object GameCatalog {
"because your score multiplies them.", "because your score multiplies them.",
available = true, available = true,
), ),
GameDescriptor(
id = CATAN,
title = "Katan",
menus = setOf(GameMenu.BOARD),
minPlayers = 3,
maxPlayers = 4,
teamBased = false,
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
// Engine and screen exist but have not been verified on two real
// devices yet. Dev builds can open it as a *Preview*; a production
// build does not list it. Flip `available` on, and `devPreview`
// off, once two-device play has been verified.
available = false,
devPreview = true,
),
) )
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
@@ -0,0 +1,340 @@
package org.prolibertate.games.game.catan
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Catan opponent.
*
* It plays a legal, mildly strategic game: it settles on the best-producing
* intersections it can reach, raises cities, chases Largest Army with its
* knights, spends its progress cards when they complete something, trades
* its surplus to the bank or the table to close a one-card gap, and robs
* whoever is winning.
*
* Honesty is a design constraint, not an accident: this runs on the host,
* where the authoritative state is fully visible. It reads only public
* information — terrain, numbers, buildings, roads, resource counts,
* dev-card counts, knights played — plus its own hand, and never looks
* into the deck, another seat's development cards, or the seed beyond the
* roll the rules derive from it.
*/
class CatanAi : GameAi<CatanState, CatanMove> {
/** Plays the Cities & Knights tables; this class keeps the base game. */
private val ck = CatanCkAi(this)
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
"No legal move for seat $seat"
}
if (state.isCk) return ck.choose(state, seat, legal)
return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
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>): SetupPlace {
require(places.isNotEmpty()) { "No legal settlement placement" }
return places.maxByOrNull { place ->
spotValue(state, place.vertex) +
0.25 * placeDirection(state, place)
} ?: places.first()
}
/**
* Where the setup road points: the best settlement spot two edges out
* through the far end of the road. The vertex next to the new
* settlement can never hold one (the distance rule), so what a road is
* worth is what it reaches past it.
*/
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
val far = CatanBoard.otherEnd(place.edge, place.vertex)
return CatanBoard.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
.maxOfOrNull { spotValue(state, it) }
?: 0.0
}
// -----------------------------------------------------------------------
// Dice, discards and the robber
// -----------------------------------------------------------------------
private fun chooseDiscard(state: CatanState, seat: Int, discards: List<Discard>): Discard {
require(discards.isNotEmpty()) { "No legal discard" }
// Throw away what it wants least; grain and ore are the city, wool
// is mostly a dev-card tax.
return discards.minByOrNull { discard ->
discard.cards.sumOf { want(it) }
} ?: discards.first()
}
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val hex = CatanBoard.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
if (building.owner == seat) continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
score += worth * CatanBoard.numberOdds(state.numbers[hex]) / 36.0
}
if (move.victim != null) {
score += 0.15 * state.resourceCount(move.victim) +
0.3 * points[move.victim]
}
score
} ?: moves.first()
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
// A city doubles the best production the seat already owns.
legal.filterIsInstance<BuildCity>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
legal.filterIsInstance<BuildSettlement>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
// A knight is worth playing whenever one is playable: it blocks an
// opponent's best hex and marches toward Largest Army.
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
yearOfPlenty(state, legal)?.let { return it }
monopoly(state, legal)?.let { return it }
// Road Building is worth two free segments whenever there is
// somewhere to put them.
if (legal.filterIsInstance<PlayRoadBuilding>().isNotEmpty() &&
legal.filterIsInstance<BuildRoad>().size >= 2
) {
return legal.filterIsInstance<PlayRoadBuilding>().first()
}
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
// A paid road only when it reaches toward a spot worth settling.
legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.1 }
.maxByOrNull { roadValue(state, it) }
?.let { return it }
// A bank trade only when it completes a build this turn; trading
// four for one on spec is how hands evaporate.
legal.filterIsInstance<MaritimeTrade>()
.filter { completesBuild(state, seat, it) }
.maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 }
?.let { return it }
offer(state, seat)?.let { return it }
return legal.lastOrNull { it is EndTurn } ?: legal.first()
}
/** Year of Plenty: two of whatever the nearest build is shortest of. */
private fun yearOfPlenty(state: CatanState, legal: List<CatanMove>): PlayYearOfPlenty? {
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
if (takes.isEmpty()) return null
val shortage = shortage(state) ?: return null
return takes.maxByOrNull { take ->
take.take.count { it == shortage } * 2 + take.take.size
}
}
/** Monopoly: declare the resource everyone else is hoarding, of the ones wanted. */
private fun monopoly(state: CatanState, legal: List<CatanMove>): PlayMonopoly? {
val plays = legal.filterIsInstance<PlayMonopoly>()
if (plays.isEmpty()) return null
val best = plays.maxByOrNull {
19 - CatanRules.bankSupply(state, it.resource) + want(it.resource)
} ?: return null
// Only worth the card when the table is actually holding a pile.
return if (19 - CatanRules.bankSupply(state, best.resource) >= 10) best else null
}
/** What the seat is shortest of for its next city, else its next settlement. */
private fun shortage(state: CatanState): Resource? {
val seat = state.turn
val targets = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
for (cost in targets) {
val missing = cost.entries.filter { (res, need) ->
(state.resources[seat][res] ?: 0) < need
}
if (missing.isNotEmpty()) {
return missing.maxByOrNull { (res, need) -> need - (state.resources[seat][res] ?: 0) }?.key
}
}
return null
}
private fun completesBuild(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
val hand = state.resources[seat].toMutableMap()
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
hand[move.give] = (hand[move.give] ?: 0) - ratio
hand[move.get] = (hand[move.get] ?: 0) + 1
return covers(hand, CatanRules.CITY_COST) || covers(hand, CatanRules.SETTLEMENT_COST)
}
private fun covers(hand: Map<Resource, Int>, cost: Map<Resource, Int>): Boolean =
cost.all { (res, need) -> (hand[res] ?: 0) >= need }
/**
* A domestic offer for exactly the card that closes a build, paid for
* with two of the surplus — offered to whoever visibly holds the most
* of what is missing. Constructed rather than enumerated (see
* [OfferTrade]), so it is checked against the rules before it is spoken.
*
* At most one offer a turn: a declined offer changes nothing, so
* re-offering it would only bounce between the same two seats forever.
*/
private fun offer(state: CatanState, seat: Int): OfferTrade? {
if (offeredThisTurn(state, seat)) return null
val seatHand = state.resources[seat]
val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
for (cost in builds) {
val missing = cost.entries.filter { (res, need) -> (seatHand[res] ?: 0) < need }
if (missing.size != 1) continue
val need = missing.first().key
val surplus = Resource.entries
.filter { it != need }
.filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) }
.sortedByDescending { seatHand[it] ?: 0 }
if (surplus.isEmpty()) continue
val target = (0 until state.playerCount)
.filter { it != seat }
.filter { (state.resources[it][need] ?: 0) > 0 }
.maxByOrNull { state.resources[it][need] ?: 0 }
?: continue
val give = listOf(surplus.first(), surplus.first())
val trade = OfferTrade(to = target, give = give, get = listOf(need))
if (CatanRules.isLegalMove(state, seat, trade)) return trade
}
return null
}
/**
* Whether [seat] has already put an offer on the table this turn, read
* from the public log: the lines after this seat's last roll are this
* turn's, and an offer can only be spoken on the offerer's own turn.
*/
private fun offeredThisTurn(state: CatanState, seat: Int): Boolean {
val sinceRoll = state.log
.drop(state.log.indexOfLast { it.startsWith("Seat $seat rolls") } + 1)
return sinceRoll.any { it.startsWith("Seat $seat offers") }
}
// -----------------------------------------------------------------------
// Free roads and trade responses
// -----------------------------------------------------------------------
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.05 }
.maxByOrNull { roadValue(state, it) }
return roads ?: legal.firstOrNull { it is EndFreeRoads }
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
?: legal.first()
}
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull()
val offer = state.pendingTrade
if (accept == null || offer == null) return legal.first { it is DeclineTrade }
// Accept when what arrives (the offerer's give) is worth more than what
// leaves (the offerer's get): a mild table, not a shark.
val incoming = offer.give.sumOf { want(it) }
val outgoing = offer.get.sumOf { want(it) }
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
}
/**
* Picks the counter-offer that gains the most by the same valuation, or
* turns them all down. The AI never counters itself: it only answers.
*/
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove {
val offer = state.pendingTrade ?: return legal.first()
val best = legal.filterIsInstance<AcceptCounter>()
.map { choice -> choice to offer.counters.first { it.by == choice.seat } }
.maxByOrNull { (_, c) -> c.give.sumOf { want(it) } - c.get.sumOf { want(it) } }
if (best != null) {
val (choice, c) = best
if (c.give.sumOf { want(it) } > c.get.sumOf { want(it) }) return choice
}
return legal.first { it is DismissCounters }
}
// -----------------------------------------------------------------------
// Valuations
// -----------------------------------------------------------------------
/** What a seat would think of building on [vertex]: production and ports. */
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 CatanBoard.VERTEX_HEXES[vertex]) {
val resource = state.terrains[h].produces ?: continue
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
kinds += resource
}
value += 0.12 * kinds.size // variety keeps a build plan alive
for (e in CatanBoard.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) 0.1 else 0.2
}
return value
}
/**
* What a road on this edge leads to: the best settlement spot it brings
* within reach, discounted for being one step further out.
*/
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
var best = 0.0
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
for (endpoint in listOf(from, to)) {
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
best = maxOf(best, spotValue(state, near) * 0.8)
}
}
return best
}
/** How much the AI wants to hold one card of [resource]. */
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
}
}
@@ -0,0 +1,322 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.Serializable
import kotlin.math.atan2
import kotlin.random.Random
/**
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
* shared edges, the coast and the nine harbour positions.
*
* Everything is derived once, deterministically, from axial coordinates —
* there is no randomness and no state here; the layout of a particular
* game (which terrain and number sits on which hex) is [generate], and the
* live state lives in [CatanState].
*
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
* position is kept on an exact integer lattice so vertices shared by
* neighbouring hexes are recognised by position instead of by floating
* point: the centre of hex (q, r) is at lattice point (2q + r, 3r), and its
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
* in half-units of the hex grid; rendering scales and flips them (lattice
* +Y is toward the top of the board).
*/
object CatanBoard {
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
val HEXES: List<HexCoord> = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
}
val HEX_COUNT: Int = HEXES.size
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// -----------------------------------------------------------------------
// Vertices and edges
// -----------------------------------------------------------------------
/** Lattice position of each vertex, by vertex id. */
val VERTEX_POS: List<Pair<Int, Int>>
val VERTEX_COUNT: Int
/** Vertex ids of hex h's six corners, corner 0 first (see [CORNER_OFFSETS]). */
val HEX_CORNERS: List<List<Int>>
/** Both endpoints of each edge, as vertex ids, low id first. */
val EDGE_VERTICES: List<Pair<Int, Int>>
val EDGE_COUNT: Int
/** Edges touching each vertex. */
val VERTEX_EDGES: List<List<Int>>
/** Vertices one edge away from each vertex: the distance rule's horizon. */
val VERTEX_NEIGHBORS: List<List<Int>>
/** Hexes touching each vertex. */
val VERTEX_HEXES: List<List<Int>>
/** Hexes touching each edge: two for interior edges, one on the coast. */
val EDGE_HEXES: List<List<Int>>
/** Edges adjacent to each edge (sharing a vertex). */
val EDGE_NEIGHBORS: List<List<Int>>
/** Hexes adjacent to each hex, in [NEIGHBOR_STEPS] order. */
val HEX_NEIGHBORS: List<List<Int>>
/** The coastal edges (those with only one hex) in ring order. */
val COASTAL: List<Int>
/** The nine fixed harbour positions, spread around the coast. */
val PORT_POSITIONS: List<Int>
init {
// Vertices: assign ids in first-seen order over hexes then corners.
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
val vertexPos = mutableListOf<Pair<Int, Int>>()
val hexCorners = HEXES.map { hex ->
val cx = 2 * hex.q + hex.r
val cy = 3 * hex.r
CORNER_OFFSETS.map { (dx, dy) ->
val pos = (cx + dx) to (cy + dy)
vertexIds.getOrPut(pos) {
vertexPos += pos
vertexPos.size - 1
}
}
}
VERTEX_POS = vertexPos
VERTEX_COUNT = vertexPos.size
HEX_CORNERS = hexCorners
// Edges between a hex's consecutive corners, deduplicated by the
// pair of vertex ids.
val edgeIds = mutableMapOf<Pair<Int, Int>, Int>()
val edgeVertices = mutableListOf<Pair<Int, Int>>()
hexCorners.forEach { corners ->
for (k in 0..5) {
val a = corners[k]
val b = corners[(k + 1) % 6]
val key = minOf(a, b) to maxOf(a, b)
edgeIds.getOrPut(key) {
edgeVertices += key
edgeVertices.size - 1
}
}
}
EDGE_VERTICES = edgeVertices
EDGE_COUNT = edgeVertices.size
val vertexEdges = List(VERTEX_COUNT) { mutableListOf<Int>() }
val vertexHexes = List(VERTEX_COUNT) { mutableSetOf<Int>() }
val edgeHexes = List(EDGE_COUNT) { mutableSetOf<Int>() }
hexCorners.forEachIndexed { h, corners ->
corners.forEach { v -> vertexHexes[v] += h }
for (k in 0..5) {
val a = corners[k]
val b = corners[(k + 1) % 6]
val key = minOf(a, b) to maxOf(a, b)
val e = edgeIds.getValue(key)
edgeHexes[e] += h
vertexEdges[a] += e
vertexEdges[b] += e
}
}
// Distinct: an interior edge is walked once from each of the two hexes
// it separates, so without this every shared edge would be counted
// twice and an interior vertex would appear to have six edges.
VERTEX_EDGES = vertexEdges.map { it.distinct() }
VERTEX_HEXES = vertexHexes.map { it.sorted() }
EDGE_HEXES = edgeHexes.map { it.sorted() }
VERTEX_NEIGHBORS = List(VERTEX_COUNT) { v ->
VERTEX_EDGES[v].map { otherEnd(it, v) }.distinct().sorted()
}
EDGE_NEIGHBORS = List(EDGE_COUNT) { e ->
val (a, b) = EDGE_VERTICES[e]
(VERTEX_EDGES[a] + VERTEX_EDGES[b]).distinct().filter { it != e }.sorted()
}
HEX_NEIGHBORS = HEXES.map { hex ->
NEIGHBOR_STEPS.mapNotNull { (dq, dr) ->
hexIndex[HexCoord(hex.q + dq, hex.r + dr)]
}
}
// The coast in walking order: sort the coastal edges by the angle of
// their midpoint around the board centre, which is the lattice
// origin. The board is a hexagon, so this yields one closed ring.
COASTAL = EDGE_HEXES.withIndex()
.filter { it.value.size == 1 }
.map { (e, _) -> e }
.sortedBy { e -> angleOf(midpoint(e)) }
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
PORT_POSITIONS = buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
}
/** The other endpoint of [edge], given one [vertex] on it. */
fun otherEnd(edge: Int, vertex: Int): Int {
val (a, b) = EDGE_VERTICES[edge]
return if (vertex == a) b else a
}
/** Lattice position of hex h's centre. */
fun hexCenter(h: Int): Pair<Int, Int> {
val hex = HEXES[h]
return (2 * hex.q + hex.r) to (3 * hex.r)
}
/** Whether two hexes share an edge. */
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
/** Hex-grid distance between two axial positions. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
val dq = q1 - q2
val dr = r1 - r2
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
}
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
private fun midpoint(edge: Int): Pair<Double, Double> {
val (a, b) = EDGE_VERTICES[edge]
val pa = VERTEX_POS[a]
val pb = VERTEX_POS[b]
return ((pa.first + pb.first) / 2.0) to ((pa.second + pb.second) / 2.0)
}
private fun angleOf(point: Pair<Double, Double>): Double =
atan2(point.second, point.first)
// -----------------------------------------------------------------------
// Island generation
// -----------------------------------------------------------------------
/**
* The island layout for one game: terrain and number per hex, the robber
* on the desert, and the nine harbours.
*/
@Serializable
data class Generated(
val terrains: List<Terrain>,
/** 0 where there is no token (the desert). */
val numbers: List<Int>,
val robber: Int,
val ports: List<Port>,
)
/** The terrain tile mix: four forests, four pastures, four fields, three hills, three mountains, one desert. */
private val TERRAIN_TILES: List<Terrain> = listOf(
Terrain.FOREST, Terrain.FOREST, Terrain.FOREST, Terrain.FOREST,
Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE,
Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS,
Terrain.HILLS, Terrain.HILLS, Terrain.HILLS,
Terrain.MOUNTAINS, Terrain.MOUNTAINS, Terrain.MOUNTAINS,
Terrain.DESERT,
)
/** The 18 number tokens, the two hardest numbers withheld: 7 never appears. */
private val RED_TOKENS = listOf(6, 6, 8, 8)
private val OTHER_TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
/** Shuffle attempts before falling back to the corner placement for 6s and 8s. */
private const val RED_ATTEMPTS = 60
/**
* Generates the island from [seed].
*
* Terrain is shuffled over the hexes, exactly as shuffling the land
* tiles face down does. Number tokens are shuffled over the land hexes
* with one repair rule: no two of the red tokens (6 and 8) may sit on
* adjacent hexes, the balance convention the published game asks for.
* The robber starts on the desert. Harbour *positions* are fixed (the
* physical frame is not shuffled); the nine harbour *types* — five
* 2:1 resource ports and four 3:1 ports — are shuffled onto them.
*
* The 6/8 separation is guaranteed, not merely probable: if no shuffled
* placement separates them (about one shuffle in five does), the red
* tokens go to four of the six board-corner hexes, which are pairwise
* non-adjacent, and at most one corner can be desert.
*/
fun generate(seed: Long): Generated {
val random = Random(seed)
val terrains = TERRAIN_TILES.shuffled(random)
val desert = terrains.indexOf(Terrain.DESERT)
val land = terrains.indices.filter { it != desert }
var redHexes: List<Int>? = null
var attempt = 0
while (redHexes == null && attempt < RED_ATTEMPTS) {
val candidate = land.shuffled(random).take(4)
val separated = candidate.withIndex().all { (i, a) ->
candidate.drop(i + 1).none { hexesAdjacent(a, it) }
}
if (separated) redHexes = candidate
attempt++
}
val reds = redHexes ?: CORNER_HEXES
.mapNotNull { hexIndex[it] }
.filter { it != desert }
.shuffled(random)
.take(4)
val numbers = IntArray(HEX_COUNT)
RED_TOKENS.forEachIndexed { i, token -> numbers[reds[i]] = token }
val rest = land.filter { it !in reds }.shuffled(random)
OTHER_TOKENS.forEachIndexed { i, token -> numbers[rest[i]] = token }
val portTypes = (
listOf(
Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE,
) + List(4) { null }
).shuffled(random)
val ports = PORT_POSITIONS.mapIndexed { i, edge ->
val resource = portTypes[i]
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
}
return Generated(terrains, numbers.toList(), desert, ports)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,345 @@
package org.prolibertate.games.game.catan
/**
* The computer opponent for Cities & Knights, reached through [CatanAi].
*
* It plays the same cautious game as the base AI — settle well, raise cities —
* and adds the three things this rule set asks of a player: keep enough active
* knights standing when the barbarians land, spend commodities on improvements
* as soon as it can, and answer every chore the table raises with the card it
* values least. It reads only what a player at the table could see, plus its
* own hand.
*
* It is not a strong player. It never moves knights about (they stay where
* hired, and are activated when the barbarians draw near), never plays the
* progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat,
* Deserter, Intrigue), and makes no domestic offers, though it answers them.
*/
internal class CatanCkAi(private val ai: CatanAi) {
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove = when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
CatanPhase.CHORE -> chooseChore(state, seat, legal)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
// -----------------------------------------------------------------------
// Chores
// -----------------------------------------------------------------------
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val ck = state.ck!!
return when (val chore = ck.chores.first()) {
is DiscardChore -> {
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
DiscardGoods(CatanCk.goodsList(state, seat).sortedBy { worth(state, seat, it) }.take(n))
}
is GiveChore -> {
val pool = CatanCk.goodsList(state, seat)
.filter { !chore.commoditiesOnly || it.isCommodity }
.sortedBy { worth(state, seat, it) }
GiveGoods(pool.take(minOf(chore.count, pool.size)))
}
is PillageChore ->
legal.filterIsInstance<PillageCity>().minByOrNull { production(state, it.vertex) } ?: legal.first()
is PickDeckChore -> {
val picks = legal.filterIsInstance<PickDeck>()
picks.maxByOrNull { CatanCk.improvement(state, seat, it.track) } ?: legal.first()
}
is DropProgressChore ->
legal.filterIsInstance<DropProgress>().minByOrNull { drop ->
ck.progressHands[seat].first { it.id == drop.card }.kind.let(::cardWorth)
} ?: legal.first()
is MetropolisChore ->
legal.filterIsInstance<PlaceMetropolis>().maxByOrNull { production(state, it.vertex) }
?: legal.first()
is AqueductChore ->
legal.filterIsInstance<TakeAqueduct>().maxByOrNull { ai.want(it.resource) } ?: legal.first()
is RelocateChore -> legal.first()
is RemoveKnightChore ->
legal.filterIsInstance<RemoveKnight>().minByOrNull {
CatanCk.knightAt(state, it.vertex)?.level ?: 0
} ?: legal.first()
is PlaceKnightChore -> legal.first()
is SpyChore -> legal.first()
is SmithChore ->
legal.filterIsInstance<PromoteKnight>().firstOrNull() ?: legal.first { it is EndSmith }
}
}
// -----------------------------------------------------------------------
// The robber
// -----------------------------------------------------------------------
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val hex = CatanBoard.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
score += if (building.owner == seat) -worth * odds else worth * odds
}
if (move.victim != null) {
score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim]
}
score
} ?: moves.first()
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val ck = state.ck!!
val soon = ck.barbarians <= 3
// Chase the robber off a hex it is squatting on.
if (ck.attacked) {
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
if (onMine != null && robberHurtsMe) return onMine
}
// The cheap upgrade first, then the full-price one.
legal.filterIsInstance<PlayProgress>()
.filter { kindOf(state, seat, it) == ProgressType.MEDICINE }
.maxByOrNull { production(state, it.vertex!!) }
?.let { return it }
legal.filterIsInstance<BuildCity>().maxByOrNull { production(state, it.vertex) }
?.let { return it }
legal.filterIsInstance<BuildSettlement>().maxByOrNull { ai.spotValue(state, it.vertex) }
?.let { return it }
// Improvements spend cards nothing else needs. Favour the track furthest along.
legal.filterIsInstance<BuyImprovement>()
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
?.let { return it }
// Knights: stand ready by the time the barbarians land.
defence(state, seat, legal, soon)?.let { return it }
progress(state, seat, legal)?.let { return it }
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
if (CatanCk.goodsCount(state, seat) >= 7) return it
}
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.1 }
.maxByOrNull { ai.roadValue(state, it) }
?.let { return it }
legal.filterIsInstance<GoodsBankTrade>()
.filter { completes(state, seat, it) }
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
?.let { return it }
return legal.lastOrNull { it is EndTurn } ?: legal.first()
}
/** Hire, promote and activate so the knights are standing when the barbarians land. */
private fun defence(state: CatanState, seat: Int, legal: List<CatanMove>, soon: Boolean): CatanMove? {
val mine = CatanCk.knightsOf(state, seat)
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
val want = if (soon) 4 else 2
if (mine.size < want) {
legal.filterIsInstance<HireKnight>().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire ->
// Keep a grain back for waking it, once the ship is close.
if (!soon || grainLeft >= 1) return hire
}
}
if (soon || mine.size >= 2) {
legal.filterIsInstance<PromoteKnight>().firstOrNull()?.let { return it }
}
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
return null
}
/** Progress cards worth playing without a plan. */
private fun progress(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove? {
val ck = state.ck!!
val soon = ck.barbarians <= 4
val plays = legal.filterIsInstance<PlayProgress>()
fun kindOf(play: PlayProgress) = ck.progressHands[seat].first { it.id == play.card }.kind
fun first(kind: ProgressType) = plays.firstOrNull { kindOf(it) == kind }
first(ProgressType.IRRIGATION)?.let { return it }
first(ProgressType.MINING)?.let { return it }
first(ProgressType.ENGINEER)?.let { return it }
first(ProgressType.SMITH)?.let { return it }
first(ProgressType.WEDDING)?.let { return it }
if (soon) first(ProgressType.WARLORD)?.let { return it }
plays.filter { kindOf(it) == ProgressType.MASTER_MERCHANT }
.maxByOrNull { play -> play.goods.orEmpty().sumOf { worth(state, seat, it) } }
?.let { return it }
first(ProgressType.SPY)?.let { return it }
first(ProgressType.BISHOP)?.let { return it }
if (legal.filterIsInstance<BuildRoad>().size >= 2) first(ProgressType.ROAD_BUILDING)?.let { return it }
// Crane: only when it makes the difference to an improvement.
first(ProgressType.CRANE)?.let { crane ->
val helps = Track.entries.any { track ->
val level = CatanCk.improvement(state, seat, track)
val have = ck.commodities[seat][track.commodity] ?: 0
level < IMPROVEMENT_MAX && have == level // one short of the next level's price
}
if (helps) return crane
}
// Saboteur: when someone level or ahead is sitting on a pile.
first(ProgressType.SABOTEUR)?.let { saboteur ->
val vp = CatanRules.publicVictoryPoints(state)
val pile = (0 until state.playerCount).filter { it != seat && vp[it] >= vp[seat] }
.sumOf { CatanCk.goodsCount(state, it) / 2 }
if (pile >= 4) return saboteur
}
// Monopolies: only when the table is holding a pile of it.
plays.filter { kindOf(it) == ProgressType.RESOURCE_MONOPOLY || kindOf(it) == ProgressType.TRADE_MONOPOLY }
.maxByOrNull { held(state, seat, it.good!!) }
?.let { best ->
val each = if (kindOf(best) == ProgressType.RESOURCE_MONOPOLY) 2 else 1
val take = (0 until state.playerCount).filter { it != seat }
.sumOf { minOf(each, CatanCk.goodsOf(state, it)[best.good!!] ?: 0) }
if (take >= 3) return best
}
// The merchant, on the best hex beside a building of ours.
plays.filter { kindOf(it) == ProgressType.MERCHANT }
.maxByOrNull { play ->
val hex = play.hexA!!
CatanBoard.numberOdds(state.numbers[hex]).toDouble()
}?.let { return it }
// A commercial harbour turns spare resources into commodities.
plays.filter { kindOf(it) == ProgressType.COMMERCIAL_HARBOR }
.maxByOrNull { held(state, seat, it.good!!) }
?.let { if (held(state, seat, it.good!!) >= 2) return it }
return null
}
private fun kindOf(state: CatanState, seat: Int, play: PlayProgress): ProgressType =
state.ck!!.progressHands[seat].first { it.id == play.card }.kind
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
private fun production(state: CatanState, vertex: Int): Double =
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
}
private fun held(state: CatanState, seat: Int, good: Good): Int = CatanCk.goodsOf(state, seat)[good] ?: 0
/** Whether the trade leaves a hand that can pay for a city, a settlement or an improvement. */
private fun completes(state: CatanState, seat: Int, trade: GoodsBankTrade): Boolean {
val hand = CatanCk.goodsOf(state, seat).toMutableMap()
hand[trade.give] = (hand[trade.give] ?: 0) - CatanCk.bankRatio(state, seat, trade.give)
hand[trade.get] = (hand[trade.get] ?: 0) + 1
fun covers(cost: Map<Resource, Int>) = cost.all { (r, n) -> (hand[Good.of(r)] ?: 0) >= n }
if (covers(CatanRules.CITY_COST) || covers(CatanRules.SETTLEMENT_COST)) return true
return Track.entries.any { track ->
val level = CatanCk.improvement(state, seat, track)
level < IMPROVEMENT_MAX &&
(hand[Good.of(track.commodity)] ?: 0) >= level + 1 &&
(state.ck!!.commodities[seat][track.commodity] ?: 0) < level + 1
}
}
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove =
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.05 }
.maxByOrNull { ai.roadValue(state, it) }
?: legal.firstOrNull { it is EndFreeRoads }
?: legal.first()
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val decline = legal.first { it is DeclineTrade }
val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline
val offer = state.ck?.goodsOffer
if (offer != null) {
val incoming = offer.give.sumOf { worth(state, seat, it) }
val outgoing = offer.get.sumOf { worth(state, seat, it) }
return if (incoming > outgoing) accept else decline
}
val plain = state.pendingTrade ?: return decline
val incoming = plain.give.sumOf { ai.want(it) }
val outgoing = plain.get.sumOf { ai.want(it) }
return if (incoming > outgoing) accept else decline
}
/** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove =
legal.firstOrNull { it is DismissCounters } ?: legal.first()
// -----------------------------------------------------------------------
// Valuations
// -----------------------------------------------------------------------
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
private fun worth(state: CatanState, seat: Int, good: Good): Double =
good.resource?.let { ai.want(it) } ?: when (good) {
Good.PAPER -> 0.95
Good.COIN -> 0.9
else -> 0.85
}
/** Which track to lean toward when two are level: politics for the knights, then science. */
private fun trackLean(track: Track): Int = when (track) {
Track.POLITICS -> 2
Track.SCIENCE -> 1
Track.TRADE -> 0
}
private fun cardWorth(kind: ProgressType): Double = when (kind) {
ProgressType.INVENTOR, ProgressType.ALCHEMIST, ProgressType.DIPLOMAT,
ProgressType.DESERTER, ProgressType.INTRIGUE, ProgressType.MERCHANT_FLEET,
-> 0.0
ProgressType.MERCHANT, ProgressType.COMMERCIAL_HARBOR, ProgressType.CRANE -> 0.5
else -> 1.0
}
}
@@ -0,0 +1,486 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/*
* The data of the Cities & Knights rule set. The rules themselves live in
* [CatanCk]; docs/RULES-catan-ck.md describes them and the choices made.
*
* Everything here is reached through [CatanState.ck], which is null for the
* base game, so a base game never sees any of it.
*/
/** Steps the barbarian ship must make before it lands. */
const val BARBARIAN_STEPS = 7
/** Commodity cards of each type the bank starts with (resources have 19). */
const val BANK_PER_COMMODITY = 12
/** Knights of each strength one player owns. */
const val KNIGHTS_PER_LEVEL = 2
/** City walls one player owns. */
const val MAX_WALLS = 3
/** Levels of city improvement in each track. */
const val IMPROVEMENT_MAX = 5
/** Progress cards a player may keep in hand. */
const val PROGRESS_HAND_MAX = 4
/** Cards a player may hold through a seven, before walls. */
const val BASE_HAND_LIMIT = 7
/** Longest Road is worth two points here; there is no Largest Army. */
const val CK_LONGEST_ROAD_VP = 2
/** Improvement level that unlocks the track's special power. */
const val IMPROVEMENT_POWER_LEVEL = 3
/** Level at which a track can claim its metropolis, and the level that can steal one. */
const val METROPOLIS_LEVEL = 4
/**
* What the three cities-of-industry give in place of a second resource:
* forest gives paper, pasture cloth and mountains coin.
*/
@Serializable
enum class Commodity { CLOTH, COIN, PAPER }
/**
* Any card a player can hold in hand and trade: a resource or a commodity.
* Progress cards are not goods.
*/
@Serializable
enum class Good {
LUMBER, BRICK, WOOL, GRAIN, ORE, CLOTH, COIN, PAPER;
val resource: Resource?
get() = when (this) {
LUMBER -> Resource.LUMBER
BRICK -> Resource.BRICK
WOOL -> Resource.WOOL
GRAIN -> Resource.GRAIN
ORE -> Resource.ORE
else -> null
}
val commodity: Commodity?
get() = when (this) {
CLOTH -> Commodity.CLOTH
COIN -> Commodity.COIN
PAPER -> Commodity.PAPER
else -> null
}
val isCommodity: Boolean get() = commodity != null
companion object {
fun of(resource: Resource): Good = when (resource) {
Resource.LUMBER -> LUMBER
Resource.BRICK -> BRICK
Resource.WOOL -> WOOL
Resource.GRAIN -> GRAIN
Resource.ORE -> ORE
}
fun of(commodity: Commodity): Good = when (commodity) {
Commodity.CLOTH -> CLOTH
Commodity.COIN -> COIN
Commodity.PAPER -> PAPER
}
val RESOURCES: List<Good> = Resource.entries.map { of(it) }
val COMMODITIES: List<Good> = Commodity.entries.map { of(it) }
}
}
/**
* The three tracks of city improvement. Each is bought with one commodity, has
* a colour of progress-card deck and a power that unlocks at level three.
*/
@Serializable
enum class Track(val commodity: Commodity) {
/** Yellow, bought with cloth: trade commodities two for one. */
TRADE(Commodity.CLOTH),
/** Blue, bought with coin: knights may become mighty. */
POLITICS(Commodity.COIN),
/** Green, bought with paper: the aqueduct. */
SCIENCE(Commodity.PAPER),
}
/**
* One face of the event die: a ship, or a city gate of a track's colour.
* Three faces are ships.
*/
@Serializable
enum class EventFace { SHIP, TRADE, POLITICS, SCIENCE }
/** The progress cards: [copies] of each in the deck of its [track]. */
@Serializable
enum class ProgressType(val track: Track, val copies: Int, val scoresAtOnce: Boolean = false) {
// Science (green)
ALCHEMIST(Track.SCIENCE, 2),
CRANE(Track.SCIENCE, 2),
ENGINEER(Track.SCIENCE, 1),
INVENTOR(Track.SCIENCE, 2),
IRRIGATION(Track.SCIENCE, 2),
MEDICINE(Track.SCIENCE, 2),
MINING(Track.SCIENCE, 2),
PRINTER(Track.SCIENCE, 1, scoresAtOnce = true),
ROAD_BUILDING(Track.SCIENCE, 2),
SMITH(Track.SCIENCE, 2),
// Politics (blue)
BISHOP(Track.POLITICS, 2),
CONSTITUTION(Track.POLITICS, 1, scoresAtOnce = true),
DESERTER(Track.POLITICS, 2),
DIPLOMAT(Track.POLITICS, 2),
INTRIGUE(Track.POLITICS, 2),
SABOTEUR(Track.POLITICS, 2),
SPY(Track.POLITICS, 3),
WARLORD(Track.POLITICS, 2),
WEDDING(Track.POLITICS, 2),
// Trade (yellow)
COMMERCIAL_HARBOR(Track.TRADE, 2),
MASTER_MERCHANT(Track.TRADE, 2),
MERCHANT(Track.TRADE, 6),
MERCHANT_FLEET(Track.TRADE, 2),
RESOURCE_MONOPOLY(Track.TRADE, 4),
TRADE_MONOPOLY(Track.TRADE, 2),
}
/** One progress card; [id] is stable for the life of the game. */
@Serializable
data class ProgressCard(val id: Int, val kind: ProgressType)
/**
* A knight on the board. [level] is its strength: 1 basic, 2 strong, 3 mighty.
*
* [activatedTurn] and [promotedTurn] record the turn number each happened on:
* a knight cannot act on the turn it was activated, and cannot be promoted twice
* in one turn.
*/
@Serializable
data class Knight(
val vertex: Int,
val owner: Int,
val level: Int,
val active: Boolean = false,
val activatedTurn: Int = 0,
val promotedTurn: Int = 0,
)
/** A metropolis stands on one city: [vertex] of seat [seat]. */
@Serializable
data class MetropolisAt(val seat: Int, val vertex: Int)
/** The merchant sits on a hex, controlled by [seat] (who holds its point). */
@Serializable
data class MerchantAt(val seat: Int, val hex: Int)
/** A counter-proposal to a [GoodsOffer]: seat [by] would [give] these if the offerer handed over [get]. */
@Serializable
data class GoodsCounter(val by: Int, val give: List<Good>, val get: List<Good>)
/**
* A domestic trade offer in goods, which may include commodities. It is the
* base game's [TradeOffer] in cards of any kind, and works the same way: [to] is
* the one seat it is addressed to or null for the whole table, [asking] is the
* seat that may answer now, and [counters] collect for the offerer to choose from.
*/
@Serializable
data class GoodsOffer(
val by: Int,
val to: Int?,
val give: List<Good>,
val get: List<Good>,
val asking: Int = to ?: -1,
val counters: List<GoodsCounter> = emptyList(),
)
/** Where play goes when the queue of [Chore]s runs out. */
@Serializable
enum class Back { PLAYING, AWAIT_ROLL, AFTER_EVENT, AFTER_DISCARDS, AFTER_PRODUCTION }
/**
* Something one seat must decide before the game can go on. A chore is queued
* when a roll, an attack or a card needs an answer from somebody (possibly not
* the player whose turn it is); the first in [CkState.chores] is the one being
* asked.
*/
@Serializable
sealed interface Chore {
val seat: Int
}
/** Give [count] cards to the bank, of the seat's choosing (a seven, a saboteur). */
@Serializable
@SerialName("c_discard")
data class DiscardChore(override val seat: Int, val count: Int) : Chore
/**
* Hand [count] cards of the seat's choosing to seat [to] (a wedding, a
* commercial harbour). [commoditiesOnly] restricts them to commodities, and
* [reward] is the resource [to] gives back for each.
*/
@Serializable
@SerialName("c_give")
data class GiveChore(
override val seat: Int,
val count: Int,
val to: Int,
val commoditiesOnly: Boolean = false,
val reward: Good? = null,
) : Chore
/** The barbarians won and this seat lost the contest: pick a city to fall. */
@Serializable
@SerialName("c_pillage")
data class PillageChore(override val seat: Int) : Chore
/** Choose a colour of progress deck to draw from. */
@Serializable
@SerialName("c_pickDeck")
data class PickDeckChore(override val seat: Int) : Chore
/** More progress cards than the hand holds: give one back. */
@Serializable
@SerialName("c_dropProgress")
data class DropProgressChore(override val seat: Int) : Chore
/** Choose the city that will bear the [track] metropolis. */
@Serializable
@SerialName("c_metropolis")
data class MetropolisChore(override val seat: Int, val track: Track) : Chore
/** The aqueduct pays out: take any one resource. */
@Serializable
@SerialName("c_aqueduct")
data class AqueductChore(override val seat: Int) : Chore
/** A knight was driven from its place; move it, or lose it. [knight] stood on [Knight.vertex]. */
@Serializable
@SerialName("c_relocate")
data class RelocateChore(override val seat: Int, val knight: Knight) : Chore
/** A deserter was played against this seat: remove one of your knights. */
@Serializable
@SerialName("c_removeKnight")
data class RemoveKnightChore(override val seat: Int, val by: Int) : Chore
/** Place a knight of [level] on the board for free, active or not as given. */
@Serializable
@SerialName("c_placeKnight")
data class PlaceKnightChore(override val seat: Int, val level: Int, val active: Boolean) : Chore
/** Choose one of [target]'s progress cards to take. */
@Serializable
@SerialName("c_spy")
data class SpyChore(override val seat: Int, val target: Int) : Chore
/** Promote up to [left] more knights for free. */
@Serializable
@SerialName("c_smith")
data class SmithChore(override val seat: Int, val left: Int) : Chore
/**
* Everything Cities & Knights adds to the table.
*
* Counts that are public ([progressCounts], the knights, the improvement
* levels) are stored directly; the decks and the other seats' progress hands are
* here in full because the host owns the truth, and [CatanRules.viewFor] keeps
* them off the wire.
*/
@Serializable
data class CkState(
/** Commodity cards per seat. */
val commodities: List<Map<Commodity, Int>>,
/** Improvement level (0..5) per seat and track. */
val improvements: List<Map<Track, Int>>,
/** The metropolis of each track that has one. */
val metropolises: Map<Track, MetropolisAt> = emptyMap(),
val knights: List<Knight> = emptyList(),
/** Vertices whose city has a wall. */
val walls: List<Int> = emptyList(),
/** Steps left before the barbarians land, [BARBARIAN_STEPS] at the start. */
val barbarians: Int = BARBARIAN_STEPS,
/** Whether the barbarians have landed once: until then the robber sleeps. */
val attacked: Boolean = false,
val eventDie: EventFace? = null,
/** Progress decks, top first. Order is hidden. */
val decks: Map<Track, List<ProgressCard>>,
val progressHands: List<List<ProgressCard>>,
/** Public mirror of the progress-hand sizes. */
val progressCounts: List<Int>,
/** Victory points from Constitution and Printer, played face up at once. */
val progressVp: List<Int>,
/** Defender of Catan cards won, one point each. */
val defenderVp: List<Int>,
val merchant: MerchantAt? = null,
/** A crane was played this turn and its discount is unspent. */
val crane: Boolean = false,
/** A merchant fleet was played this turn for this good. */
val fleet: Good? = null,
/** Dice named by an alchemist, used by the next roll. */
val alchemist: List<Int>? = null,
/** The robber is being moved by a bishop, which robs everyone nearby. */
val bishop: Boolean = false,
val chores: List<Chore> = emptyList(),
val back: Back = Back.PLAYING,
val goodsOffer: GoodsOffer? = null,
/** Seats that have already drawn a defender or winners' card this attack, for the log only. */
val lastAttack: String? = null,
)
// ---------------------------------------------------------------------------
// Moves
// ---------------------------------------------------------------------------
/** Buy the next level of improvement in [track]. */
@Serializable
@SerialName("buyImprovement")
data class BuyImprovement(val track: Track) : CatanMove
/** Hire a basic knight onto [vertex], an empty crossing at the end of one of your roads. */
@Serializable
@SerialName("hireKnight")
data class HireKnight(val vertex: Int) : CatanMove
/** Pay a grain to activate the knight on [vertex]. */
@Serializable
@SerialName("activateKnight")
data class ActivateKnight(val vertex: Int) : CatanMove
/** Promote the knight on [vertex] one level (paid, or free under a Smith). */
@Serializable
@SerialName("promoteKnight")
data class PromoteKnight(val vertex: Int) : CatanMove
/** Move the active knight on [from] along your roads to [to], driving off a weaker knight there. */
@Serializable
@SerialName("moveKnight")
data class MoveKnight(val from: Int, val to: Int) : CatanMove
/** The active knight on [vertex] chases the robber; the robber move follows. */
@Serializable
@SerialName("chaseRobber")
data class ChaseRobber(val vertex: Int) : CatanMove
/** Raise a wall around the city on [vertex]. */
@Serializable
@SerialName("buildWall")
data class BuildWall(val vertex: Int) : CatanMove
/**
* Play a progress card. Which of the optional arguments are used depends on
* the card (see [CatanCk]); the engine's legal list names every combination it
* accepts, so a client only ever chooses among them.
*/
@Serializable
@SerialName("progress")
data class PlayProgress(
val card: Int,
val target: Int? = null,
val vertex: Int? = null,
val edge: Int? = null,
val hexA: Int? = null,
val hexB: Int? = null,
val good: Good? = null,
val dice: List<Int>? = null,
val goods: List<Good>? = null,
) : CatanMove
/**
* Trade [give] to the bank for [get], at the best ratio this seat has for the
* card it gives. Replaces [MaritimeTrade] in Cities & Knights, where
* commodities trade too.
*/
@Serializable
@SerialName("goodsBank")
data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove
/**
* Offer a trade in goods to seat [to], or to the whole table when [to] is null.
* Free-form like [OfferTrade], so it is checked structurally and is not in the
* enumerated move list.
*/
@Serializable
@SerialName("offerGoods")
data class OfferGoods(val to: Int?, val give: List<Good>, val get: List<Good>) : CatanMove
/**
* Answer the goods offer on the table with different terms. Structural, like
* [OfferGoods]; the answers to the counters are the shared [AcceptCounter] and
* [DismissCounters].
*/
@Serializable
@SerialName("counterGoods")
data class CounterGoods(val give: List<Good>, val get: List<Good>) : CatanMove
/**
* Give up exactly the cards named, to a [DiscardChore]. Structural, not
* enumerated; the legal list carries one example so a seat is never without a move.
*/
@Serializable
@SerialName("discardGoods")
data class DiscardGoods(val cards: List<Good>) : CatanMove
/** Hand over the cards named to a [GiveChore]. Structural, like [DiscardGoods]. */
@Serializable
@SerialName("giveGoods")
data class GiveGoods(val cards: List<Good>) : CatanMove
/** Let the city on [vertex] fall to the barbarians. */
@Serializable
@SerialName("pillageCity")
data class PillageCity(val vertex: Int) : CatanMove
/** Draw from the deck of this track's colour. */
@Serializable
@SerialName("pickDeck")
data class PickDeck(val track: Track) : CatanMove
/** Give up one progress card from a hand that is over the limit. */
@Serializable
@SerialName("dropProgress")
data class DropProgress(val card: Int) : CatanMove
/** Put the metropolis on the city at [vertex]. */
@Serializable
@SerialName("placeMetropolis")
data class PlaceMetropolis(val vertex: Int) : CatanMove
/** Take one [resource] from the bank for an aqueduct. */
@Serializable
@SerialName("aqueduct")
data class TakeAqueduct(val resource: Resource) : CatanMove
/** Move the knight that was driven off to [to]. */
@Serializable
@SerialName("relocateKnight")
data class RelocateKnight(val to: Int) : CatanMove
/** Take the knight on [vertex] off the board, for a deserter. */
@Serializable
@SerialName("removeKnight")
data class RemoveKnight(val vertex: Int) : CatanMove
/** Place the free knight from a deserter on [vertex]. */
@Serializable
@SerialName("placeKnight")
data class PlaceKnight(val vertex: Int) : CatanMove
/** Take the spied progress card with this id. */
@Serializable
@SerialName("spyTake")
data class SpyTake(val card: Int) : CatanMove
/** Stop promoting knights under a Smith. */
@Serializable
@SerialName("endSmith")
data object EndSmith : CatanMove
@@ -0,0 +1,379 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
const val NO_PLAYER = -1
/** Road segments required to hold Longest Road. */
const val LONGEST_ROAD_MIN = 5
/** Knights required to take Largest Army. */
const val LARGEST_ARMY_MIN = 3
/** 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),
}
/** 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,
/** 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,
}
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
@Serializable
enum class CatanRuleSet {
/** The base game: development cards, Largest Army, a robber from the start. */
BASE,
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS,
}
@Serializable
data class CatanOptions(
val playerCount: Int = 3,
val pointsToWin: Int = 10,
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
) {
init {
require(playerCount in 3..4) { "The island seats three or four players" }
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
}
}
/**
* The whole game as one immutable value.
*
* Public information (terrain, numbers, ports, buildings, roads, resource
* counts, dev-card counts, knights played) is stored directly. Hidden
* information — the deck's order and every hand's development cards — is
* here in full because the host owns the truth; [CatanRules.viewFor] is
* what keeps it off the wire.
*/
@Serializable
data class CatanState(
val options: CatanOptions,
val seed: Long,
val phase: CatanPhase,
/**
* The seat whose turn it is — not always the seat expected to move:
* during [CatanPhase.DISCARDING] and [CatanPhase.TRADE_PENDING] another
* seat acts on the turn owner's behalf.
*/
val turn: Int,
/** How many setup placements are done, 0 until 2 * playerCount. */
val setupIndex: Int,
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
val turnNumber: Int,
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
val terrains: List<Terrain>,
/** Number token per hex, 0 where there is none (the desert). */
val numbers: List<Int>,
/** Hex the robber currently occupies. */
val robber: Int,
val ports: List<Port>,
/** What stands on each vertex, or null. */
val buildings: List<CatanBuilding?>,
/** Owning seat per edge, or [NO_PLAYER]. */
val roads: List<Int>,
/** Resource cards per seat. Card counts are public in Catan. */
val resources: List<Map<Resource, Int>>,
/** The bank's development cards, top first. Order is hidden. */
val devDeck: List<DevCard>,
/** Development cards per seat. Hidden from everyone but the holder. */
val devHands: List<List<DevCard>>,
/** Public mirror of the dev-hand sizes, for views and scoreboards. */
val devHandCounts: List<Int>,
/** Knights played (not merely held) per seat. */
val knightsPlayed: List<Int>,
/** Whether the turn owner has already played a development card. */
val devCardPlayedThisTurn: Boolean,
val pendingTrade: TradeOffer?,
/** Roads still owed on a played Road Building card. */
val pendingFreeRoads: Int,
/** Seat due to discard during [CatanPhase.DISCARDING]. */
val discardSeat: Int?,
val longestRoadOwner: Int?,
val largestArmyOwner: Int?,
/** The dice as last rolled, for the table display. */
val lastDice: List<Int>,
/**
* How many dice rolls and steals have happened so far. Random events are
* derived from the seed and this counter, so the same game replays
* identically without storing a random generator in the state.
*/
val rngStep: Int,
val winner: Int?,
val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null,
) {
val playerCount: Int get() = options.playerCount
/** Whether this table plays Cities & Knights. */
val isCk: Boolean get() = ck != null
/** Total resource cards held by [seat]. */
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
/** [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
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,21 @@ interface GameRules<S : Any, M : Any> {
fun legalMoves(state: S, seat: Int): List<M> fun legalMoves(state: S, seat: Int): List<M>
/**
* Whether [move] is currently legal for [seat].
*
* Defaults to membership in [legalMoves], which is everything a game
* with a small, enumerable move set needs. A game whose move space is
* combinatorial — Catan's trade offers name any two multisets of cards —
* overrides this and validates structurally instead, because enumerating
* every intent would explode the list the UI and the network driver work
* from. Gates that check a specific move should call this method rather
* than testing list membership directly, so both kinds of game behave the
* same way.
*/
fun isLegalMove(state: S, seat: Int, move: M): Boolean =
move in legalMoves(state, seat)
/** /**
* Applies [move] on behalf of [seat]. * Applies [move] on behalf of [seat].
* *
@@ -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 }
}
}
@@ -96,6 +96,27 @@ class MatchController<S : Any, M : Any>(
/** Host only: the true state. Clients never see this. */ /** Host only: the true state. Clients never see this. */
private var authoritative: S? = null 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 val lock = Mutex()
private var sequence = 0 private var sequence = 0
@@ -160,7 +181,7 @@ class MatchController<S : Any, M : Any>(
check(role == Role.HOST) { "Not hosting" } check(role == Role.HOST) { "Not hosting" }
clients.forEach { connection -> listenToPeer(connection) } clients.forEach { connection -> listenToPeer(connection) }
scope.launch { 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 // 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 // to take the app down with it: this runs in the screen's own
// scope, where nothing would catch it. // 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) { private fun listenToPeer(connection: Connection) {
peers[connection.peerId] = connection peers[connection.peerId] = connection
peerJobs[connection.peerId] = scope.launch { 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.")) } seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
return return
} }
if (move !in rules.legalMoves(current, seat)) { if (!rules.isLegalMove(current, seat, move)) {
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) } seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
return return
} }
@@ -424,7 +459,7 @@ class MatchController<S : Any, M : Any>(
// throws on a move that is no longer legal, and there is nothing // throws on a move that is no longer legal, and there is nothing
// above this to catch it. // above this to catch it.
if (rules.currentSeat(live) != seat) return@withLock false if (rules.currentSeat(live) != seat) return@withLock false
if (move !in rules.legalMoves(live, seat)) return@withLock false if (!rules.isLegalMove(live, seat, move)) return@withLock false
authoritative = rules.applyMove(live, seat, move) authoritative = rules.applyMove(live, seat, move)
publish() publish()
true true
@@ -449,6 +484,16 @@ class MatchController<S : Any, M : Any>(
} }
_finished.value = rules.isFinished(current) _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) { for (slot in config.seats) {
val peerId = slot.peerId ?: continue val peerId = slot.peerId ?: continue
val connection = peers[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,
),
)
}
}
}
}
@@ -29,6 +29,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -50,6 +52,10 @@ import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.ui.game.LocalCardSpeed import org.prolibertate.games.ui.game.LocalCardSpeed
import org.prolibertate.games.update.UpdateChannel import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater import org.prolibertate.games.update.Updater
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.prolibertate.games.game.engine.SavedGameStore
import org.prolibertate.games.game.engine.SavedMatch
/** Everything the screens need, assembled once by the activity. */ /** Everything the screens need, assembled once by the activity. */
class AppEnv( class AppEnv(
@@ -60,6 +66,8 @@ class AppEnv(
/** The shared score sheet, for the Scorekeeper to host or join. */ /** The shared score sheet, for the Scorekeeper to host or join. */
val scoreSession: ScorekeeperSession, val scoreSession: ScorekeeperSession,
val peerId: String, val peerId: String,
/** Games in progress, kept between sessions. */
val saves: SavedGameStore,
) )
/** /**
@@ -83,6 +91,8 @@ sealed interface Route {
val config: TableConfig, val config: TableConfig,
val hosting: Boolean, val hosting: Boolean,
val localSeat: Int, 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 ) : Route
} }
@@ -167,6 +177,10 @@ private fun AppContent(
// Dev builds show the games that are not finished yet; production // Dev builds show the games that are not finished yet; production
// releases list only what can actually be played. // releases list only what can actually be played.
showComingSoon = showComingSoon, showComingSoon = showComingSoon,
saved = savedGames(env, stack),
onResume = { saved ->
push(Route.Play(saved.gameId, saved.config, true, saved.localSeat, saved.stateJson))
},
onPickSection = { push(Route.Section(it)) }, onPickSection = { push(Route.Section(it)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onScorekeeper = { push(Route.Scorekeeper) }, onScorekeeper = { push(Route.Scorekeeper) },
@@ -194,7 +208,13 @@ private fun AppContent(
onBack = { pop() }, onBack = { pop() },
) )
is Route.Setup -> GameSetupScreen( 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)!!, descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.displayName, playerName = settings.displayName,
onBack = { pop() }, onBack = { pop() },
@@ -202,7 +222,16 @@ private fun AppContent(
onHostOnline = { optionsJson -> onHostOnline = { optionsJson ->
push(Route.Lobby(route.gameId, optionsJson, hosting = true)) push(Route.Lobby(route.gameId, optionsJson, hosting = true))
}, },
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( is Route.Lobby -> LobbyScreen(
env = env, env = env,
@@ -231,6 +260,8 @@ private fun AppContent(
@Composable @Composable
fun MainMenuScreen( fun MainMenuScreen(
showComingSoon: Boolean, showComingSoon: Boolean,
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
onPickSection: (GameMenu) -> Unit, onPickSection: (GameMenu) -> Unit,
onJoinGame: () -> Unit, onJoinGame: () -> Unit,
onScorekeeper: () -> Unit, onScorekeeper: () -> Unit,
@@ -280,6 +311,23 @@ fun MainMenuScreen(
) )
} }
// Games left half-played, most recent first, above everything
// else: coming back to the app is mostly coming back to one.
if (saved.isNotEmpty()) {
Text(
"Continue",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.fillMaxWidth(),
)
val now = System.currentTimeMillis()
saved.forEach { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Button(onClick = { onResume(match) }, modifier = Modifier.fillMaxWidth()) {
Text(savedLine(title, match, now))
}
}
}
// Below the games, and full width like them, so the column // Below the games, and full width like them, so the column
// stays one column all the way down. // stays one column all the way down.
OutlinedButton( OutlinedButton(
@@ -364,7 +412,11 @@ fun SectionScreen(
} }
} }
items(games) { game -> items(games) { game ->
GameTile(game = game, onClick = { onPickGame(game) }) GameTile(
game = game,
playable = game.playableIn(devBuild = showComingSoon),
onClick = { onPickGame(game) },
)
} }
} }
} }
@@ -403,12 +455,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) {
Card( Card(
onClick = onClick, onClick = onClick,
enabled = game.available, enabled = playable,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (game.available) { containerColor = if (playable) {
MaterialTheme.colorScheme.surfaceVariant MaterialTheme.colorScheme.surfaceVariant
} else { } else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
@@ -427,13 +479,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
Text( val players = when {
text = when {
!game.available -> "Coming soon"
// A puzzle for one, rather than "1 players". // A puzzle for one, rather than "1 players".
game.maxPlayers == 1 -> "On your own" game.maxPlayers == 1 -> "On your own"
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
else -> "${game.minPlayers}–${game.maxPlayers} players" else -> "${game.minPlayers}–${game.maxPlayers} players"
}
Text(
text = when {
!playable -> "Coming soon"
// Playable on this dev build only; production hides it.
!game.available -> "Preview · $players"
else -> players
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
@@ -481,3 +538,17 @@ fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) {
Text(text) 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -29,6 +32,7 @@ import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.GameDescriptor import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot 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.engine.TableConfig
import org.prolibertate.games.game.backgammon.BackgammonLevel import org.prolibertate.games.game.backgammon.BackgammonLevel
import org.prolibertate.games.game.backgammon.BackgammonOptions import org.prolibertate.games.game.backgammon.BackgammonOptions
@@ -57,6 +61,8 @@ import org.prolibertate.games.game.morris.MorrisOptions
import org.prolibertate.games.game.pirates.PiratesLevel import org.prolibertate.games.game.pirates.PiratesLevel
import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions import org.prolibertate.games.game.tayu.TayuOptions
@@ -81,6 +87,10 @@ fun GameSetupScreen(
onBack: () -> Unit, onBack: () -> Unit,
onPlayOffline: (TableConfig, Int) -> Unit, onPlayOffline: (TableConfig, Int) -> Unit,
onHostOnline: (String) -> Unit, onHostOnline: (String) -> 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 euchre by remember { mutableStateOf(EuchreOptions()) }
var sequence by remember { mutableStateOf(SequenceOptions()) } var sequence by remember { mutableStateOf(SequenceOptions()) }
@@ -104,6 +114,7 @@ fun GameSetupScreen(
var freecell by remember { mutableStateOf(FreeCellOptions()) } var freecell by remember { mutableStateOf(FreeCellOptions()) }
var spider by remember { mutableStateOf(SpiderOptions()) } var spider by remember { mutableStateOf(SpiderOptions()) }
var pyramid by remember { mutableStateOf(PyramidOptions()) } 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 // Not an engine option: which seat the local player takes. Seat 0 is
// always White, so choosing Black means sitting in seat 1. // always White, so choosing Black means sitting in seat 1.
var playWhite by remember { mutableStateOf(true) } var playWhite by remember { mutableStateOf(true) }
@@ -134,6 +145,7 @@ fun GameSetupScreen(
GameCatalog.FREECELL -> setupJson.encodeToString(freecell) GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
GameCatalog.SPIDER -> setupJson.encodeToString(spider) GameCatalog.SPIDER -> setupJson.encodeToString(spider)
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
GameCatalog.CATAN -> setupJson.encodeToString(catan)
else -> "{}" else -> "{}"
} }
val seatCount = seatCountFor(descriptor.id, optionsJson) val seatCount = seatCountFor(descriptor.id, optionsJson)
@@ -145,6 +157,10 @@ fun GameSetupScreen(
) { ) {
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium) Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
if (saved != null) {
SavedGameBanner(saved, onResume = { onResume(saved) }, onDiscard = onDiscardSaved)
}
Divider() Divider()
Text("House rules", fontWeight = FontWeight.Bold) Text("House rules", fontWeight = FontWeight.Bold)
@@ -199,6 +215,8 @@ fun GameSetupScreen(
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
else -> Text("No options yet for this game.") else -> Text("No options yet for this game.")
} }
@@ -227,6 +245,12 @@ fun GameSetupScreen(
descriptor.id == GameCatalog.PIRATES && playPirates -> 1 descriptor.id == GameCatalog.PIRATES && playPirates -> 1
else -> 0 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") { PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
onPlayOffline( onPlayOffline(
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat), offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
@@ -303,6 +327,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
GameCatalog.TAYU -> GameCatalog.TAYU ->
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
GameCatalog.CATAN ->
setupJson.decodeFromString<CatanOptions>(optionsJson).playerCount
else -> fallback else -> fallback
} }
}.getOrDefault(fallback) }.getOrDefault(fallback)
@@ -326,6 +353,72 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
else -> seat else -> seat
} }
/** The targets the setup screen offers for a Katan rule set. */
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) {
// Five is a short game for trying things out; ten is the published target.
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
}
/** The published target for a rule set. */
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
CatanRuleSet.BASE -> 10
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
}
/**
* [options] switched to [ruleSet]. The target moves to the new rule set's own
* default unless the one already chosen is on its menu too.
*/
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions =
options.copy(
ruleSet = ruleSet,
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) }
?: catanDefaultPoints(ruleSet),
)
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE -> "Base game"
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
}
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE ->
"Settle the island, trade, and play development cards. Longest Road and Largest Army score."
CatanRuleSet.CITIES_AND_KNIGHTS ->
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
"cards and metropolises. No development cards; the robber sleeps until the first attack."
}
@Composable
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Rules",
values = CatanRuleSet.entries,
selected = options.ruleSet,
display = ::catanRuleSetName,
onSelect = { onChange(catanSwitchRules(options, it)) },
)
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall)
ChipRow(
label = "Players",
values = listOf(3, 4),
selected = options.playerCount,
display = { "$it players" },
onSelect = { onChange(options.copy(playerCount = it)) },
)
ChipRow(
label = "Game to",
values = catanPointsChoices(options.ruleSet),
selected = options.pointsToWin,
display = { "$it points" },
onSelect = { onChange(options.copy(pointsToWin = it)) },
)
}
}
@Composable @Composable
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) { private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1141,3 +1234,30 @@ private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOpti
onSelect = { onChange(options.copy(redeals = it)) }, 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") }
}
}
}
}
@@ -2,10 +2,12 @@ package org.prolibertate.games.ui
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -13,6 +15,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.chess.ChessAi import org.prolibertate.games.game.chess.ChessAi
import org.prolibertate.games.game.chess.ChessMove import org.prolibertate.games.game.chess.ChessMove
import org.prolibertate.games.game.chess.ChessRules import org.prolibertate.games.game.chess.ChessRules
@@ -108,8 +114,11 @@ import org.prolibertate.games.game.wizard.WizardPhase
import org.prolibertate.games.game.wizard.WizardRules import org.prolibertate.games.game.wizard.WizardRules
import org.prolibertate.games.game.wizard.WizardState import org.prolibertate.games.game.wizard.WizardState
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.net.MatchSaver
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.BackgammonScreen import org.prolibertate.games.ui.game.BackgammonScreen
import org.prolibertate.games.ui.game.catan.CatanScreen
import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CrazyEightsScreen
@@ -150,6 +159,10 @@ fun PlayScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT
// The same handle for every table this screen builds, so an End-game dialog
// anywhere below can reach the table that is showing.
val endHandle = remember(route) { EndGameHandle() }
CompositionLocalProvider(LocalEndGameHandle provides endHandle) {
when (route.gameId) { when (route.gameId) {
GameCatalog.EUCHRE -> { GameCatalog.EUCHRE -> {
val controller = remember(route) { val controller = remember(route) {
@@ -668,8 +681,33 @@ fun PlayScreen(
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
} }
GameCatalog.CATAN -> {
val controller = remember(route) {
MatchController<CatanState, CatanMove>(
rules = CatanRules,
ai = CatanAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
// A computer's turn is several small moves (roll, build,
// trade, end), so the pause is per move, not per turn.
aiThinkingMillis = { settings.scaled(450L) },
)
}
StartMatch(env, controller, route)
CatanScreen(
controller = controller,
localSeat = route.localSeat,
seatNames = route.config.seats.sortedBy { it.seat }.map { it.name },
onExit = onExit,
)
}
else -> Text("That game isn't playable yet.") else -> Text("That game isn't playable yet.")
} }
}
} }
@Composable @Composable
@@ -678,8 +716,38 @@ private fun <S : Any, M : Any> StartMatch(
controller: MatchController<S, M>, controller: MatchController<S, M>,
route: Route.Play, route: Route.Play,
) { ) {
val scope = rememberCoroutineScope()
val endHandle = LocalEndGameHandle.current
// A game against the computer (or on one device) is kept as it is played, so
// it can be picked up after the app has been closed. A table with another
// device at it is not: the other player's seat cannot be held for them.
val saveable = route.hosting && route.config.seats.none { it.kind == PlayerKind.HUMAN_REMOTE }
val saver = remember(controller) {
if (saveable) {
MatchSaver(env.saves, route.gameId, route.config, route.localSeat, scope)
} else {
null
}
}
// The position to carry on from belongs to the first table only. Dealing
// again (solitaire's "new deal") builds a fresh controller under the same
// route, and that one must not be put back to where the last one stood.
val resume = remember(route) { mutableStateOf(route.resumeState) }
LaunchedEffect(controller) { LaunchedEffect(controller) {
if (route.hosting) { if (route.hosting) {
resume.value?.let { saved ->
resume.value = null
// A save this version cannot read — one from an older build — is
// not worth keeping; the table is dealt afresh instead.
if (!controller.restore(saved)) env.saves.clear(route.gameId)
}
if (saver != null) {
saver.start()
controller.onHostState = saver::offer
endHandle.onEnd = saver::discard
}
controller.startAsHost(env.lobby.hostConnections) controller.startAsHost(env.lobby.hostConnections)
} else { } else {
env.lobby.clientConnection?.let { controller.startAsClient(it) } env.lobby.clientConnection?.let { controller.startAsClient(it) }
@@ -692,6 +760,10 @@ private fun <S : Any, M : Any> StartMatch(
env.lobby.rebinder = controller env.lobby.rebinder = controller
onDispose { onDispose {
env.lobby.rebinder = null env.lobby.rebinder = null
// Whatever the writer has not caught up with is written before the
// table goes, so leaving mid-move does not lose the last move.
saver?.flush()
saver?.stop()
controller.close() controller.close()
} }
} }
@@ -705,11 +777,14 @@ private fun <S : Any, M : Any> StartMatch(
// sent. So the table is asked for again on the way back in, over a fresh // sent. So the table is asked for again on the way back in, over a fresh
// link if the old one did not survive. // link if the old one did not survive.
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, route.hosting) { DisposableEffect(lifecycleOwner, route.hosting, saver) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START && !route.hosting) { if (event == Lifecycle.Event.ON_START && !route.hosting) {
env.lobby.restoreLink() env.lobby.restoreLink()
} }
// The app going to the background may be the last the process hears:
// Android can end it without a word, so the position is written now.
if (event == Lifecycle.Event.ON_STOP) saver?.flush()
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
@@ -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)}"
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -378,7 +379,12 @@ private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -79,7 +80,12 @@ fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit
onDismissRequest = onKeepPlaying, onDismissRequest = onKeepPlaying,
title = { Text("End this game?") }, title = { Text("End this game?") },
text = { Text(what) }, 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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -314,7 +315,12 @@ private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -215,7 +216,12 @@ private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -268,7 +269,12 @@ private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying, onDismissRequest = onKeepPlaying,
title = { Text("End this game?") }, title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") }, 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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -342,7 +343,12 @@ private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying, onDismissRequest = onKeepPlaying,
title = { Text("End this game?") }, title = { Text("End this game?") },
text = { Text("The deal is left as it stands.") }, 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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -356,7 +357,12 @@ private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying, onDismissRequest = onKeepPlaying,
title = { Text("End this game?") }, title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") }, 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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -483,7 +484,12 @@ private fun LeaveBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -242,7 +243,12 @@ private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying, onDismissRequest = onKeepPlaying,
title = { Text("End this game?") }, title = { Text("End this game?") },
text = { Text("The board is left as it stands.") }, 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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -371,7 +372,12 @@ private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left." "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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -668,7 +669,12 @@ private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"table is told you have left." "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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -295,7 +296,12 @@ private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying, onDismissRequest = onKeepPlaying,
title = { Text("End this game?") }, title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") }, 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") } }, dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
) )
} }
@@ -0,0 +1,644 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntSize
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.game.catan.Resource
/**
* The island, drawn and tappable.
*
* A stateless view: it is handed the state, the parts of the board that are
* currently tappable ([targets]) and the one setup settlement that is waiting
* for its road ([selectedVertex]), and it reports taps. It knows no rules; what
* a tap *means* is decided by [CatanInteraction]. Where things are is decided by
* [BoardLayout], and what they look like by [CatanStyle], so this file is only
* the glue between the three.
*
* To change how a piece is drawn, edit the matching `draw…` function below. To
* change a colour or a proportion, edit [CatanStyle] instead.
*/
@Composable
fun CatanBoardView(
state: CatanState,
targets: BoardTargets,
selectedVertex: Int?,
onVertexTap: (Int) -> Unit,
onEdgeTap: (Int) -> Unit,
onHexTap: (Int) -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
/** Hexes to light up as having produced on the latest roll. */
rolledHexes: Set<Int> = emptySet(),
/** Cities & Knights: the knight whose menu is open or that is about to move. */
selectedKnight: Int? = null,
) {
val measurer = rememberTextMeasurer()
var size by remember { mutableStateOf(IntSize.Zero) }
val layout = remember(size) {
if (size.width > 0 && size.height > 0) {
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat())
} else {
null
}
}
// Held in State so the gesture detector, which is only restarted when the
// layout or targets change, always calls the latest callbacks.
val vertexTap by rememberUpdatedState(onVertexTap)
val edgeTap by rememberUpdatedState(onEdgeTap)
val hexTap by rememberUpdatedState(onHexTap)
Canvas(
modifier = modifier
.aspectRatio(CatanGeometry.ASPECT)
.onSizeChanged { size = it }
.semantics { contentDescription = "Katan island board" }
.pointerInput(layout, targets, style) {
detectTapGestures { tap ->
val board = layout ?: return@detectTapGestures
val at = Pt(tap.x, tap.y)
val reach = board.radius * style.tapReach
when {
targets.tappableVertices.isNotEmpty() ->
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
targets.edges.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
targets.hexes.isNotEmpty() ->
// A hex is big: accept a tap anywhere near its middle.
board.nearestHex(at, board.radius * 0.95f, targets.hexes)?.let { hexTap(it) }
}
}
},
) {
val board = layout ?: return@Canvas
drawRect(color = style.sea)
drawPorts(state, board, style, measurer)
drawHexes(state, board, style, measurer)
drawRolled(rolledHexes, board, style)
drawRobber(state, board, style)
drawMerchant(state, board, style, measurer)
drawRoads(state, board, style)
drawEdgeTargets(targets, board, style)
drawBuildings(state, board, style)
drawWalls(state, board, style)
drawMetropolises(state, board, style)
drawKnights(state, board, style)
drawKnightTargets(targets, board, style)
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
drawHexTargets(targets, board, style)
}
}
// ---------------------------------------------------------------------------
// Terrain and tokens
// ---------------------------------------------------------------------------
private fun DrawScope.drawHexes(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
for (hex in 0 until CatanBoard.HEX_COUNT) {
val centre = board.hexCenter(hex)
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
val path = polygon(corners)
drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
val number = state.numbers[hex]
if (number != 0) drawToken(number, centre, board, style, measurer)
}
}
/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */
private fun DrawScope.drawRolled(hexes: Set<Int>, board: BoardLayout, style: CatanStyle) {
for (hex in hexes) {
val centre = board.hexCenter(hex)
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) })
drawPath(path, color = style.rolled.copy(alpha = 0.28f))
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f))
drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f))
}
}
private fun DrawScope.drawToken(
number: Int,
at: Pt,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
val radius = board.radius * style.tokenRadius
drawCircle(color = style.token, radius = radius, center = at.offset())
drawCircle(
color = style.hexEdge,
radius = radius,
center = at.offset(),
style = Stroke(width = board.radius * 0.02f),
)
val hot = number == 6 || number == 8
drawCentered(
measurer = measurer,
text = number.toString(),
at = Pt(at.x, at.y - radius * 0.12f),
fontPx = board.radius * style.tokenText,
color = if (hot) style.tokenHotInk else style.tokenInk,
weight = if (hot) FontWeight.ExtraBold else FontWeight.Bold,
)
// One pip per thirty-sixth of the odds, so the likelier numbers read at a glance.
val pips = CatanBoard.numberOdds(number)
val pipRadius = radius * 0.075f
val gap = pipRadius * 2.6f
val startX = at.x - gap * (pips - 1) / 2f
for (i in 0 until pips) {
drawCircle(
color = if (hot) style.tokenHotInk else style.tokenInk,
radius = pipRadius,
center = Offset(startX + i * gap, at.y + radius * 0.52f),
)
}
}
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
// In Cities & Knights the robber sleeps, drawn faintly, until the barbarians first land.
val asleep = state.ck?.attacked == false
val tint = if (asleep) style.robber.copy(alpha = 0.3f) else style.robber
val rim = Color.White.copy(alpha = if (asleep) 0.25f else 0.7f)
val hex = board.hexCenter(state.robber)
// Beside the number token rather than on it, so the number stays readable.
val x = hex.x + board.radius * 0.5f
val y = hex.y + board.radius * 0.3f
val s = board.radius * style.robberSize
val pawn = Path().apply {
moveTo(x - s * 0.55f, y + s * 0.9f)
lineTo(x - s * 0.25f, y - s * 0.15f)
lineTo(x + s * 0.25f, y - s * 0.15f)
lineTo(x + s * 0.55f, y + s * 0.9f)
close()
}
drawPath(pawn, color = tint)
drawCircle(color = tint, radius = s * 0.38f, center = Offset(x, y - s * 0.42f))
drawPath(pawn, color = rim, style = Stroke(width = s * 0.07f))
drawCircle(
color = rim,
radius = s * 0.38f,
center = Offset(x, y - s * 0.42f),
style = Stroke(width = s * 0.07f),
)
}
// ---------------------------------------------------------------------------
// Harbours
// ---------------------------------------------------------------------------
private fun DrawScope.drawPorts(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
for (port in state.ports) {
val anchor = board.portAnchor(port.edge)
val (a, b) = board.edgeEnds(port.edge)
val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk
// Two piers from the marker to the vertices that can use the harbour.
for (end in listOf(a, b)) {
drawLine(
color = style.portMarker.copy(alpha = 0.9f),
start = anchor.offset(),
end = end.offset(),
strokeWidth = board.radius * 0.07f,
cap = StrokeCap.Round,
)
}
val radius = board.radius * style.portRadius
drawCircle(color = style.portMarker, radius = radius, center = anchor.offset())
drawCircle(
color = tint,
radius = radius,
center = anchor.offset(),
style = Stroke(width = board.radius * 0.07f),
)
// What the harbour trades: a picture of the resource, or "?" for any.
val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f)
val iconSize = radius * 0.5f
val resource = port.resource
if (resource != null) {
drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline)
} else {
drawCentered(
measurer = measurer,
text = "?",
at = iconAt,
fontPx = board.radius * style.portText * 1.5f,
color = style.portInk,
weight = FontWeight.ExtraBold,
)
}
drawCentered(
measurer = measurer,
text = "${port.ratio}:1",
at = Pt(anchor.x, anchor.y + radius * 0.5f),
fontPx = board.radius * style.portText,
color = style.portInk,
weight = FontWeight.Bold,
)
}
}
/**
* A small pictogram of [resource] centred on [at], about 2·[s] across: a tree,
* bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no
* image assets to ship or scale.
*/
private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) {
val outline = Stroke(width = s * 0.12f)
when (resource) {
Resource.LUMBER -> {
val crown = Path().apply {
moveTo(at.x, at.y - s)
lineTo(at.x + s * 0.8f, at.y + s * 0.3f)
lineTo(at.x - s * 0.8f, at.y + s * 0.3f)
close()
}
drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f))
drawPath(crown, color = fill)
drawPath(crown, color = line, style = outline)
}
Resource.BRICK -> {
val w = s * 0.9f
val h = s * 0.42f
for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) {
val top = at.y - h + row * h
val left = at.x - w + shift - w * 0.25f
for (col in 0..1) {
val x = left + col * w
drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h))
drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline)
}
}
}
Resource.WOOL -> {
for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) {
drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s))
}
drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f))
drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
}
Resource.GRAIN -> {
drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f)
for (i in 0..2) {
val y = at.y - s * 0.7f + i * s * 0.5f
for (side in listOf(-1f, 1f)) {
val kernel = Path().apply {
moveTo(at.x, y + s * 0.3f)
lineTo(at.x + side * s * 0.6f, y - s * 0.05f)
lineTo(at.x + side * s * 0.15f, y - s * 0.2f)
close()
}
drawPath(kernel, color = fill)
drawPath(kernel, color = line, style = Stroke(width = s * 0.08f))
}
}
}
Resource.ORE -> {
val peak = Path().apply {
moveTo(at.x - s, at.y + s * 0.8f)
lineTo(at.x - s * 0.3f, at.y - s * 0.6f)
lineTo(at.x + s * 0.1f, at.y + s * 0.05f)
lineTo(at.x + s * 0.45f, at.y - s * 0.3f)
lineTo(at.x + s, at.y + s * 0.8f)
close()
}
drawPath(peak, color = fill)
drawPath(peak, color = line, style = outline)
}
}
}
// ---------------------------------------------------------------------------
// Pieces
// ---------------------------------------------------------------------------
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (edge in 0 until CatanBoard.EDGE_COUNT) {
val owner = state.roads[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
// Pulled in from the ends so adjacent roads do not fuse into a blob.
val from = lerp(a, b, 0.12f)
val to = lerp(a, b, 0.88f)
val width = board.radius * style.roadWidth
drawLine(
color = style.pieceOutline,
start = from.offset(),
end = to.offset(),
strokeWidth = width * 1.45f,
cap = StrokeCap.Round,
)
drawLine(
color = style.seats[owner % style.seats.size],
start = from.offset(),
end = to.offset(),
strokeWidth = width,
cap = StrokeCap.Round,
)
}
}
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
val building = state.buildings[vertex] ?: continue
val at = board.vertex(vertex)
val fill = style.seats[building.owner % style.seats.size]
val path = when (building.type) {
BuildingType.SETTLEMENT -> housePath(at, board.radius * style.settlementSize)
BuildingType.CITY -> cityPath(at, board.radius * style.citySize * 0.8f)
}
drawPath(path, color = fill)
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.045f))
}
}
/** One gable: a settlement. [s] is its half-width. */
private fun housePath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s, at.y + s * 0.7f)
lineTo(at.x - s, at.y - s * 0.1f)
lineTo(at.x, at.y - s)
lineTo(at.x + s, at.y - s * 0.1f)
lineTo(at.x + s, at.y + s * 0.7f)
close()
}
/** Two gables, the taller on the left: a city. [s] is the unit its outline is scaled by. */
private fun cityPath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s, at.y + s * 0.7f)
lineTo(at.x - s, at.y - s * 0.5f)
lineTo(at.x - s * 0.45f, at.y - s * 1.1f)
lineTo(at.x + s * 0.1f, at.y - s * 0.5f)
lineTo(at.x + s * 0.1f, at.y - s * 0.1f)
lineTo(at.x + s * 0.55f, at.y - s * 0.55f)
lineTo(at.x + s * 1.1f, at.y - s * 0.1f)
lineTo(at.x + s * 1.1f, at.y + s * 0.7f)
close()
}
// ---------------------------------------------------------------------------
// Cities & Knights pieces
// ---------------------------------------------------------------------------
/** A dashed wall round each walled city, crenellated by the dashes. */
private fun DrawScope.drawWalls(state: CatanState, board: BoardLayout, style: CatanStyle) {
val walls = state.ck?.walls ?: return
for (vertex in walls) {
val at = board.vertex(vertex).offset()
val radius = board.radius * style.citySize * 1.25f
val dash = PathEffect.dashPathEffect(floatArrayOf(radius * 0.38f, radius * 0.22f))
drawCircle(
color = style.pieceOutline,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.13f, pathEffect = dash),
)
drawCircle(
color = style.wall,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.09f, pathEffect = dash),
)
}
}
/** A pennant in the track's colour flying over a city that carries its metropolis. */
private fun DrawScope.drawMetropolises(state: CatanState, board: BoardLayout, style: CatanStyle) {
val metropolises = state.ck?.metropolises ?: return
for ((track, at) in metropolises) {
val base = board.vertex(at.vertex)
val unit = board.radius * style.citySize
val poleTop = Pt(base.x + unit * 0.9f, base.y - unit * 2.0f)
val poleBottom = Pt(base.x + unit * 0.9f, base.y - unit * 0.4f)
drawLine(
color = style.pieceOutline,
start = poleBottom.offset(),
end = poleTop.offset(),
strokeWidth = board.radius * 0.05f,
)
val flag = Path().apply {
moveTo(poleTop.x, poleTop.y)
lineTo(poleTop.x + unit * 1.1f, poleTop.y + unit * 0.35f)
lineTo(poleTop.x, poleTop.y + unit * 0.7f)
close()
}
drawPath(flag, color = style.track.getValue(track))
drawPath(flag, color = style.pieceOutline, style = Stroke(width = board.radius * 0.035f))
}
}
/**
* Each knight is a shield in its owner's colour, marked with one, two or three
* dots for its strength. A sleeping knight is drawn faded; an awake one has a
* bright rim.
*/
private fun DrawScope.drawKnights(state: CatanState, board: BoardLayout, style: CatanStyle) {
val knights = state.ck?.knights ?: return
for (knight in knights) {
val at = board.vertex(knight.vertex)
val s = board.radius * style.knightSize
val seat = style.seats[knight.owner % style.seats.size]
val path = shieldPath(at, s)
if (knight.active) {
drawPath(path, color = style.knightReady, style = Stroke(width = s * 0.55f))
}
drawPath(path, color = if (knight.active) seat else seat.copy(alpha = 0.55f))
drawPath(path, color = style.pieceOutline, style = Stroke(width = s * 0.16f))
val pip = s * 0.14f
val gap = pip * 2.8f
val startX = at.x - gap * (knight.level - 1) / 2f
for (i in 0 until knight.level) {
drawCircle(
color = style.pieceOutline,
radius = pip,
center = Offset(startX + i * gap, at.y - s * 0.05f),
)
}
}
}
private fun shieldPath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s * 0.8f, at.y - s)
lineTo(at.x + s * 0.8f, at.y - s)
lineTo(at.x + s * 0.8f, at.y + s * 0.1f)
quadraticBezierTo(at.x + s * 0.8f, at.y + s * 0.9f, at.x, at.y + s * 1.25f)
quadraticBezierTo(at.x - s * 0.8f, at.y + s * 0.9f, at.x - s * 0.8f, at.y + s * 0.1f)
close()
}
/** A ring round each of the player's knights that can do something now. */
private fun DrawScope.drawKnightTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (vertex in targets.knights) {
drawCircle(
color = style.target,
radius = board.radius * style.knightSize * 1.9f,
center = board.vertex(vertex).offset(),
style = Stroke(width = board.radius * 0.07f),
)
}
}
/** The merchant: a coin in its controller's colour, beside the hex it sits on. */
private fun DrawScope.drawMerchant(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
val merchant = state.ck?.merchant ?: return
val hex = board.hexCenter(merchant.hex)
val at = Pt(hex.x - board.radius * 0.5f, hex.y + board.radius * 0.3f)
val r = board.radius * 0.2f
drawCircle(color = style.seats[merchant.seat % style.seats.size], radius = r, center = at.offset())
drawCircle(color = style.pieceOutline, radius = r, center = at.offset(), style = Stroke(width = r * 0.3f))
drawCentered(
measurer = measurer,
text = "M",
at = at,
fontPx = r * 1.3f,
color = style.pieceOutline,
weight = FontWeight.ExtraBold,
)
}
// ---------------------------------------------------------------------------
// Tap targets
// ---------------------------------------------------------------------------
private fun DrawScope.drawVertexTargets(
targets: BoardTargets,
selected: Int?,
board: BoardLayout,
style: CatanStyle,
) {
val radius = board.radius * style.targetRadius
for (vertex in targets.vertices) {
val at = board.vertex(vertex).offset()
drawCircle(color = style.target.copy(alpha = 0.85f), radius = radius, center = at)
drawCircle(
color = style.pieceOutline,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.04f),
)
}
if (selected != null) {
val at = board.vertex(selected).offset()
drawCircle(
color = style.targetSelected,
radius = radius * 1.6f,
center = at,
style = Stroke(width = board.radius * 0.09f),
)
}
}
private fun DrawScope.drawEdgeTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (edge in targets.edges) {
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.15f)
val to = lerp(a, b, 0.85f)
drawLine(
color = style.pieceOutline,
start = from.offset(),
end = to.offset(),
strokeWidth = board.radius * style.roadWidth * 1.5f,
cap = StrokeCap.Round,
)
drawLine(
color = style.target.copy(alpha = 0.9f),
start = from.offset(),
end = to.offset(),
strokeWidth = board.radius * style.roadWidth,
cap = StrokeCap.Round,
)
}
}
private fun DrawScope.drawHexTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (hex in targets.hexes) {
val centre = board.hexCenter(hex)
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.08f) })
drawPath(path, color = style.target.copy(alpha = 0.38f))
drawPath(path, color = style.target, style = Stroke(width = board.radius * 0.07f))
}
}
// ---------------------------------------------------------------------------
// Drawing helpers
// ---------------------------------------------------------------------------
private fun Pt.offset(): Offset = Offset(x, y)
private fun lerp(a: Pt, b: Pt, t: Float): Pt = Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
/** [p] moved [fraction] of the way toward [centre]. */
private fun insetToward(p: Pt, centre: Pt, fraction: Float): Pt = lerp(p, centre, fraction)
private fun polygon(points: List<Pt>): Path = Path().apply {
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
close()
}
private fun DrawScope.drawCentered(
measurer: TextMeasurer,
text: String,
at: Pt,
fontPx: Float,
color: Color,
weight: FontWeight,
) {
val layout = measurer.measure(
text = text,
style = TextStyle(fontSize = fontPx.toSp(), fontWeight = weight, color = color),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
)
}
@@ -0,0 +1,813 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.GoodsCounter
import org.prolibertate.games.game.catan.GoodsOffer
import org.prolibertate.games.game.catan.IMPROVEMENT_MAX
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.PickDeck
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.ProgressCard
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.catan.PromoteKnight
import org.prolibertate.games.game.catan.SpyTake
import org.prolibertate.games.game.catan.TakeAqueduct
import org.prolibertate.games.game.catan.Track
/*
* The Cities & Knights dialogs. Like the base game's, each is a stateless
* composable apart from the choice it is helping the player make, and what is
* allowed is always asked of the engine's legal list through CatanInteraction.
*/
// ---------------------------------------------------------------------------
// Shared pieces
// ---------------------------------------------------------------------------
/** A card tile with a minus and a plus, for choosing how many of a good. */
@Composable
private fun GoodCounter(good: Good, count: Int, max: Int, onChange: (Int) -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
GoodTile(good, count, enabled = max > 0 || count > 0)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedButton(
onClick = { onChange(count - 1) },
enabled = count > 0,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("−") }
OutlinedButton(
onClick = { onChange(count + 1) },
enabled = count < max,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("+") }
}
}
}
private fun Map<Good, Int>.asGoods(): List<Good> = Good.entries.flatMap { g -> List(this[g] ?: 0) { g } }
/** A row of the three track colours, used for decks and improvements. */
@Composable
private fun TrackChip(track: Track, style: CatanStyle = LocalCatanStyle.current) {
Box(
modifier = Modifier
.size(14.dp)
.clip(RoundedCornerShape(3.dp))
.background(style.track.getValue(track)),
)
}
// ---------------------------------------------------------------------------
// Choosing cards to give up
// ---------------------------------------------------------------------------
/**
* Pick exactly [quota] cards out of [held] — to discard on a seven or a saboteur,
* or to hand over for a wedding or a commercial harbour. Not dismissable: the
* game is waiting on it. [allowed] is the engine's structural check, so the
* confirm button only lights when the move would be accepted.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsPickDialog(
title: String,
intro: String,
held: Map<Good, Int>,
quota: Int,
confirmLabel: String,
selectable: (Good) -> Boolean,
allowed: (List<Good>) -> Boolean,
onConfirm: (List<Good>) -> Unit,
) {
var picked by remember { mutableStateOf(emptyMap<Good, Int>()) }
val selection = picked.asGoods()
AlertDialog(
onDismissRequest = {},
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("$intro (${selection.size} of $quota chosen).")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Good.entries.forEach { good ->
val have = held[good] ?: 0
if (have > 0 && selectable(good)) {
GoodCounter(
good = good,
count = picked[good] ?: 0,
max = if (selection.size < quota) have else (picked[good] ?: 0),
onChange = { n -> picked = picked + (good to n.coerceIn(0, have)) },
)
}
}
}
}
},
confirmButton = {
Button(onClick = { onConfirm(selection) }, enabled = allowed(selection)) { Text(confirmLabel) }
},
)
}
// ---------------------------------------------------------------------------
// City improvements
// ---------------------------------------------------------------------------
/** The three improvement tracks: where the player stands, what the next level costs, and what it earns. */
@Composable
fun CatanImproveDialog(
state: CatanState,
seat: Int,
legal: List<CatanMove>,
onBuy: (BuyImprovement) -> Unit,
onCancel: () -> Unit,
) {
val ck = state.ck ?: return
val buyable = CatanInteraction.improvable(legal)
AlertDialog(
onDismissRequest = onCancel,
title = { Text(CatanText.IMPROVE) },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Track.entries.forEach { track ->
val level = CatanCk.improvement(state, seat, track)
val have = ck.commodities[seat][track.commodity] ?: 0
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TrackChip(track)
Text(CatanText.track(track), fontWeight = FontWeight.Bold)
Text("level $level of $IMPROVEMENT_MAX", style = MaterialTheme.typography.labelMedium)
}
Text(CatanText.trackPower(track), style = MaterialTheme.typography.bodySmall)
if (level >= IMPROVEMENT_MAX) {
Text("Fully built.", style = MaterialTheme.typography.bodySmall)
} else {
val cost = CatanCk.improvementCost(state, seat, track)
Button(onClick = { onBuy(BuyImprovement(track)) }, enabled = track in buyable) {
Text(
"${CatanCk.improvementName(track, level + 1)}: $cost " +
"${CatanText.good(Good.of(track.commodity))} (you hold $have)",
)
}
if (level + 1 == org.prolibertate.games.game.catan.METROPOLIS_LEVEL ||
level + 1 == IMPROVEMENT_MAX
) {
Text(
if (level + 1 == IMPROVEMENT_MAX) {
"Level five takes a metropolis from a seat still on four."
} else {
"Level four earns the first metropolis in this track: two more points."
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
)
}
// ---------------------------------------------------------------------------
// Progress cards
// ---------------------------------------------------------------------------
/**
* The player's progress cards. A card with nothing to decide plays at once; one
* that names a seat, a card or dice asks for it in a second step; one that
* needs a tap on the board closes the dialog and arms the board (see [ArmedCard]).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanProgressDialog(
hand: List<ProgressInHand>,
seats: CatanSeats,
onPlay: (CatanMove) -> Unit,
onArm: (ArmedCard) -> Unit,
onCancel: () -> Unit,
) {
var chosen by remember { mutableStateOf<ProgressInHand?>(null) }
var target by remember { mutableStateOf<Int?>(null) }
var yellow by remember { mutableStateOf(1) }
var red by remember { mutableStateOf(1) }
val entry = chosen
AlertDialog(
onDismissRequest = onCancel,
title = { Text(entry?.let { CatanText.progress(it.card.kind) } ?: "Progress cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (entry == null) {
if (hand.isEmpty()) Text("You hold no progress cards.")
hand.forEach { card ->
ProgressRow(card) { picked ->
val kind = picked.card.kind
when {
kind in CatanInteraction.BOARD_CARDS -> onArm(ArmedCard(kind, picked.plays))
picked.plays.size == 1 && picked.plays[0].isBare() -> onPlay(picked.plays[0])
else -> {
chosen = picked
target = null
}
}
}
}
} else {
Text(CatanText.progressHelp(entry.card.kind), style = MaterialTheme.typography.bodySmall)
val kind = entry.card.kind
val plays = entry.plays
when {
kind == ProgressType.ALCHEMIST -> {
Text("Production die (yellow)", fontWeight = FontWeight.Bold)
DiePicker(yellow) { yellow = it }
Text("Progress die (red)", fontWeight = FontWeight.Bold)
DiePicker(red) { red = it }
}
kind == ProgressType.DESERTER || kind == ProgressType.SPY -> {
Text("Choose a player:", fontWeight = FontWeight.Bold)
plays.mapNotNull { it.target }.distinct().forEach { seat ->
OutlinedButton(
onClick = { plays.firstOrNull { it.target == seat }?.let(onPlay) },
modifier = Modifier.fillMaxWidth(),
) { Text(seats.label(seat)) }
}
}
kind == ProgressType.MASTER_MERCHANT -> {
Text("Choose a player with more points:", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
plays.mapNotNull { it.target }.distinct().forEach { seat ->
if (target == seat) {
FilledTonalButton(onClick = { target = seat }) { Text(seats.label(seat)) }
} else {
OutlinedButton(onClick = { target = seat }) { Text(seats.label(seat)) }
}
}
}
val seat = target
if (seat != null) {
Text("Take:", fontWeight = FontWeight.Bold)
plays.filter { it.target == seat }.forEach { play ->
OutlinedButton(
onClick = { onPlay(play) },
modifier = Modifier.fillMaxWidth(),
) { Text(CatanText.goods(play.goods.orEmpty())) }
}
}
}
else -> {
val label = when (kind) {
ProgressType.COMMERCIAL_HARBOR -> "Offer which resource?"
ProgressType.MERCHANT_FLEET -> "Trade which card two for one?"
ProgressType.RESOURCE_MONOPOLY -> "Name a resource:"
else -> "Name a commodity:"
}
Text(label, fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
plays.mapNotNull { it.good }.distinct().forEach { good ->
GoodTile(
good = good,
count = 0,
onClick = { plays.firstOrNull { it.good == good }?.let(onPlay) },
)
}
}
}
}
}
}
},
confirmButton = {
if (entry != null && entry.card.kind == ProgressType.ALCHEMIST) {
val play = entry.plays.firstOrNull { it.dice == listOf(yellow, red) }
Button(onClick = { play?.let(onPlay) }, enabled = play != null) { Text("Play") }
}
},
dismissButton = {
TextButton(
onClick = {
if (entry == null) {
onCancel()
} else {
chosen = null
target = null
}
},
) { Text(if (entry == null) CatanText.CANCEL else "Back") }
},
)
}
private fun PlayProgress.isBare(): Boolean =
target == null && vertex == null && edge == null && hexA == null && hexB == null &&
good == null && dice == null && goods == null
@Composable
private fun DiePicker(selected: Int, onPick: (Int) -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(1..6).forEach { n ->
if (n == selected) {
FilledTonalButton(
onClick = { onPick(n) },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(40.dp),
) { Text("$n") }
} else {
OutlinedButton(
onClick = { onPick(n) },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(40.dp),
) { Text("$n") }
}
}
}
}
@Composable
private fun ProgressRow(entry: ProgressInHand, onChoose: (ProgressInHand) -> Unit) {
val kind = entry.card.kind
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrackChip(kind.track)
Column(modifier = Modifier.weight(1f)) {
Text(CatanText.progress(kind), fontWeight = FontWeight.Bold)
Text(CatanText.progressHelp(kind), style = MaterialTheme.typography.bodySmall)
}
if (entry.playable) {
Button(onClick = { onChoose(entry) }) { Text("Play") }
} else {
Text("Not now", style = MaterialTheme.typography.labelSmall)
}
}
}
// ---------------------------------------------------------------------------
// Knights
// ---------------------------------------------------------------------------
/** What the knight on one crossing can do: wake, promote, move, chase the robber. */
@Composable
fun CatanKnightDialog(
state: CatanState,
vertex: Int,
actions: List<CatanMove>,
onDo: (CatanMove) -> Unit,
onMove: (Int) -> Unit,
onCancel: () -> Unit,
) {
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
AlertDialog(
onDismissRequest = onCancel,
title = { Text(knight?.let { CatanText.knightName(it.level) } ?: "Knight") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (knight != null) {
val mood = when {
!knight.active -> "Asleep. Waking it costs ${CatanText.ACTIVATE_COST}."
knight.activatedTurn == state.turnNumber -> "Awake, but it woke this turn and cannot act yet."
else -> "Awake and ready. It counts against the barbarians."
}
Text(mood, style = MaterialTheme.typography.bodySmall)
}
actions.forEach { move ->
when (move) {
is ActivateKnight ->
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Wake (${CatanText.ACTIVATE_COST})")
}
is PromoteKnight ->
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Promote (${CatanText.KNIGHT_COST})")
}
is MoveKnight ->
OutlinedButton(onClick = { onMove(move.from) }, modifier = Modifier.fillMaxWidth()) {
Text("Move along your roads")
}
is ChaseRobber ->
OutlinedButton(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Chase away the robber")
}
else -> Unit
}
}
if (actions.isEmpty()) Text("Nothing to do with it right now.")
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
)
}
// ---------------------------------------------------------------------------
// Chores that are answered in a dialog
// ---------------------------------------------------------------------------
/** Choose a colour of progress deck to draw from. */
@Composable
fun CatanPickDeckDialog(legal: List<CatanMove>, onPick: (PickDeck) -> Unit) {
val open = legal.filterIsInstance<PickDeck>()
AlertDialog(
onDismissRequest = {},
title = { Text("Draw a progress card") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Catan held. You tied for the strongest defence, so choose any deck.")
open.forEach { pick ->
Button(onClick = { onPick(pick) }, modifier = Modifier.fillMaxWidth()) {
TrackChip(pick.track)
Text(" ${CatanText.track(pick.track)}")
}
}
}
},
confirmButton = {},
)
}
/** The hand is over its limit: give one progress card back. */
@Composable
fun CatanDropProgressDialog(hand: List<ProgressCard>, onDrop: (DropProgress) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Too many progress cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("You may keep four. Choose one to give back.")
hand.forEach { card ->
OutlinedButton(onClick = { onDrop(DropProgress(card.id)) }, modifier = Modifier.fillMaxWidth()) {
TrackChip(card.kind.track)
Text(" ${CatanText.progress(card.kind)}")
}
}
}
},
confirmButton = {},
)
}
/** The aqueduct pays: take any one resource. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanAqueductDialog(legal: List<CatanMove>, onTake: (TakeAqueduct) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Aqueduct") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("The dice paid you nothing. Take any resource.")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
legal.filterIsInstance<TakeAqueduct>().forEach { take ->
ResourceTile(resource = take.resource, count = 1, onClick = { onTake(take) })
}
}
}
},
confirmButton = {},
)
}
/** A spy: these are the progress cards the other player holds; take one. */
@Composable
fun CatanSpyDialog(cards: List<ProgressCard>, legal: List<CatanMove>, onTake: (SpyTake) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Spy") },
text = {
Column(
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Choose a card to take.")
cards.forEach { card ->
val take = SpyTake(card.id)
OutlinedButton(
onClick = { onTake(take) },
enabled = take in legal,
modifier = Modifier.fillMaxWidth(),
) {
TrackChip(card.kind.track)
Text(" ${CatanText.progress(card.kind)}")
}
}
}
},
confirmButton = {},
)
}
// ---------------------------------------------------------------------------
// Trading in goods
// ---------------------------------------------------------------------------
private enum class GoodsTab { BANK, PLAYER }
/**
* Trade with the bank at the best ratio each card has, or offer cards —
* commodities included — to one other player.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsTradeDialog(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (GoodsBankTrade) -> Unit,
onOffer: (OfferGoods) -> Unit,
onCancel: () -> Unit,
) {
var tab by remember { mutableStateOf(GoodsTab.BANK) }
var bankGive by remember { mutableStateOf<Good?>(null) }
var everyone by remember { mutableStateOf(true) }
var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Good, Int>()) }
var get by remember { mutableStateOf(emptyMap<Good, Int>()) }
val held = CatanCk.goodsOf(state, seat)
AlertDialog(
onDismissRequest = onCancel,
title = { Text(CatanText.TRADE) },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GoodsTabButton(CatanText.TRADE_WITH_BANK, tab == GoodsTab.BANK) { tab = GoodsTab.BANK }
GoodsTabButton(CatanText.TRADE_WITH_PLAYER, tab == GoodsTab.PLAYER) { tab = GoodsTab.PLAYER }
}
if (tab == GoodsTab.BANK) {
val trades = CatanInteraction.goodsBankTrades(legal)
if (trades.isEmpty()) {
Text("You do not hold enough of any one card to trade with the bank.")
} else {
Text("Give:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.keys.forEach { good ->
val ratio = CatanCk.bankRatio(state, seat, good)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
GoodTile(good, ratio, selected = bankGive == good, onClick = { bankGive = good })
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
}
}
}
val chosen = bankGive
if (chosen != null && chosen in trades) {
Text("Get one:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.getValue(chosen).forEach { good ->
GoodTile(good, 1, onClick = { onBank(GoodsBankTrade(chosen, good)) })
}
}
}
}
} else {
Text("Offer to:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
GoodsTabButton(CatanText.EVERYONE, everyone) {
everyone = true
partner = null
}
(0 until state.playerCount).filter { it != seat }.forEach { other ->
GoodsTabButton(seats.label(other), !everyone && partner == other) {
everyone = false
partner = other
}
}
}
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
GoodCounters(give, { held[it] ?: 0 }) { give = it }
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
GoodCounters(get, { 9 }) { get = it }
}
}
},
confirmButton = {
if (tab == GoodsTab.PLAYER) {
val to = if (everyone) null else partner
val giveCards = give.asGoods()
val getCards = get.asGoods()
val allowed = (everyone || partner != null) &&
CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards)
Button(
onClick = { onOffer(OfferGoods(to, giveCards, getCards)) },
enabled = allowed,
) { Text(CatanText.SEND_OFFER) }
}
},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GoodCounters(counts: Map<Good, Int>, limit: (Good) -> Int, onChange: (Map<Good, Int>) -> Unit) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Good.entries.forEach { good ->
GoodCounter(
good = good,
count = counts[good] ?: 0,
max = limit(good),
onChange = { n -> onChange(counts + (good to n.coerceIn(0, limit(good)))) },
)
}
}
}
@Composable
private fun GoodsTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
if (selected) {
FilledTonalButton(onClick = onClick) { Text(label) }
} else {
OutlinedButton(onClick = onClick) { Text(label) }
}
}
/**
* A goods offer has reached the local player: accept it, decline it, or answer
* with different terms. The counter's two sides start as the offer turned around.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsTradeResponseDialog(
state: CatanState,
seat: Int,
offer: GoodsOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
onCounter: (CounterGoods) -> Unit,
) {
var countering by remember(offer) { mutableStateOf(false) }
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
AlertDialog(
onDismissRequest = {},
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to " +
"${if (offer.to == null) "the table" else "you"} for ${CatanText.goods(offer.get)}",
)
if (countering) {
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
GoodCounters(give, { CatanCk.goodsOf(state, seat)[it] ?: 0 }) { give = it }
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
GoodCounters(get, { CatanCk.goodsOf(state, offer.by)[it] ?: 0 }) { get = it }
} else if (!canAccept) {
Text(
"You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
if (countering) {
val move = CounterGoods(give.asGoods(), get.asGoods())
Button(
onClick = { onCounter(move) },
enabled = CatanInteraction.goodsCounterAllowed(state, seat, move.give, move.get),
) { Text(CatanText.SEND_COUNTER) }
} else {
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
}
},
dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (countering) {
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
} else {
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
}
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
}
},
)
}
/** The offerer's choice once nobody accepted a goods offer: take a counter-offer, or none. */
@Composable
fun CatanGoodsCounterChoiceDialog(
counters: List<GoodsCounter>,
seats: CatanSeats,
onTake: (AcceptCounter) -> Unit,
onNoDeal: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("Counter-offers") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (counters.isEmpty()) Text("None of the counter-offers can be met any more.")
counters.forEach { counter ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"${seats.label(counter.by)} would give ${CatanText.goods(counter.give)} " +
"for ${CatanText.goods(counter.get)}",
)
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
}
}
}
},
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
)
}
@@ -0,0 +1,626 @@
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") }
},
)
}
// ---------------------------------------------------------------------------
// 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,123 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanBoard
import kotlin.math.sqrt
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
data class Pt(val x: Float, val y: Float)
/**
* Where the island sits on screen: the one place the engine's integer lattice
* (see [CatanBoard]) becomes pixels, and pixels become "which vertex / edge /
* hex did the player tap".
*
* Pure and Android-free, so it is unit-tested (`CatanGeometryTest`). The drawing
* code ([CatanBoardView]) and the tap handling both go through this, which is
* what keeps what is drawn and what is tappable from ever disagreeing.
*
* Lattice to pixel: a hex of circumradius `R` is pointy-top, so one lattice x-unit
* is `R·√3/2` and one lattice y-unit is `R/2`, and lattice +Y points up the board
* while pixel +y points down.
*/
class BoardLayout(
val width: Float,
val height: Float,
/** Circumradius of one hex, in pixels. */
val radius: Float,
) {
private val centreX = width / 2f
private val centreY = height / 2f
private val unitX = radius * SQRT3 / 2f
private val unitY = radius / 2f
fun point(latticeX: Int, latticeY: Int): Pt =
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
fun hexCenter(hex: Int): Pt {
val (lx, ly) = CatanBoard.hexCenter(hex)
return point(lx, ly)
}
fun vertex(vertex: Int): Pt {
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
return point(lx, ly)
}
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) }
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return vertex(a) to vertex(b)
}
fun edgeMidpoint(edge: Int): Pt {
val (a, b) = edgeEnds(edge)
return Pt((a.x + b.x) / 2f, (a.y + b.y) / 2f)
}
/**
* Where a harbour's marker is drawn: just off the coast, straight out from
* the middle of its edge. The marker is joined to the edge's two vertices by
* the board view, which is what shows which settlements can use it.
*/
fun portAnchor(edge: Int): Pt {
val mid = edgeMidpoint(edge)
val dx = mid.x - centreX
val dy = mid.y - centreY
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
}
/** The nearest of [among] within [reach] pixels of [at], or null. */
fun nearestVertex(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { vertex(it) }
fun nearestEdge(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { edgeMidpoint(it) }
fun nearestHex(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { hexCenter(it) }
private fun nearest(at: Pt, reach: Float, among: Collection<Int>, position: (Int) -> Pt): Int? {
var best: Int? = null
var bestDistance = reach * reach
for (candidate in among) {
val p = position(candidate)
val dx = p.x - at.x
val dy = p.y - at.y
val d = dx * dx + dy * dy
if (d <= bestDistance) {
best = candidate
bestDistance = d
}
}
return best
}
}
object CatanGeometry {
/** Lattice extents of the island: x spans -5..5, y spans -8..8. */
private const val LATTICE_X_UNITS = 10
private const val LATTICE_Y_UNITS = 16
/** Empty space round the island, in hex radii, for the sea and the harbours. */
const val MARGIN = 1.75f
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
/** Width ÷ height of the board including its margin, for sizing its box. */
val ASPECT: Float = boardWidthInRadii / boardHeightInRadii
/** The largest layout that fits [width] × [height] pixels. */
fun fit(width: Float, height: Float): BoardLayout {
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii)
return BoardLayout(width, height, radius)
}
}
private const val SQRT3 = 1.7320508f
/** How far past the coast a harbour marker sits, in hex radii. Far enough that its piers are clearly visible. */
internal const val PORT_OFFSET = 1.0f
@@ -0,0 +1,578 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.Chore
import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DevCard
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.GoodsCounter
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.HireKnight
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis
import org.prolibertate.games.game.catan.TradeCounter
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.PlayYearOfPlenty
import org.prolibertate.games.game.catan.ProgressCard
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.catan.PromoteKnight
import org.prolibertate.games.game.catan.RelocateKnight
import org.prolibertate.games.game.catan.RemoveKnight
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.Track
/**
* What a player can ask to build by tapping the board. Wall and Knight exist
* only in Cities & Knights; the base game's build menu lists the first three.
*/
enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
companion object {
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
}
}
/**
* The parts of the board the player may tap right now. Everything else is inert.
* [knights] are crossings holding one of the player's own knights that can do
* something this turn: tapping one opens its menu.
*/
data class BoardTargets(
val vertices: Set<Int> = emptySet(),
val edges: Set<Int> = emptySet(),
val hexes: Set<Int> = emptySet(),
val knights: Set<Int> = emptySet(),
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
val idleKnights: Set<Int> = emptySet(),
) {
val isEmpty: Boolean
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() && idleKnights.isEmpty()
/** Every crossing a tap can land on. */
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
}
/**
* A progress card that is waiting for a tap on the board: [plays] are every play
* of it the engine accepts, and the player's tap picks one. [firstHex] is the
* first of the two hexes an Inventor swaps, once tapped.
*/
data class ArmedCard(val kind: ProgressType, val plays: List<PlayProgress>, val firstHex: Int? = null)
/** One progress card in the local player's hand, and the plays the engine allows for it. */
data class ProgressInHand(val card: ProgressCard, val plays: List<PlayProgress>) {
val playable: Boolean get() = plays.isNotEmpty()
}
/** What a tap on the board amounts to. */
sealed interface TapOutcome {
/** Nothing: the tap landed somewhere that is not a legal target. */
data object Ignored : TapOutcome
/** Send this move to the table. */
data class Submit(val move: CatanMove) : TapOutcome
/** Setup: remember this settlement; the road that goes with it is tapped next. */
data class PickSetupVertex(val vertex: Int) : TapOutcome
/** The robber's hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
/** One of the player's own knights was tapped: open its menu. */
data class PickKnight(val vertex: Int) : TapOutcome
/** An Inventor's first hex was tapped; the second completes the swap. */
data class PickFirstHex(val hex: Int) : TapOutcome
}
/**
* One development card in the local player's hand, and what can be done with it
* right now. [plays] is empty when it cannot be played this turn (bought this
* turn, a card has already been played, a victory-point card, or it is not the
* player's turn).
*/
data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
val playable: Boolean get() = plays.isNotEmpty()
}
/**
* The rules of *tapping*: given the game state, the moves the engine says are
* legal, and what the player has selected, what can be tapped and what a tap does.
*
* Pure functions with no Android and no Compose, so the whole interaction model is
* unit-tested (`CatanInteractionTest`). The screen holds the two bits of UI state
* ([BuildKind] and the chosen setup settlement) and passes them in; it contains no
* game rules of its own. **Anything that decides whether a tap is allowed belongs
* here, never in a composable** — the engine's [legal] list is the single source
* of truth, so the UI can never offer a move the rules would refuse.
*/
object CatanInteraction {
// --- Board ---------------------------------------------------------------
fun targets(
state: CatanState,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
movingKnight: Int? = null,
armed: ArmedCard? = null,
/** The local seat, so its sleeping knights can be tapped for their menu too. */
localSeat: Int? = null,
): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
return if (setupVertex == null) {
BoardTargets(vertices = setup.map { it.vertex }.toSet())
} else {
BoardTargets(edges = setup.filter { it.vertex == setupVertex }.map { it.edge }.toSet())
}
}
val robber = legal.filterIsInstance<MoveRobber>()
if (robber.isNotEmpty()) {
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
}
// Cities & Knights: answers to a queued chore are taps on the board.
val chore = choreVertices(legal)
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
if (armed != null) return armedTargets(armed)
if (movingKnight != null) {
return BoardTargets(
vertices = legal.filterIsInstance<MoveKnight>().filter { it.from == movingKnight }.map { it.to }.toSet(),
)
}
return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
BuildKind.CITY ->
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
BuildKind.WALL ->
BoardTargets(vertices = legal.filterIsInstance<BuildWall>().map { it.vertex }.toSet())
BuildKind.KNIGHT ->
BoardTargets(vertices = legal.filterIsInstance<HireKnight>().map { it.vertex }.toSet())
null -> {
val ready = knightsWithActions(legal)
val mine = if (localSeat != null && legal.isNotEmpty()) {
state.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
} else {
emptySet()
}
BoardTargets(knights = ready, idleKnights = mine - ready)
}
}
}
/** The crossings a queued chore is asking the player to tap, from the legal list. */
private fun choreVertices(legal: List<CatanMove>): Set<Int> = buildSet {
legal.forEach { move ->
when (move) {
is PillageCity -> add(move.vertex)
is PlaceMetropolis -> add(move.vertex)
is RelocateKnight -> add(move.to)
is RemoveKnight -> add(move.vertex)
is PlaceKnight -> add(move.vertex)
else -> Unit
}
}
// A Smith's promotions are knights to tap, while the chore is open.
if (legal.any { it is EndSmith }) {
legal.filterIsInstance<PromoteKnight>().forEach { add(it.vertex) }
}
}
private fun choreMove(legal: List<CatanMove>, vertex: Int): CatanMove? = legal.firstOrNull { move ->
when (move) {
is PillageCity -> move.vertex == vertex
is PlaceMetropolis -> move.vertex == vertex
is RelocateKnight -> move.to == vertex
is RemoveKnight -> move.vertex == vertex
is PlaceKnight -> move.vertex == vertex
is PromoteKnight -> legal.any { it is EndSmith } && move.vertex == vertex
else -> false
}
}
// --- Cities & Knights: knights ---------------------------------------------
/** Crossings of the player's knights that have an action available. */
fun knightsWithActions(legal: List<CatanMove>): Set<Int> = buildSet {
legal.forEach { move ->
when (move) {
is ActivateKnight -> add(move.vertex)
is MoveKnight -> add(move.from)
is ChaseRobber -> add(move.vertex)
is PromoteKnight -> if (legal.none { it is EndSmith }) add(move.vertex)
else -> Unit
}
}
}
/** What the knight on [vertex] can do right now, in the order its menu lists them. */
fun knightActions(legal: List<CatanMove>, vertex: Int): List<CatanMove> =
legal.filter { move ->
when (move) {
is ActivateKnight -> move.vertex == vertex
is PromoteKnight -> move.vertex == vertex && legal.none { it is EndSmith }
is ChaseRobber -> move.vertex == vertex
else -> false
}
} + listOfNotNull(legal.firstOrNull { it is MoveKnight && it.from == vertex })
fun canEndSmith(legal: List<CatanMove>): Boolean = legal.any { it is EndSmith }
// --- Cities & Knights: progress cards --------------------------------------
/** [seat]'s progress cards, each with the plays the engine currently allows. */
fun progressHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<ProgressInHand> =
state.ck?.progressHands?.getOrNull(seat).orEmpty().map { card ->
ProgressInHand(card, legal.filterIsInstance<PlayProgress>().filter { it.card == card.id })
}.sortedWith(compareBy({ it.card.kind.track.ordinal }, { it.card.kind.ordinal }, { it.card.id }))
/** The cards that are played by tapping the board, not by choosing in a dialog. */
val BOARD_CARDS: Set<ProgressType> = setOf(
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.MERCHANT,
ProgressType.DIPLOMAT, ProgressType.INTRIGUE, ProgressType.INVENTOR,
)
private fun armedTargets(armed: ArmedCard): BoardTargets = when (armed.kind) {
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.INTRIGUE ->
BoardTargets(vertices = armed.plays.mapNotNull { it.vertex }.toSet())
ProgressType.DIPLOMAT -> BoardTargets(edges = armed.plays.mapNotNull { it.edge }.toSet())
ProgressType.MERCHANT -> BoardTargets(hexes = armed.plays.mapNotNull { it.hexA }.toSet())
ProgressType.INVENTOR -> {
val first = armed.firstHex
val all = armed.plays.flatMap { listOfNotNull(it.hexA, it.hexB) }.toSet()
if (first == null) {
BoardTargets(hexes = all)
} else {
BoardTargets(
hexes = armed.plays
.filter { it.hexA == first || it.hexB == first }
.flatMap { listOfNotNull(it.hexA, it.hexB) }
.filter { it != first }
.toSet(),
)
}
}
else -> BoardTargets()
}
/** The improvement tracks the player can buy a level of right now. */
fun improvable(legal: List<CatanMove>): Set<Track> =
legal.filterIsInstance<BuyImprovement>().map { it.track }.toSet()
// --- Cities & Knights: goods ----------------------------------------------
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
legal.filterIsInstance<GoodsBankTrade>()
.groupBy({ it.give }, { it.get })
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
.toSortedMap(compareBy<Good> { it.ordinal })
fun goodsOfferAllowed(
state: CatanState,
seat: Int,
to: Int?,
give: List<Good>,
get: List<Good>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get))
/** Whether [seat] may answer the goods offer on the table with [give] for [get]. */
fun goodsCounterAllowed(state: CatanState, seat: Int, give: List<Good>, get: List<Good>): Boolean =
give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, CounterGoods(give, get))
/** The goods counter-offers the offerer may take right now, in the order they came in. */
fun goodsCounterChoices(state: CatanState, legal: List<CatanMove>): List<GoodsCounter> {
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
return state.ck?.goodsOffer?.counters.orEmpty().filter { it.by in takeable }
}
/** The chore at the front of the queue, if the table is waiting on one. */
fun chore(state: CatanState): Chore? =
if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null
fun onVertexTap(
vertex: Int,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
movingKnight: Int? = null,
armed: ArmedCard? = null,
/** The local player's knights, which can be tapped for their menu even with nothing to do. */
ownKnights: Set<Int> = emptySet(),
): TapOutcome {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
// Re-picking is allowed until the road is chosen, so a misplaced
// tap does not commit the settlement.
return if (setup.any { it.vertex == vertex }) {
TapOutcome.PickSetupVertex(vertex)
} else {
TapOutcome.Ignored
}
}
choreMove(legal, vertex)?.let { return TapOutcome.Submit(it) }
if (armed != null) {
val play = armed.plays.firstOrNull { it.vertex == vertex } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
if (movingKnight != null) {
val move = MoveKnight(movingKnight, vertex)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
val move: CatanMove = when (building) {
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
BuildKind.CITY -> BuildCity(vertex)
BuildKind.WALL -> BuildWall(vertex)
BuildKind.KNIGHT -> HireKnight(vertex)
else -> return if (building == null && (vertex in knightsWithActions(legal) || vertex in ownKnights)) {
TapOutcome.PickKnight(vertex)
} else {
TapOutcome.Ignored
}
}
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onEdgeTap(
edge: Int,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
armed: ArmedCard? = null,
): TapOutcome {
if (legal.any { it is SetupPlace }) {
if (setupVertex == null) return TapOutcome.Ignored
val move = SetupPlace(setupVertex, edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
if (armed != null) {
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
// Free roads need no build mode; a paid road does.
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
if (!canBuild) return TapOutcome.Ignored
val move = BuildRoad(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard? = null): TapOutcome {
if (armed != null) return onArmedHexTap(hex, legal, armed)
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
return when {
moves.isEmpty() -> TapOutcome.Ignored
moves.size == 1 -> TapOutcome.Submit(moves.first())
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
}
}
private fun onArmedHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard): TapOutcome {
if (armed.kind == ProgressType.INVENTOR) {
val first = armed.firstHex ?: return if (hex in armedTargets(armed).hexes) {
TapOutcome.PickFirstHex(hex)
} else {
TapOutcome.Ignored
}
val play = armed.plays.firstOrNull {
(it.hexA == first && it.hexB == hex) || (it.hexA == hex && it.hexB == first)
} ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
val play = armed.plays.firstOrNull { it.hexA == hex } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
// --- Action bar ------------------------------------------------------------
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
/**
* The hexes that paid out for this turn's roll, so the board can light them.
* Empty until the turn's roll has happened (the dice left over from the last
* turn are not this turn's), on a seven, and for the hex the robber sits on.
*/
fun rolledHexes(state: CatanState): Set<Int> {
if (state.lastDice.size != 2) return emptySet()
if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST ||
state.phase == CatanPhase.SETUP_SECOND
) {
return emptySet()
}
val total = state.lastDice.sum()
if (total == 7) return emptySet()
return (0 until CatanBoard.HEX_COUNT)
.filter { state.numbers[it] == total && it != state.robber }
.toSet()
}
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
fun canBuyDevCard(legal: List<CatanMove>): Boolean = legal.any { it is BuyDevCard }
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
}
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
legal.any { it is AcceptTrade } to legal.any { it is DeclineTrade }
// --- Development cards -----------------------------------------------------
/** [seat]'s development cards, each with the plays the engine currently allows. */
fun devHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<DevCardInHand> =
state.devHands.getOrNull(seat).orEmpty().map { card ->
DevCardInHand(card, legal.filter { playOf(it) == card.id })
}.sortedWith(compareBy({ it.card.type.ordinal }, { it.card.id }))
private fun playOf(move: CatanMove): Int? = when (move) {
is PlayKnight -> move.card
is PlayRoadBuilding -> move.card
is PlayMonopoly -> move.card
is PlayYearOfPlenty -> move.card
else -> null
}
/** The Monopoly moves for one card, by the resource each would name. */
fun monopolyChoices(plays: List<CatanMove>): Map<Resource, PlayMonopoly> =
plays.filterIsInstance<PlayMonopoly>().associateBy { it.resource }
/** The Year of Plenty move that takes exactly [take], if the engine allows it. */
fun plentyMove(plays: List<CatanMove>, take: List<Resource>): PlayYearOfPlenty? {
val want = take.groupingBy { it }.eachCount()
return plays.filterIsInstance<PlayYearOfPlenty>().firstOrNull {
it.take.groupingBy { r -> r }.eachCount() == want
}
}
/**
* Resources a Year of Plenty can still be asked for, given what is already
* [picked]: those that complete some take the engine allows. Empty once the
* two cards are chosen.
*/
fun plentyAvailable(plays: List<CatanMove>, picked: List<Resource>): Set<Resource> {
val chosen = picked.groupingBy { it }.eachCount()
val available = mutableSetOf<Resource>()
for (play in plays.filterIsInstance<PlayYearOfPlenty>()) {
if (!chosen.all { (resource, n) -> play.take.count { it == resource } >= n }) continue
val remaining = play.take.toMutableList()
picked.forEach { remaining.remove(it) }
available += remaining
}
return available
}
// --- Discarding -----------------------------------------------------------
/** How many cards a player holding [held] must discard on a seven. */
fun discardQuota(held: Int): Int = held / 2
/** The discard move that gives up exactly [selection], if the engine allows it. */
fun discardMove(legal: List<CatanMove>, selection: List<Resource>): Discard? {
val want = selection.groupingBy { it }.eachCount()
return legal.filterIsInstance<Discard>().firstOrNull {
it.cards.groupingBy { r -> r }.eachCount() == want
}
}
// --- Trading ---------------------------------------------------------------
/** Bank trades on offer: what can be given, and for what. */
fun bankTrades(legal: List<CatanMove>): Map<Resource, List<Resource>> =
legal.filterIsInstance<MaritimeTrade>()
.groupBy({ it.give }, { it.get })
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
.toSortedMap(compareBy<Resource> { it.ordinal })
/** What it costs [seat] to give one [resource] to the bank: 4, 3 or 2 cards. */
fun bankRatio(state: CatanState, seat: Int, resource: Resource): Int =
CatanRules.bestPortRatio(state, seat, resource)
/**
* Whether [seat] can offer [give] for [get] to [to] right now. Uses the
* engine's own structural check, since offers are not in the enumerated
* move list.
*/
fun offerAllowed(
state: CatanState,
seat: Int,
to: Int?,
give: List<Resource>,
get: List<Resource>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get))
/** Whether [seat] may answer the offer on the table with [give] for [get]. */
fun counterAllowed(state: CatanState, seat: Int, give: List<Resource>, get: List<Resource>): Boolean =
give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, CounterTrade(give, get))
/** The counter-offers the offerer may take right now, in the order they came in. */
fun counterChoices(state: CatanState, legal: List<CatanMove>): List<TradeCounter> {
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
return state.pendingTrade?.counters.orEmpty().filter { it.by in takeable }
}
}
@@ -0,0 +1,634 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.EventFace
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Track
/*
* Stateless building blocks for the Katan screen.
*
* Every composable here takes plain values and reports events through lambdas;
* none of them holds game state or touches the controller, so each can be
* restyled, re-laid-out or replaced on its own. Colours and proportions come
* from [CatanStyle] and wording from [CatanText].
*/
// ---------------------------------------------------------------------------
// Resource tile
// ---------------------------------------------------------------------------
/**
* One resource as a coloured tile with its count. The hand, the discard picker
* and the trade pickers all use this, so a resource looks the same everywhere.
*/
@Composable
fun ResourceTile(
resource: Resource,
count: Int,
modifier: Modifier = Modifier,
selected: Boolean = false,
enabled: Boolean = true,
style: CatanStyle = LocalCatanStyle.current,
onClick: (() -> Unit)? = null,
) {
val shape = RoundedCornerShape(10.dp)
Column(
modifier = modifier
.defaultMinSize(minWidth = 56.dp)
.clip(shape)
.background(style.resource.getValue(resource).copy(alpha = if (enabled) 1f else 0.4f))
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) Color.White else Color(0x33000000),
shape = shape,
)
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 6.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = count.toString(),
color = style.resourceInk,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Text(
text = CatanText.resource(resource),
color = style.resourceInk,
fontSize = 11.sp,
maxLines = 1,
)
}
}
/**
* One card type — a resource or a commodity — as a coloured tile with its count.
* Cities & Knights hands, discards and trades use this so commodities look the
* same everywhere.
*/
@Composable
fun GoodTile(
good: Good,
count: Int,
modifier: Modifier = Modifier,
selected: Boolean = false,
enabled: Boolean = true,
style: CatanStyle = LocalCatanStyle.current,
onClick: (() -> Unit)? = null,
) {
val resource = good.resource
if (resource != null) {
ResourceTile(resource, count, modifier, selected, enabled, style, onClick)
return
}
val commodity = good.commodity!!
val shape = RoundedCornerShape(10.dp)
val ink = style.commodityInk.getValue(commodity)
Column(
modifier = modifier
.defaultMinSize(minWidth = 56.dp)
.clip(shape)
.background(style.commodity.getValue(commodity).copy(alpha = if (enabled) 1f else 0.4f))
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) Color.White else Color(0x33000000),
shape = shape,
)
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 6.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = count.toString(), color = ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(text = CatanText.good(good), color = ink, fontSize = 11.sp, maxLines = 1)
}
}
/**
* The local player's cards: one tile per resource, and in Cities & Knights one
* per commodity as well, in a row that wraps.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanHand(
state: CatanState,
seat: Int,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (state.isCk) {
val held = CatanCk.goodsOf(state, seat)
Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) }
} else {
Resource.entries.forEach { resource ->
ResourceTile(resource, state.resources[seat][resource] ?: 0)
}
}
}
}
// ---------------------------------------------------------------------------
// Players
// ---------------------------------------------------------------------------
/**
* One player's public standing: colour, name, points, how many cards they hold
* (not which), and — in the roomier layout — development cards, knights and
* their longest road.
*
* **Fixed height.** Every line is always present (blank when there is nothing to
* say) and never wraps, so the card is the same height whatever the game is
* doing. The board is given whatever room is left, so anything that made this
* card taller or shorter would rescale the island under the player's thumb.
*
* [points] is passed in rather than computed here because the local player's
* own count includes hidden victory-point cards and everyone else's does not;
* the screen decides which. [compact] is the narrow portrait strip (four cards
* across a phone); the default has room for the detail lines.
*/
@Composable
fun CatanPlayerCard(
state: CatanState,
seat: Int,
seats: CatanSeats,
points: Int,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
compact: Boolean = false,
) {
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
val shape = RoundedCornerShape(12.dp)
Column(
modifier = modifier
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(
width = if (onTurn) 3.dp else 1.dp,
color = if (onTurn) MaterialTheme.colorScheme.primary else Color(0x22000000),
shape = shape,
)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(
modifier = Modifier
.size(14.dp)
.clip(CircleShape)
.background(style.seats[seat % style.seats.size])
.border(1.dp, style.pieceOutline, CircleShape),
)
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
}
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
val ck = state.ck
if (ck != null) {
val cards = CatanCk.goodsCount(state, seat)
val strength = CatanCk.strength(state, seat)
if (compact) {
CardLine("$cards cards · ⚔$strength", MaterialTheme.typography.bodySmall)
} else {
CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall)
CardLine(
"⚔ ${CatanCk.knightsOf(state, seat).size} · str $strength · " +
"Road ${CatanRules.longestRoadFor(state, seat)}",
MaterialTheme.typography.bodySmall,
)
}
// Trade, politics, science: the three improvement levels.
CardLine(
Track.entries.joinToString(" ") { "${it.name.first()}${CatanCk.improvement(state, seat, it)}" },
MaterialTheme.typography.bodySmall,
color = Color.Unspecified,
)
val ckAwards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
ck.metropolises.forEach { (track, at) ->
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
}
if (ck.merchant?.seat == seat) add("Merchant")
}
CardLine(
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
style = MaterialTheme.typography.labelSmall,
weight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
return@Column
}
if (compact) {
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
} else {
CardLine(
"${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
MaterialTheme.typography.bodySmall,
)
CardLine(
"Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
MaterialTheme.typography.bodySmall,
)
}
val awards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
}
// Always drawn, so the card does not grow when somebody takes an award.
CardLine(
text = awards.joinToString(" · ").ifEmpty { BLANK },
style = MaterialTheme.typography.labelSmall,
weight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
/** A non-breaking space: a line of text with nothing in it, still one line tall. */
private const val BLANK = "\u00A0"
/** One line of a player card: never wraps, so the card's height cannot change. */
@Composable
private fun CardLine(
text: String,
style: TextStyle,
weight: FontWeight? = null,
color: Color = Color.Unspecified,
) {
Text(
text = text,
style = style,
fontWeight = weight,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// ---------------------------------------------------------------------------
// Dice
// ---------------------------------------------------------------------------
/**
* The two dice as last rolled, with their total. Blank until the first roll. In
* Cities & Knights the dice are yellow and red (the red one decides progress
* cards) and a third, the event die, shows a ship or a coloured city gate.
*/
@Composable
fun CatanDice(
dice: List<Int>,
modifier: Modifier = Modifier,
ck: Boolean = false,
event: EventFace? = null,
style: CatanStyle = LocalCatanStyle.current,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val shown = if (dice.size == 2) dice else listOf(null, null)
shown.forEachIndexed { index, value ->
val face = when {
!ck -> Color.White
index == 0 -> Color(0xFFFFF176)
else -> Color(0xFFEF9A9A)
}
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(face)
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = value?.toString() ?: "–",
color = Color(0xFF1C1B1F),
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
textAlign = TextAlign.Center,
)
}
}
if (ck) {
val gate = when (event) {
EventFace.TRADE -> style.track.getValue(Track.TRADE)
EventFace.POLITICS -> style.track.getValue(Track.POLITICS)
EventFace.SCIENCE -> style.track.getValue(Track.SCIENCE)
EventFace.SHIP -> style.barbarian
null -> Color(0x33000000)
}
Box(
modifier = Modifier
.size(width = 52.dp, height = 36.dp)
.clip(RoundedCornerShape(8.dp))
.background(gate)
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = if (event == EventFace.SHIP) "⛵ Ship" else CatanText.event(event),
color = if (event == EventFace.TRADE) Color(0xFF1C1B1F) else Color.White,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
maxLines = 1,
)
}
}
// Always drawn ("= –" before the first roll) so the row never changes width.
Text(
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
/**
* Cities & Knights: how close the barbarians are, and how the cities on the
* island compare with the knights standing ready. **Fixed height**, like every
* other strip around the board.
*/
@Composable
fun CatanBarbarians(state: CatanState, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current) {
val ck = state.ck ?: return
val cities = state.buildings.count { it?.type == BuildingType.CITY }
val defence = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
Row(
modifier = modifier.fillMaxWidth().height(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("⛵", fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) {
// The ship starts seven steps out and lands when the last box fills.
val sailed = BARBARIAN_STEPS_DISPLAY - ck.barbarians
for (i in 0 until BARBARIAN_STEPS_DISPLAY) {
Box(
modifier = Modifier
.weight(1f)
.height(10.dp)
.clip(RoundedCornerShape(3.dp))
.background(if (i < sailed) style.barbarian else Color(0x33000000)),
)
}
}
val verdict = if (defence >= cities) "holds" else "falls"
Text(
text = "${CatanText.barbarianSteps(ck.barbarians)} · knights $defence v. $cities cities ($verdict)",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** Steps shown on the barbarian strip: the engine's seven. */
private const val BARBARIAN_STEPS_DISPLAY = org.prolibertate.games.game.catan.BARBARIAN_STEPS
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
data class CatanActions(
val canRoll: Boolean = false,
val canBuild: Set<BuildKind> = emptySet(),
val building: BuildKind? = null,
val canBuyCard: Boolean = false,
val canPlayCard: Boolean = false,
val canTrade: Boolean = false,
val canEndTurn: Boolean = false,
val canEndFreeRoads: Boolean = false,
/** A setup settlement is chosen and a different one may still be picked. */
val canRepickSetup: Boolean = false,
/** Which kinds the Build menu lists: three in the base game, five in Cities & Knights. */
val buildKinds: List<BuildKind> = BuildKind.BASE,
/** Cities & Knights: the table plays the expansion, so the bar shows its buttons instead. */
val ck: Boolean = false,
val canImprove: Boolean = false,
val canPlayProgress: Boolean = false,
val progressCount: Int = 0,
val canEndSmith: Boolean = false,
)
/**
* The buttons for whatever the player can do now. Pure display: every press is a lambda.
*
* **The bar never changes size.** The top row holds the one "primary" action
* (roll, end turn, done with roads, cancel) and keeps its height when it is
* empty; the second row is always all six buttons, greyed out when they do not
* apply. Buttons appearing and disappearing used to resize this area and, with
* it, the board above — so nothing here may be conditionally *present*, only
* conditionally *enabled*.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanActionBar(
actions: CatanActions,
onRoll: () -> Unit,
onBuild: (BuildKind?) -> Unit,
onBuyCard: () -> Unit,
onPlayCard: () -> Unit,
onTrade: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onRepickSetup: () -> Unit,
modifier: Modifier = Modifier,
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
compact: Boolean = false,
onImprove: () -> Unit = {},
onProgress: () -> Unit = {},
onEndSmith: () -> Unit = {},
) {
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
if (actions.canEndSmith) Button(onClick = onEndSmith) { Text(CatanText.STOP_SMITH) }
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (compact || actions.ck) {
BuildMenuButton(actions, onBuild)
} else {
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
}
if (actions.ck) {
OutlinedButton(onClick = onImprove, enabled = actions.canImprove) {
LabelWithCost(CatanText.IMPROVE, CatanText.NO_COST)
}
OutlinedButton(onClick = onProgress, enabled = actions.canPlayProgress) {
LabelWithCost(CatanText.CARDS_BUTTON, "${actions.progressCount} held")
}
} else {
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
}
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
}
}
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
}
}
}
}
/** Room for one button, held whether or not there is one to show. */
private val PRIMARY_ROW_HEIGHT = 48.dp
@Composable
private fun BuildButton(
kind: BuildKind,
label: String,
cost: String,
actions: CatanActions,
onBuild: (BuildKind?) -> Unit,
) {
val enabled = kind in actions.canBuild
val active = actions.building == kind
val onClick = { onBuild(if (active) null else kind) }
if (active) {
FilledTonalButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
} else {
OutlinedButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
}
}
/**
* The small-screen stand-in for the three build buttons: one "Build" button
* whose menu lists Road, Settlement and City with their costs. While a build
* mode is armed the button shows which, and choosing that entry again cancels it.
*/
@Composable
private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) {
var open by remember { mutableStateOf(false) }
val armed = actions.building
val anyEnabled = actions.canBuild.isNotEmpty() || armed != null
val label: @Composable () -> Unit = {
LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST)
}
Box {
if (armed != null) {
FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() }
} else {
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
for (kind in actions.buildKinds) {
DropdownMenuItem(
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
enabled = kind in actions.canBuild,
onClick = {
open = false
onBuild(if (armed == kind) null else kind)
},
)
}
}
}
}
private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
}
private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD_COST
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
BuildKind.CITY -> CatanText.CITY_COST
BuildKind.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
}
@Composable
private fun LabelWithCost(label: String, cost: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(label)
Text(cost, fontSize = 9.sp, maxLines = 1)
}
}
// ---------------------------------------------------------------------------
// Log
// ---------------------------------------------------------------------------
/** The most recent happenings, newest last. Scrolls. */
@Composable
fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
if (state.log.isEmpty()) {
Text(CatanText.NO_LOG, style = MaterialTheme.typography.bodySmall, modifier = modifier)
return
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
state.log.takeLast(40).forEach {
Text(text = it, style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -0,0 +1,132 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/*
* Android Studio previews for the Katan UI. They exist so the look can be
* iterated on without starting a match: open this file, open the Preview pane,
* and edit CatanStyle / CatanText / the panels. Each preview draws a real
* position (the computer plays itself for a while), not a mock.
*
* Nothing here ships in a behaviour sense: previews are only compiled for tooling.
*/
private val previewJson = Json { encodeDefaults = true }
/** A real game, played forward by the computer for [moves] moves, for previewing. */
private fun sampleState(players: Int = 4, moves: Int = 90, seed: Long = 11L): CatanState {
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
optionsJson = previewJson.encodeToString(CatanOptions(playerCount = players)),
seed = seed,
)
val ai = CatanAi()
var state = CatanRules.initialState(config)
repeat(moves) {
if (state.phase == CatanPhase.GAME_OVER) return state
val seat = CatanRules.currentSeat(state) ?: return state
val legal = CatanRules.legalMoves(state, seat)
if (legal.isEmpty()) return state
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
return state
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardPreview() {
val state = remember { sampleState() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
CatanBoardView(
state = state,
targets = BoardTargets(),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardDarkPreview() {
val state = remember { sampleState() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = true)) {
CatanBoardView(
state = state,
targets = BoardTargets(),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardWithTargetsPreview() {
val state = remember { sampleState(moves = 6) }
val legal = remember(state) { CatanRules.currentSeat(state)?.let { CatanRules.legalMoves(state, it) }.orEmpty() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
CatanBoardView(
state = state,
targets = CatanInteraction.targets(state, legal, building = null, setupVertex = null),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 560)
@Composable
private fun PanelsPreview() {
val state = remember { sampleState() }
val seats = CatanSeats(me = 0, names = listOf("You", "Ann", "Bo", "Cy"))
val points = remember(state) { CatanRules.publicVictoryPoints(state) }
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(0 until state.playerCount).forEach { seat ->
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f), compact = true)
}
}
CatanHand(state, seat = 0)
CatanDice(state.lastDice)
CatanActionBar(
actions = CatanActions(
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
building = BuildKind.ROAD,
canBuyCard = true,
canTrade = true,
canEndTurn = true,
),
onRoll = {}, onBuild = {}, onBuyCard = {}, onPlayCard = {}, onTrade = {},
onEndTurn = {}, onEndFreeRoads = {}, onRepickSetup = {},
)
}
}
@@ -0,0 +1,614 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.AqueductChore
import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DiscardChore
import org.prolibertate.games.game.catan.DiscardGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.DropProgressChore
import org.prolibertate.games.game.catan.DismissCounters
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.SpyChore
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
/**
* Katan, on the island.
*
* This composable is only wiring. It reads the match from the controller, holds
* the handful of purely visual choices (which build mode is armed, which setup
* settlement is waiting for its road, which dialog is open), and hands
* everything else to single-purpose pieces:
*
* | Concern | Where it lives |
* | ------------------------------ | -------------------------- |
* | Colours, sizes, shapes | [CatanStyle] |
* | Every word on screen | [CatanText] |
* | Island → pixels, taps → hexes | [CatanGeometry] |
* | What is tappable, what a tap does | [CatanInteraction] |
* | Drawing the island | [CatanBoardView] |
* | Cards, players, dice, buttons | `CatanPanels.kt` |
* | Every dialog | `CatanDialogs.kt` |
*
* There are no game rules here: the engine's legal-move list is the only source
* of what the player may do, so the screen cannot offer a move the rules refuse.
* See `docs/UI-catan.md` for how to adapt the look and feel.
*/
@Composable
fun CatanScreen(
controller: MatchController<CatanState, CatanMove>,
localSeat: Int,
onExit: () -> Unit,
seatNames: List<String> = emptyList(),
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
val notice by controller.notice.collectAsState()
// Purely visual state. Anything that affects the game goes through submit().
var building by remember { mutableStateOf<BuildKind?>(null) }
var setupVertex by remember { mutableStateOf<Int?>(null) }
var victimChoice by remember { mutableStateOf<TapOutcome.PickRobberVictim?>(null) }
var showCards by remember { mutableStateOf(false) }
var showTrade by remember { mutableStateOf(false) }
var confirmingEnd by remember { mutableStateOf(false) }
var resultDismissed by remember { mutableStateOf(false) }
// Cities & Knights: the knight whose menu is open, the one about to move,
// a progress card waiting for a tap on the board, and the two list dialogs.
var knightMenu by remember { mutableStateOf<Int?>(null) }
var movingKnight by remember { mutableStateOf<Int?>(null) }
var armed by remember { mutableStateOf<ArmedCard?>(null) }
var showImprove by remember { mutableStateOf(false) }
var showProgress by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
val style = remember(dark) { catanStyle(dark) }
val seats = remember(localSeat, seatNames) { CatanSeats(localSeat, seatNames) }
// The engine's list changes after every move; drop any selection it no
// longer supports (a build mode the player can no longer afford, a setup
// settlement that is no longer pending).
LaunchedEffect(legal) {
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
setupVertex = null
}
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
if (legal.isEmpty()) {
showCards = false
showTrade = false
victimChoice = null
showImprove = false
showProgress = false
knightMenu = null
movingKnight = null
armed = null
}
}
val submit: (CatanMove) -> Unit = { move ->
controller.submit(move)
building = null
setupVertex = null
victimChoice = null
showCards = false
showTrade = false
showImprove = false
showProgress = false
knightMenu = null
movingKnight = null
armed = null
}
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text(CatanText.END_GAME) }
}
val current = state
if (current == null) {
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
abandoned?.let { CatanAbandonedDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) CatanEndGameDialog(onExit) { confirmingEnd = false }
}
return
}
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat)
val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
val devHand = CatanInteraction.devHand(current, localSeat, legal)
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions(
buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE,
ck = current.isCk,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable },
progressCount = progressHand.size,
canEndSmith = CatanInteraction.canEndSmith(legal),
canRoll = CatanInteraction.canRoll(legal),
canBuild = CatanInteraction.canBuild(legal),
building = building,
canBuyCard = CatanInteraction.canBuyDevCard(legal),
canPlayCard = devHand.any { it.playable },
canTrade = playing,
canEndTurn = CatanInteraction.canEndTurn(legal),
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
canRepickSetup = setupVertex != null,
)
val rolledHexes = CatanInteraction.rolledHexes(current)
val prompt = CatanText.prompt(
current,
seats,
setupVertexChosen = setupVertex != null,
building = building,
movingKnight = movingKnight != null,
armed = armed?.kind,
)
val handle: (TapOutcome) -> Unit = { outcome ->
when (outcome) {
TapOutcome.Ignored -> Unit
is TapOutcome.Submit -> submit(outcome.move)
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
}
}
val publicPoints = CatanRules.publicVictoryPoints(current)
val pointsOf: (Int) -> Int = { seat ->
if (seat == localSeat) CatanRules.victoryPoints(current, seat) else publicPoints[seat]
}
CompositionLocalProvider(LocalCatanStyle provides style) {
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight * 1.1f
// Phones (either way up) get the one-button build menu.
val compactBuild = !wide || maxWidth < 600.dp
val board: @Composable (Modifier) -> Unit = { boardModifier ->
CatanBoardView(
state = current,
targets = targets,
selectedVertex = setupVertex,
onVertexTap = {
handle(
CatanInteraction.onVertexTap(
it, legal, building, setupVertex, movingKnight, armed,
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
),
)
},
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
modifier = boardModifier,
style = style,
rolledHexes = rolledHexes,
selectedKnight = movingKnight ?: knightMenu,
)
}
val players: @Composable (Boolean) -> Unit = { grid ->
PlayerCards(current, seats, pointsOf, style, twoPerRow = grid)
}
val promptRow: @Composable () -> Unit = {
PromptRow(prompt, notice, current)
}
val handAndActions: @Composable () -> Unit = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
CatanHand(current, localSeat)
CatanActionBar(
actions = actions,
onRoll = { submit(RollDice()) },
onBuild = { building = it },
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
onPlayCard = { showCards = true },
onTrade = { showTrade = true },
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
onEndFreeRoads = {
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
},
onRepickSetup = { setupVertex = null },
compact = compactBuild,
onImprove = { showImprove = true },
onProgress = { showProgress = true },
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
)
}
}
if (wide) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) {
board(Modifier)
}
Column(
modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
players(true)
if (current.isCk) CatanBarbarians(current, style = style)
promptRow()
handAndActions()
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
}
}
} else {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
players(false)
if (current.isCk) CatanBarbarians(current, style = style)
promptRow()
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
board(Modifier)
}
handAndActions()
}
}
}
CatanDialogHost(
state = current,
seats = seats,
localSeat = localSeat,
legal = legal,
abandoned = abandoned,
confirmingEnd = confirmingEnd,
resultDismissed = resultDismissed,
victimChoice = victimChoice,
showCards = showCards,
showTrade = showTrade,
devHand = devHand,
knightMenu = knightMenu,
showImprove = showImprove,
showProgress = showProgress,
progressHand = progressHand,
onMoveKnight = { from ->
knightMenu = null
movingKnight = from
},
onArmCard = { card ->
showProgress = false
armed = card
},
onCancelKnight = { knightMenu = null },
onCancelImprove = { showImprove = false },
onCancelProgress = { showProgress = false },
onExit = onExit,
onSubmit = submit,
onStay = controller::dismissAbandoned,
onKeepPlaying = { confirmingEnd = false },
onDismissResult = { resultDismissed = true },
onCancelVictim = { victimChoice = null },
onCancelCards = { showCards = false },
onCancelTrade = { showTrade = false },
)
}
}
}
// ---------------------------------------------------------------------------
// Layout pieces
// ---------------------------------------------------------------------------
@Composable
private fun PlayerCards(
state: CatanState,
seats: CatanSeats,
pointsOf: (Int) -> Int,
style: CatanStyle,
twoPerRow: Boolean,
) {
val order = (0 until state.playerCount).toList()
val rows = if (twoPerRow) order.chunked(2) else listOf(order)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
rows.forEach { row ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
row.forEach { seat ->
CatanPlayerCard(
state = state,
seat = seat,
seats = seats,
points = pointsOf(seat),
modifier = Modifier.weight(1f),
style = style,
compact = !twoPerRow,
)
}
// Keep an odd last row's card the same width as the others.
if (twoPerRow && row.size == 1) Box(Modifier.weight(1f))
}
}
}
}
/**
* The one line telling the player what is going on, and the dice.
*
* **Fixed height.** The text always occupies exactly two lines (padded when it is
* shorter, clipped when it is longer) and a refused move replaces the prompt
* rather than adding to it, so this row is the same height whatever is said and
* the board below it never moves.
*/
@Composable
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = notice ?: prompt,
style = MaterialTheme.typography.bodyMedium,
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
}
}
/**
* Chooses which dialog, if any, is showing. Pulled out of [CatanScreen] so the
* priority order — an ended table beats a result beats a forced discard beats
* an optional picker — is in one readable place.
*/
@Composable
private fun CatanDialogHost(
state: CatanState,
seats: CatanSeats,
localSeat: Int,
legal: List<CatanMove>,
abandoned: String?,
confirmingEnd: Boolean,
resultDismissed: Boolean,
victimChoice: TapOutcome.PickRobberVictim?,
showCards: Boolean,
showTrade: Boolean,
devHand: List<DevCardInHand>,
knightMenu: Int?,
showImprove: Boolean,
showProgress: Boolean,
progressHand: List<ProgressInHand>,
onMoveKnight: (Int) -> Unit,
onArmCard: (ArmedCard) -> Unit,
onCancelKnight: () -> Unit,
onCancelImprove: () -> Unit,
onCancelProgress: () -> Unit,
onExit: () -> Unit,
onSubmit: (CatanMove) -> Unit,
onStay: () -> Unit,
onKeepPlaying: () -> Unit,
onDismissResult: () -> Unit,
onCancelVictim: () -> Unit,
onCancelCards: () -> Unit,
onCancelTrade: () -> Unit,
) {
val offer = state.pendingTrade
val goodsOffer = state.ck?.goodsOffer
when {
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
confirmingEnd -> CatanEndGameDialog(onEnd = onExit, onKeepPlaying = onKeepPlaying)
state.phase == CatanPhase.GAME_OVER ->
if (!resultDismissed) CatanGameOverDialog(state, seats, onExit, onDismissResult)
state.phase == CatanPhase.DISCARDING && state.discardSeat == localSeat ->
CatanDiscardDialog(
held = state.resources[localSeat],
quota = CatanInteraction.discardQuota(state.resourceCount(localSeat)),
legal = legal,
onSubmit = onSubmit,
)
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
ChoreDialog(state, localSeat, legal, onSubmit)
state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.asking == localSeat ->
CatanGoodsTradeResponseDialog(
state = state,
seat = localSeat,
offer = goodsOffer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
onCounter = onSubmit,
)
state.phase == CatanPhase.TRADE_COUNTERS && goodsOffer != null && state.turn == localSeat && legal.isNotEmpty() ->
CatanGoodsCounterChoiceDialog(
counters = CatanInteraction.goodsCounterChoices(state, legal),
seats = seats,
onTake = onSubmit,
onNoDeal = { onSubmit(DismissCounters) },
)
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat ->
CatanTradeResponseDialog(
state = state,
seat = localSeat,
offer = offer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
onCounter = onSubmit,
)
state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() ->
CatanCounterChoiceDialog(
counters = CatanInteraction.counterChoices(state, legal),
seats = seats,
onTake = onSubmit,
onNoDeal = { onSubmit(DismissCounters) },
)
victimChoice != null ->
CatanVictimDialog(
victims = victimChoice.victims,
seats = seats,
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) },
onCancel = onCancelVictim,
)
knightMenu != null ->
CatanKnightDialog(
state = state,
vertex = knightMenu,
actions = CatanInteraction.knightActions(legal, knightMenu),
onDo = onSubmit,
onMove = onMoveKnight,
onCancel = onCancelKnight,
)
showImprove ->
CatanImproveDialog(
state = state,
seat = localSeat,
legal = legal,
onBuy = onSubmit,
onCancel = onCancelImprove,
)
showProgress ->
CatanProgressDialog(
hand = progressHand,
seats = seats,
onPlay = onSubmit,
onArm = onArmCard,
onCancel = onCancelProgress,
)
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
showTrade && state.isCk ->
CatanGoodsTradeDialog(
state = state,
seat = localSeat,
seats = seats,
legal = legal,
onBank = onSubmit,
onOffer = onSubmit,
onCancel = onCancelTrade,
)
showTrade ->
CatanTradeDialog(
state = state,
seat = localSeat,
seats = seats,
legal = legal,
onBank = onSubmit,
onOffer = onSubmit,
onCancel = onCancelTrade,
)
}
}
/**
* The dialog for the chore at the front of the queue, when it is the local
* player's. Chores that are answered by tapping the board (a city to lose, a
* metropolis site, a knight to place) have no dialog: the prompt line tells the
* player what to tap.
*/
@Composable
private fun ChoreDialog(
state: CatanState,
localSeat: Int,
legal: List<CatanMove>,
onSubmit: (CatanMove) -> Unit,
) {
val ck = state.ck ?: return
val check: (List<org.prolibertate.games.game.catan.Good>, (List<org.prolibertate.games.game.catan.Good>) -> CatanMove) -> Boolean =
{ cards, make -> CatanRules.isLegalMove(state, localSeat, make(cards)) }
when (val chore = ck.chores.firstOrNull()) {
is DiscardChore -> {
val quota = minOf(chore.count, CatanCk.goodsCount(state, localSeat))
CatanGoodsPickDialog(
title = "Discard $quota cards",
intro = "Choose $quota cards to give up",
held = CatanCk.goodsOf(state, localSeat),
quota = quota,
confirmLabel = "Discard",
selectable = { true },
allowed = { check(it, ::DiscardGoods) },
onConfirm = { onSubmit(DiscardGoods(it)) },
)
}
is GiveChore -> {
val pool = CatanCk.goodsOf(state, localSeat).filterKeys { !chore.commoditiesOnly || it.isCommodity }
val quota = minOf(chore.count, pool.values.sum())
CatanGoodsPickDialog(
title = "Hand over $quota ${if (quota == 1) "card" else "cards"}",
intro = if (chore.commoditiesOnly) "Choose a commodity to give" else "Choose which cards to give",
held = pool,
quota = quota,
confirmLabel = "Give",
selectable = { !chore.commoditiesOnly || it.isCommodity },
allowed = { check(it, ::GiveGoods) },
onConfirm = { onSubmit(GiveGoods(it)) },
)
}
is PickDeckChore -> CatanPickDeckDialog(legal) { onSubmit(it) }
is DropProgressChore -> CatanDropProgressDialog(ck.progressHands[localSeat]) { onSubmit(it) }
is AqueductChore -> CatanAqueductDialog(legal) { onSubmit(it) }
is SpyChore -> CatanSpyDialog(ck.progressHands[chore.target], legal) { onSubmit(it) }
else -> Unit
}
}
@@ -0,0 +1,167 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import org.prolibertate.games.game.catan.Commodity
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.catan.Track
/**
* Every visual decision for the Katan screen, in one place.
*
* **To restyle Katan, edit this file.** Nothing else in `ui/game/catan/` hard-
* codes a colour or a proportion: the board, the hand, the player strip and the
* dialogs all read a [CatanStyle] from [LocalCatanStyle]. A different art
* direction is a different [CatanStyle] value (see [catanStyle]); a new theme
* can be supplied at the screen's root with
* `CompositionLocalProvider(LocalCatanStyle provides myStyle)`.
*
* Wording is the same story in [CatanText]; geometry is in [CatanGeometry].
*
* Proportions are fractions of the *hex radius* (centre of a hex to one of its
* corners), so they scale with the board whatever the screen size.
*/
@Immutable
data class CatanStyle(
// --- Board surfaces -----------------------------------------------------
/** The water around the island. */
val sea: Color,
/** Fill of each hex by terrain. */
val terrain: Map<Terrain, Color>,
/** Outline between hexes. */
val hexEdge: Color,
/** The number-token disc, and the ink for ordinary and hot (6 / 8) numbers. */
val token: Color,
val tokenInk: Color,
val tokenHotInk: Color,
/** The robber's pawn. */
val robber: Color,
/** Outline and wash on hexes that produced for the latest dice roll. */
val rolled: Color,
/** Harbour marker disc and its ink. */
val portMarker: Color,
val portInk: Color,
// --- Resources (hand tiles, trade pickers, harbour markers) -------------
val resource: Map<Resource, Color>,
val resourceInk: Color,
// --- Cities & Knights ---------------------------------------------------
/** Commodity cards: cloth, coin and paper. */
val commodity: Map<Commodity, Color> = LightCommodities,
/** Ink on a commodity tile (paper is pale, so it is not white). */
val commodityInk: Map<Commodity, Color> = LightCommodityInk,
/** The colour of each improvement track, its city gate, its deck and its metropolis. */
val track: Map<Track, Color> = LightTracks,
/** Wall round a city, and the barbarian strip. */
val wall: Color = Color(0xFF5D4037),
val barbarian: Color = Color(0xFFB71C1C),
/** The halo round a knight that is awake. White, so it is not mistaken for the yellow tap ring. */
val knightReady: Color = Color(0xFFFFFFFF),
// --- Players ------------------------------------------------------------
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
val seats: List<Color>,
/** Outline drawn round every piece so any seat colour reads on any terrain. */
val pieceOutline: Color,
// --- Interaction feedback ----------------------------------------------
/** Marks where the player may build or move right now. */
val target: Color,
/** The setup settlement that has been chosen and is waiting for its road. */
val targetSelected: Color,
// --- Metrics (fractions of the hex radius) ------------------------------
/** How far each tile polygon is pulled in from its corners, leaving a gap. */
val hexInset: Float = 0.04f,
val roadWidth: Float = 0.20f,
val settlementSize: Float = 0.30f,
val citySize: Float = 0.42f,
val tokenRadius: Float = 0.36f,
val robberSize: Float = 0.34f,
val portRadius: Float = 0.46f,
val targetRadius: Float = 0.20f,
/** How close a tap must land to a vertex / edge / hex centre to count. */
val tapReach: Float = 0.55f,
val tokenText: Float = 0.34f,
val portText: Float = 0.27f,
val knightSize: Float = 0.30f,
)
private val LightCommodities = mapOf(
Commodity.CLOTH to Color(0xFF7E57C2),
Commodity.COIN to Color(0xFFF9A825),
Commodity.PAPER to Color(0xFFECEFF1),
)
private val LightCommodityInk = mapOf(
Commodity.CLOTH to Color(0xFFFFFFFF),
Commodity.COIN to Color(0xFF3E2723),
Commodity.PAPER to Color(0xFF263238),
)
private val LightTracks = mapOf(
Track.TRADE to Color(0xFFF2C300),
Track.POLITICS to Color(0xFF1E6FD9),
Track.SCIENCE to Color(0xFF2E9E4F),
)
/** The light palette, which is also the reference for the dark one. */
private val LightStyle = CatanStyle(
sea = Color(0xFF3A7CA5),
terrain = mapOf(
Terrain.FOREST to Color(0xFF2F6B3A),
Terrain.PASTURE to Color(0xFF9CCC65),
Terrain.FIELDS to Color(0xFFE8C84A),
Terrain.HILLS to Color(0xFFB4532F),
Terrain.MOUNTAINS to Color(0xFF7C8794),
Terrain.DESERT to Color(0xFFE6D3A0),
),
hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9),
tokenInk = Color(0xFF2B2B2B),
tokenHotInk = Color(0xFFC62828),
robber = Color(0xFF263238),
rolled = Color(0xFFFFFFFF),
portMarker = Color(0xFFFFF8E1),
portInk = Color(0xFF2B2B2B),
resource = mapOf(
Resource.LUMBER to Color(0xFF2F6B3A),
Resource.BRICK to Color(0xFFB4532F),
Resource.WOOL to Color(0xFF8BC34A),
Resource.GRAIN to Color(0xFFE0B020),
Resource.ORE to Color(0xFF6B7684),
),
resourceInk = Color(0xFFFFFFFF),
seats = listOf(
Color(0xFF1565C0), // blue
Color(0xFFEF6C00), // orange
Color(0xFFF5F5F5), // white
Color(0xFF8E24AA), // purple
),
pieceOutline = Color(0xFF1B1B1B),
target = Color(0xFFFFEB3B),
targetSelected = Color(0xFF00E5FF),
)
/** Deeper sea and slightly dimmer land so the board is not a glare at night. */
private val DarkStyle = LightStyle.copy(
sea = Color(0xFF14354D),
terrain = LightStyle.terrain.mapValues { (_, c) -> c.dim(0.82f) },
token = Color(0xFFE9DFC4),
)
/** The style for the current light / dark setting. */
fun catanStyle(dark: Boolean): CatanStyle = if (dark) DarkStyle else LightStyle
/**
* What the Katan composables read their look from. Defaults to the light style
* so a preview or a test with no provider still draws something sensible;
* [CatanScreen] provides the right one for the current theme.
*/
val LocalCatanStyle = staticCompositionLocalOf { LightStyle }
private fun Color.dim(factor: Float): Color =
Color(red * factor, green * factor, blue * factor, alpha)
@@ -0,0 +1,475 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AqueductChore
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.DiscardChore
import org.prolibertate.games.game.catan.DropProgressChore
import org.prolibertate.games.game.catan.EventFace
import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.MetropolisChore
import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.PillageChore
import org.prolibertate.games.game.catan.PlaceKnightChore
import org.prolibertate.games.game.catan.Port
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.catan.RelocateChore
import org.prolibertate.games.game.catan.RemoveKnightChore
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.SmithChore
import org.prolibertate.games.game.catan.SpyChore
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.catan.Track
/**
* Who is who at the table, for wording. [names] may be empty or short — a
* client device does not always know everyone's name — and the rest fall back
* to "Seat N".
*/
class CatanSeats(val me: Int, private val names: List<String> = emptyList()) {
fun label(seat: Int): String = when {
seat == me -> "You"
else -> names.getOrNull(seat)?.takeIf { it.isNotBlank() } ?: "Seat ${seat + 1}"
}
/** For mid-sentence use: "you" rather than "You". */
fun lower(seat: Int): String = if (seat == me) "you" else label(seat)
}
/**
* Every word the Katan screen shows, in one place.
*
* **To change copy, or to localise, edit this file.** The composables never
* build a sentence themselves; they ask for it here. Keeping the sentences as
* plain functions of state also means they are unit-tested (`CatanTextTest`)
* without a device.
*
* "Katan" is this app's name for the game (see `docs/RULES-catan.md`); the
* resources are lumber, brick, wool, grain and ore.
*/
object CatanText {
const val TITLE = "Katan"
// --- Names ---------------------------------------------------------------
fun resource(resource: Resource): String = when (resource) {
Resource.LUMBER -> "Lumber"
Resource.BRICK -> "Brick"
Resource.WOOL -> "Wool"
Resource.GRAIN -> "Grain"
Resource.ORE -> "Ore"
}
fun terrain(terrain: Terrain): String = when (terrain) {
Terrain.FOREST -> "Forest"
Terrain.PASTURE -> "Pasture"
Terrain.FIELDS -> "Fields"
Terrain.HILLS -> "Hills"
Terrain.MOUNTAINS -> "Mountains"
Terrain.DESERT -> "Desert"
}
fun devCard(type: DevCardType): String = when (type) {
DevCardType.KNIGHT -> "Knight"
DevCardType.ROAD_BUILDING -> "Road building"
DevCardType.MONOPOLY -> "Monopoly"
DevCardType.YEAR_OF_PLENTY -> "Year of plenty"
DevCardType.VICTORY_POINT -> "Victory point"
}
fun devCardHelp(type: DevCardType): String = when (type) {
DevCardType.KNIGHT -> "Move the robber and steal a card. Three played knights take Largest Army."
DevCardType.ROAD_BUILDING -> "Build two roads for free."
DevCardType.MONOPOLY -> "Name a resource and take every card of it from the other players."
DevCardType.YEAR_OF_PLENTY -> "Take any two resource cards from the bank."
DevCardType.VICTORY_POINT -> "One victory point. Kept hidden until you win."
}
/** "3:1" for a generic harbour, "2:1" for a resource one. */
fun portRatio(port: Port): String = "${port.ratio}:1"
fun portName(port: Port): String =
port.resource?.let { "${portRatio(port)} ${resource(it).lowercase()}" } ?: "3:1 any"
// --- Costs ---------------------------------------------------------------
fun cost(cost: Map<Resource, Int>): String =
cost.entries.joinToString(" + ") { (r, n) -> "$n ${resource(r).lowercase()}" }
val ROAD_COST: String get() = cost(CatanRules.ROAD_COST)
val SETTLEMENT_COST: String get() = cost(CatanRules.SETTLEMENT_COST)
val CITY_COST: String get() = cost(CatanRules.CITY_COST)
val DEV_COST: String get() = cost(CatanRules.DEV_COST)
// --- Buttons and headings --------------------------------------------------
const val ROLL = "Roll dice"
const val END_TURN = "End turn"
const val ROAD = "Road"
const val SETTLEMENT = "Settlement"
const val CITY = "City"
const val BUY_CARD = "Buy card"
const val PLAY_CARD = "Play card"
const val TRADE = "Trade"
const val CANCEL = "Cancel"
const val CONFIRM = "Confirm"
const val STOP_ROADS = "Done with roads"
const val BUILD = "Build"
const val COUNTER = "Counter…"
const val SEND_COUNTER = "Send counter"
const val EVERYONE = "Everyone"
const val NO_DEAL = "No deal"
/**
* The empty second line of a button that has no cost, so every button in the
* action bar is the same height. A non-breaking space: blank, but a line.
*/
const val NO_COST = "\u00A0"
const val YOUR_HAND = "Your hand"
const val CARDS = "cards"
const val ACCEPT = "Accept"
const val DECLINE = "Decline"
const val TRADE_WITH_PLAYER = "Offer to a player"
const val TRADE_WITH_BANK = "Trade with the bank"
const val YOU_GIVE = "You give"
const val YOU_GET = "You get"
const val SEND_OFFER = "Send offer"
const val NO_LOG = "Nothing has happened yet."
const val END_GAME = "End game"
const val LOOK_AT_BOARD = "Look at the board"
const val LEAVE_GAME = "Leave the game"
// --- The prompt line ------------------------------------------------------
/**
* The one line that tells the player what is going on and what to do.
*
* [setupVertexChosen] is the setup settlement already picked and waiting
* for its road; [building] is the build mode the player has entered, if any.
*/
fun prompt(
state: CatanState,
seats: CatanSeats,
setupVertexChosen: Boolean,
building: BuildKind?,
/** Cities & Knights: a knight has been picked to move, and its destination is awaited. */
movingKnight: Boolean = false,
/** Cities & Knights: a progress card is waiting for a tap on the board. */
armed: ProgressType? = null,
): String {
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
val me = seats.me
val onClock = CatanRules.currentSeat(state)
val mine = onClock == me
val turn = seats.label(state.turn)
return when (state.phase) {
CatanPhase.GAME_OVER -> {
val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
}
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
setupVertexChosen -> "Now tap the road to place beside that settlement."
state.phase == CatanPhase.SETUP_SECOND ->
"Place your second settlement. It earns resources from the hexes around it."
else -> "Place a settlement, then a road."
}
CatanPhase.AWAIT_ROLL ->
if (mine) "Your turn. Roll the dice." else "$turn is rolling…"
CatanPhase.DISCARDING -> {
val discarder = state.discardSeat
when {
discarder == me -> "A seven! You hold ${state.resourceCount(me)} cards; " +
"discard ${state.resourceCount(me) / 2}."
discarder != null -> "A seven! ${seats.label(discarder)} must discard half."
else -> "A seven! Discarding…"
}
}
CatanPhase.ROBBER ->
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…"
CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…"
building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
else -> "Your turn. Build, trade, or end the turn."
}
CatanPhase.FREE_ROADS ->
if (mine) {
"Road building: place ${state.pendingFreeRoads} more " +
if (state.pendingFreeRoads == 1) "road." else "roads."
} else {
"$turn is placing free roads…"
}
CatanPhase.CHORE -> "Waiting for ${seats.label(onClock ?: state.turn)}…"
CatanPhase.TRADE_PENDING -> {
val offer = state.pendingTrade
when {
offer == null -> "Waiting for a reply…"
offer.asking == me -> if (offer.to == null) {
"${seats.label(offer.by)} offers the table a trade. Accept, decline or counter."
} else {
"${seats.label(offer.by)} offers you a trade. Accept, decline or counter."
}
offer.by == me -> "Waiting for ${seats.label(offer.asking)} to answer your offer…"
else -> "${seats.label(offer.by)}'s offer is with ${seats.label(offer.asking)}."
}
}
CatanPhase.TRADE_COUNTERS ->
if (mine) {
"Nobody accepted, but there are counter-offers. Take one or turn them down."
} else {
"$turn is weighing counter-offers…"
}
}
}
/** "3 Lumber + 1 Brick" for a flat list of cards. */
fun cards(cards: List<Resource>): String =
cards.groupingBy { it }.eachCount().entries
.sortedBy { it.key.ordinal }
.joinToString(" + ") { (r, n) -> "$n ${resource(r)}" }
.ifEmpty { "nothing" }
fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
"$by offers ${cards(give)} to $to for ${cards(get)}"
/** One counter-offer as the offerer sees it: what that seat gives and wants. */
fun counterSummary(by: String, give: List<Resource>, get: List<Resource>): String =
"$by gives ${cards(give)} for your ${cards(get)}"
fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
val winner = state.winner ?: return "Game over"
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
}
// --- Cities & Knights ----------------------------------------------------
fun good(good: Good): String = when (good) {
Good.LUMBER -> "Lumber"
Good.BRICK -> "Brick"
Good.WOOL -> "Wool"
Good.GRAIN -> "Grain"
Good.ORE -> "Ore"
Good.CLOTH -> "Cloth"
Good.COIN -> "Coin"
Good.PAPER -> "Paper"
}
/** "2 Lumber + 1 Cloth" for a flat list of goods. */
fun goods(cards: List<Good>): String =
cards.groupingBy { it }.eachCount().entries
.sortedBy { it.key.ordinal }
.joinToString(" + ") { (g, n) -> "$n ${good(g)}" }
.ifEmpty { "nothing" }
fun track(track: Track): String = when (track) {
Track.TRADE -> "Trade"
Track.POLITICS -> "Politics"
Track.SCIENCE -> "Science"
}
/** What the third level of a track gives, for the improvement dialog. */
fun trackPower(track: Track): String = when (track) {
Track.TRADE -> "From level 3, trade commodities with the bank two for one."
Track.POLITICS -> "From level 3, knights can be promoted to mighty."
Track.SCIENCE -> "From level 3, when the dice pay you nothing, take any resource."
}
fun knightName(level: Int): String = when (level) {
1 -> "Basic knight"
2 -> "Strong knight"
else -> "Mighty knight"
}
fun event(face: EventFace?): String = when (face) {
null -> "–"
EventFace.SHIP -> "Ship"
EventFace.TRADE -> "Trade"
EventFace.POLITICS -> "Politics"
EventFace.SCIENCE -> "Science"
}
fun progress(kind: ProgressType): String = CatanCk.progressName(kind)
fun progressHelp(kind: ProgressType): String = when (kind) {
ProgressType.ALCHEMIST -> "Before rolling, choose what the two number dice show."
ProgressType.CRANE -> "Your next city improvement costs one commodity less."
ProgressType.ENGINEER -> "Raise a wall round one of your cities for free."
ProgressType.INVENTOR -> "Swap two number tokens (not 2, 6, 8 or 12)."
ProgressType.IRRIGATION -> "Take 2 grain for each fields hex beside your settlements and cities."
ProgressType.MEDICINE -> "Upgrade a settlement to a city for 2 ore and 1 grain."
ProgressType.MINING -> "Take 2 ore for each mountains hex beside your settlements and cities."
ProgressType.PRINTER -> "One victory point."
ProgressType.ROAD_BUILDING -> "Build two roads for free."
ProgressType.SMITH -> "Promote up to two of your knights one level for free."
ProgressType.BISHOP -> "Move the robber and take a card from every player beside its new hex."
ProgressType.CONSTITUTION -> "One victory point."
ProgressType.DESERTER -> "Another player removes a knight; you place a free one of the same strength."
ProgressType.DIPLOMAT -> "Remove an open road. If it was yours, build it again anywhere for free."
ProgressType.INTRIGUE -> "Drive an opponent's knight off a crossing beside one of your roads."
ProgressType.SABOTEUR -> "Every player with as many points as you or more discards half their cards."
ProgressType.SPY -> "Look at another player's progress cards and take one."
ProgressType.WARLORD -> "Wake all your knights without paying grain."
ProgressType.WEDDING -> "Every player with more points gives you 2 cards."
ProgressType.COMMERCIAL_HARBOR -> "Offer a resource to each player; each swaps it for a commodity."
ProgressType.MASTER_MERCHANT -> "Take 2 cards from a player with more points."
ProgressType.MERCHANT -> "Put the merchant beside your settlement: trade that resource 2:1 and score a point."
ProgressType.MERCHANT_FLEET -> "This turn, trade one chosen card type with the bank two for one."
ProgressType.RESOURCE_MONOPOLY -> "Name a resource. Every other player gives you 2 of it."
ProgressType.TRADE_MONOPOLY -> "Name a commodity. Every other player gives you 1 of it."
}
const val IMPROVE = "Improve"
const val KNIGHTS = "Knights"
const val CARDS_BUTTON = "Progress"
const val WALL = "Wall"
const val KNIGHT = "Knight"
const val STOP_SMITH = "Done promoting"
val KNIGHT_COST: String get() = cost(CatanCk.KNIGHT_COST)
val ACTIVATE_COST: String get() = cost(CatanCk.ACTIVATE_COST)
val WALL_COST: String get() = cost(CatanCk.WALL_COST)
private fun ckPrompt(
state: CatanState,
seats: CatanSeats,
setupVertexChosen: Boolean,
building: BuildKind?,
movingKnight: Boolean,
armed: ProgressType?,
): String {
val me = seats.me
val onClock = CatanRules.currentSeat(state)
val mine = onClock == me
val turn = seats.label(state.turn)
val ck = state.ck!!
return when (state.phase) {
CatanPhase.GAME_OVER -> {
val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
}
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
setupVertexChosen -> "Now tap the road to place beside that settlement."
state.phase == CatanPhase.SETUP_SECOND ->
"Place your second piece: it is a city, and earns resources from the hexes around it."
else -> "Place a settlement, then a road."
}
CatanPhase.AWAIT_ROLL -> when {
!mine -> "$turn is rolling…"
armed != null -> armedPrompt(armed)
movingKnight -> "Tap where the knight should go."
else -> "Your turn. Roll the dice, or wake knights and play progress cards first."
}
CatanPhase.ROBBER -> when {
!mine -> "$turn is moving the robber…"
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
else -> "Move the robber. Tap a hex."
}
CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…"
armed != null -> armedPrompt(armed)
movingKnight -> "Tap where the knight should go."
building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
building == BuildKind.WALL -> "Tap one of your cities to raise a wall."
building == BuildKind.KNIGHT -> "Tap an empty crossing at the end of your road to hire a knight."
else -> "Your turn. Build, improve, trade, or end the turn. Tap a knight to use it."
}
CatanPhase.FREE_ROADS ->
if (mine) {
"Place ${state.pendingFreeRoads} free " +
if (state.pendingFreeRoads == 1) "road." else "roads."
} else {
"$turn is placing free roads…"
}
CatanPhase.TRADE_PENDING -> {
val offer = ck.goodsOffer
val asking = offer?.asking?.takeIf { it >= 0 }
when {
offer == null || asking == null -> "Waiting for a reply…"
asking == me -> "${seats.label(offer.by)} offers you a trade."
offer.by == me -> "Waiting for ${seats.label(asking)} to answer your offer…"
else -> "${seats.label(offer.by)} has offered ${seats.label(asking)} a trade."
}
}
CatanPhase.TRADE_COUNTERS ->
if (mine) {
"Nobody accepted, but there are counter-offers. Take one or turn them down."
} else {
"$turn is weighing counter-offers…"
}
CatanPhase.DISCARDING -> "Discarding…"
CatanPhase.CHORE -> {
val chore = ck.chores.firstOrNull()
val who = seats.label(chore?.seat ?: state.turn)
if (chore == null || chore.seat != me) {
when (chore) {
is PillageChore -> "The barbarians have landed. $who must give up a city."
is DiscardChore -> "$who must discard ${chore.count} cards."
else -> "Waiting for $who…"
}
} else {
when (chore) {
is DiscardChore -> "Discard ${chore.count} cards."
is GiveChore -> "Give ${chore.count} card${if (chore.count == 1) "" else "s"} to ${seats.lower(chore.to)}."
is PillageChore -> "The barbarians won. Tap a city of yours to give up."
is PickDeckChore -> "Choose a progress deck to draw from."
is DropProgressChore -> "You hold too many progress cards. Give one back."
is MetropolisChore -> "Tap the city that will bear the ${track(chore.track).lowercase()} metropolis."
is AqueductChore -> "Your aqueduct pays. Take any resource."
is RelocateChore -> "Your knight was driven off. Tap where it goes."
is RemoveKnightChore -> "Tap one of your knights to remove."
is PlaceKnightChore -> "Tap where your free ${knightName(chore.level).lowercase()} stands."
is SpyChore -> "Choose a progress card to take."
is SmithChore -> "Tap a knight to promote (${chore.left} left), or stop."
}
}
}
}
}
private fun armedPrompt(kind: ProgressType): String = when (kind) {
ProgressType.ENGINEER -> "Engineer: tap a city to wall."
ProgressType.MEDICINE -> "Medicine: tap a settlement to upgrade."
ProgressType.MERCHANT -> "Merchant: tap a hex beside one of your buildings."
ProgressType.DIPLOMAT -> "Diplomat: tap an open road to remove."
ProgressType.INTRIGUE -> "Intrigue: tap an enemy knight beside your road."
ProgressType.INVENTOR -> "Inventor: tap two hexes to swap their tokens."
else -> "Tap the board."
}
/** "6 steps away", "1 step away" for the barbarian ship. */
fun barbarianSteps(steps: Int): String = "$steps ${if (steps == 1) "step" else "steps"} away"
}
@@ -21,7 +21,7 @@ enum class UpdateChannel(val label: String, val blurb: String) {
/** Marks a tag or a version name as belonging to the dev channel. */ /** Marks a tag or a version name as belonging to the dev channel. */
const val DEV_SUFFIX = "-dev" const val DEV_SUFFIX = "-dev"
/** One published build, as read off a GitHub release. */ /** One published build, as read off a channel's `latest.json`. */
data class Release( data class Release(
val tag: String, val tag: String,
val versionCode: Int, val versionCode: Int,
@@ -70,7 +70,7 @@ fun versionCodeOfTag(tag: String): Int? =
* nothing at all. * nothing at all.
* *
* Only an actual update is worth a word. Opening the app is not asking a * Only an actual update is worth a word. Opening the app is not asking a
* question, so a check that finds nothing — or that cannot reach GitHub at all * 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 * — 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 * single launch. The button in Settings *is* asking a question, and reports
* every answer including those two. * every answer including those two.
@@ -8,19 +8,17 @@ import android.provider.Settings
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/** /**
* Over-the-air updates from the repository's GitHub Releases. * Over-the-air updates from plg.wallacenet.ca (Cloudflare Pages).
* *
* Release tags are v<series>.<run-number> and the APK's versionCode is that same run * 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. * number, so comparing the two is enough to tell whether a newer build exists.
* The repository's releases must be publicly downloadable — no token is * Everything there is public — no token is embedded in the app.
* embedded in the app, and GitHub blocks anonymous access to private assets.
* *
* There is nothing to choose here. The channel comes from the running APK, so * There is nothing to choose here. The channel comes from the running APK, so
* a dev install looks only at dev builds and a production install only at * a dev install looks only at dev builds and a production install only at
@@ -48,65 +46,37 @@ class Updater(private val activity: Activity) {
} }
/** /**
* Production reads GitHub's "latest release", which by definition skips * Each channel publishes a `latest.json` next to its APK on Cloudflare
* prereleases. Dev walks the release list for the newest prerelease, which * Pages: `/prod/latest.json` and `/dev/latest.json`. The two live in one
* is what the CI pipeline publishes from the dev branch. * site but separate paths, so a build can only ever look down its own line.
*/ */
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) { private fun fetchLatestRelease(channel: UpdateChannel): Release? {
UpdateChannel.PRODUCTION -> val path = if (channel == UpdateChannel.DEV) "dev" else "prod"
getJsonObject("https://api.github.com/repos/$OWNER/$REPO/releases/latest") return getJsonObject("$BASE_URL/$path/latest.json")?.let { toRelease(it) }
?.let { toRelease(it) }
UpdateChannel.DEV ->
getJsonArray("https://api.github.com/repos/$OWNER/$REPO/releases?per_page=30")
?.let { releases ->
// GitHub returns newest first.
(0 until releases.length())
.map { releases.getJSONObject(it) }
.firstOrNull { it.optBoolean("prerelease") && !it.optBoolean("draft") }
?.let { toRelease(it) }
}
} }
private fun toRelease(json: JSONObject): Release? { private fun toRelease(json: JSONObject): Release? {
val tag = json.optString("tag_name").ifBlank { return null } val tag = json.optString("tag").ifBlank { return null }
val versionCode = versionCodeOfTag(tag) ?: return null val versionCode = versionCodeOfTag(tag) ?: return null
val channel = channelOfTag(tag, json.optBoolean("prerelease")) val apkUrl = json.optString("apkUrl").ifBlank { return null }
val assets = json.optJSONArray("assets") ?: return null
val apks = (0 until assets.length())
.map { assets.getJSONObject(it) }
.filter { it.optString("name").endsWith(".apk") }
// A production release carries the same build twice: once named for its
// version, and once under a fixed name so a permanent download link
// exists. Pick the versioned one explicitly rather than whichever was
// uploaded first, so what gets installed is traceable to its tag.
val chosen = apks.firstOrNull { it.optString("name").contains(tag) }
?: apks.firstOrNull()
?: return null
return Release( return Release(
tag = tag, tag = tag,
versionCode = versionCode, versionCode = versionCode,
apkUrl = chosen.getString("browser_download_url"), apkUrl = apkUrl,
apkName = chosen.optString("name"), apkName = json.optString("apkName"),
channel = channel, channel = channelOfTag(tag, prerelease = false),
) )
} }
private fun openConnection(url: String): HttpURLConnection = private fun getJsonObject(url: String): JSONObject? {
(URL(url).openConnection() as HttpURLConnection).apply { val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
setRequestProperty("Accept", "application/vnd.github+json")
setRequestProperty("User-Agent", "ProLibertateGames-Updater") setRequestProperty("User-Agent", "ProLibertateGames-Updater")
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 15_000 readTimeout = 15_000
} }
private fun getJsonObject(url: String): JSONObject? {
val connection = openConnection(url)
try { try {
// 404 means the channel has nothing published yet.
if (connection.responseCode != 200) return null if (connection.responseCode != 200) return null
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() }) return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
} finally { } finally {
@@ -114,16 +84,6 @@ class Updater(private val activity: Activity) {
} }
} }
private fun getJsonArray(url: String): JSONArray? {
val connection = openConnection(url)
try {
if (connection.responseCode != 200) return null
return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
} finally {
connection.disconnect()
}
}
/** Read back from the installed APK's own version name. */ /** Read back from the installed APK's own version name. */
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName()) fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
@@ -175,7 +135,6 @@ class Updater(private val activity: Activity) {
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown" activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
private companion object { private companion object {
const val OWNER = "adrianoftyriel" const val BASE_URL = "https://plg.wallacenet.ca"
const val REPO = "ProLibertateGames"
} }
} }
@@ -106,17 +106,33 @@ class CatalogTest {
} }
@Test @Test
fun `every game in the catalogue can be played`() { fun `every reference game in the catalogue can be played`() {
// There is nothing left marked "coming soon". The machinery that hides // Every game the app ships is playable. The one exception is Katan,
// unfinished games from a production build is still there and still // listed so the slot exists and marked *Coming soon* until its engine
// tested above — it simply has nothing to hide at the moment, and this // and screen land — `available = false` is what keeps an unfinished
// is what will fail the day something is added to the list before its // game out of a production build while leaving it visible on a dev one.
// engine is written.
assertTrue(GameCatalog.all.all { it.available })
assertEquals( assertEquals(
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false), listOf(GameCatalog.CATAN),
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true), GameCatalog.all.filterNot { it.available }.map { it.id },
) )
// It is also the only difference the two channels see in a menu.
val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
}
@Test
fun `a preview game opens on a dev build and nowhere else`() {
val preview = GameCatalog.all.filter { it.devPreview }
assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN))
preview.forEach { game ->
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
}
// An ordinary finished game opens everywhere.
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
} }
@Test @Test
@@ -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)))
}
}
@@ -0,0 +1,192 @@
package org.prolibertate.games.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CatanBoardTest {
// -- Geometry -----------------------------------------------------------
@Test
fun `the island has 19 hexes, 54 vertices, 72 edges and 30 coastal edges`() {
assertEquals(19, CatanBoard.HEX_COUNT)
assertEquals(54, CatanBoard.VERTEX_COUNT)
assertEquals(72, CatanBoard.EDGE_COUNT)
assertEquals("the coast is one ring of 30 edges", 30, CatanBoard.COASTAL.size)
assertEquals("nine harbour positions", 9, CatanBoard.PORT_POSITIONS.size)
}
@Test
fun `every hex has six distinct corners and every edge two distinct ends`() {
for (h in 0 until CatanBoard.HEX_COUNT) {
assertEquals("hex $h", 6, CatanBoard.HEX_CORNERS[h].size)
assertEquals("hex $h repeats a corner", 6, CatanBoard.HEX_CORNERS[h].distinct().size)
}
CatanBoard.EDGE_VERTICES.forEach { (a, b) -> assertTrue("edge loops on itself", a != b) }
}
@Test
fun `two vertices never share a lattice position`() {
assertEquals(CatanBoard.VERTEX_COUNT, CatanBoard.VERTEX_POS.distinct().size)
assertEquals(CatanBoard.HEX_COUNT, (0 until CatanBoard.HEX_COUNT)
.map { CatanBoard.hexCenter(it) }
.distinct().size)
}
@Test
fun `adjacent hexes share exactly two vertices and one edge`() {
for (h in 0 until CatanBoard.HEX_COUNT) {
for (n in CatanBoard.HEX_NEIGHBORS[h]) {
val shared = CatanBoard.HEX_CORNERS[h].toSet()
.intersect(CatanBoard.HEX_CORNERS[n].toSet())
assertEquals("hexes $h and $n", 2, shared.size)
val sharedEdge = CatanBoard.EDGE_HEXES.indices.count { e ->
CatanBoard.EDGE_HEXES[e].containsAll(listOf(h, n))
}
assertEquals("hexes $h and $n", 1, sharedEdge)
}
}
}
@Test
fun `vertices have two or three edges, each to a distinct neighbour`() {
for (v in 0 until CatanBoard.VERTEX_COUNT) {
assertTrue("vertex $v", CatanBoard.VERTEX_EDGES[v].size in 2..3)
assertEquals("vertex $v", CatanBoard.VERTEX_EDGES[v].size, CatanBoard.VERTEX_NEIGHBORS[v].size)
CatanBoard.VERTEX_NEIGHBORS[v].forEach { n ->
assertTrue(
"vertices $v and $n are not joined by an edge",
CatanBoard.VERTEX_EDGES[v].any { CatanBoard.otherEnd(it, v) == n },
)
}
}
}
@Test
fun `interior edges touch two hexes and coastal edges one`() {
CatanBoard.EDGE_HEXES.forEach { hexes -> assertTrue(hexes.size in 1..2) }
// Nineteen hexes times six edges each, counted per side: 114.
assertEquals(114, CatanBoard.EDGE_HEXES.sumOf { it.size })
val coastal = CatanBoard.EDGE_HEXES.indices.filter { CatanBoard.EDGE_HEXES[it].size == 1 }
assertEquals(CatanBoard.COASTAL.toSet(), coastal.toSet())
}
@Test
fun `the coast is one closed ring and the harbours are spread along it`() {
CatanBoard.COASTAL.forEachIndexed { i, e ->
val next = CatanBoard.COASTAL[(i + 1) % CatanBoard.COASTAL.size]
val shared = ends(e).intersect(ends(next))
assertTrue("coastal edges $e and $next are not consecutive", shared.isNotEmpty())
}
assertEquals(9, CatanBoard.PORT_POSITIONS.distinct().size)
assertTrue(CatanBoard.PORT_POSITIONS.all { it in CatanBoard.COASTAL })
val ringSpots = CatanBoard.PORT_POSITIONS.map { CatanBoard.COASTAL.indexOf(it) }.sorted()
val gaps = ringSpots.zipWithNext { a, b -> b - a } +
(CatanBoard.COASTAL.size - ringSpots.last() + ringSpots.first())
assertTrue("harbours sit closer than three edges apart: $gaps", gaps.all { it >= 3 })
}
@Test
fun `token odds follow the dice`() {
assertEquals(5, CatanBoard.numberOdds(6))
assertEquals(5, CatanBoard.numberOdds(8))
assertEquals(1, CatanBoard.numberOdds(2))
assertEquals(1, CatanBoard.numberOdds(12))
assertEquals(36, (2..12).sumOf { CatanBoard.numberOdds(it) })
}
// -- Island generation --------------------------------------------------
private val terrainCounts = mapOf(
Terrain.FOREST to 4,
Terrain.PASTURE to 4,
Terrain.FIELDS to 4,
Terrain.HILLS to 3,
Terrain.MOUNTAINS to 3,
Terrain.DESERT to 1,
)
/** The 18 number tokens dealt to the land, the seven withheld. */
private val tokens = listOf(6, 6, 8, 8, 2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
@Test
fun `every seed deals the fixed terrain mix`() {
for (seed in 1L..500L) {
val island = CatanBoard.generate(seed)
assertEquals("seed $seed", 19, island.terrains.size)
terrainCounts.forEach { (terrain, n) ->
assertEquals("seed $seed $terrain", n, island.terrains.count { it == terrain })
}
}
}
@Test
fun `every seed deals the full token set and the desert stays bare`() {
for (seed in 1L..500L) {
val island = CatanBoard.generate(seed)
val dealt = island.numbers.filter { it > 0 }
assertEquals("seed $seed", 18, dealt.size)
assertEquals("seed $seed", tokens.sorted(), dealt.sorted())
assertEquals("seed $seed: the desert bears no token", 0, island.numbers[island.robber])
assertEquals(
"seed $seed: the robber does not start on the desert",
island.terrains.indexOf(Terrain.DESERT),
island.robber,
)
}
}
@Test
fun `no seed puts two red tokens on adjacent hexes`() {
for (seed in 1L..500L) {
val island = CatanBoard.generate(seed)
val red = island.numbers.withIndex()
.filter { it.value == 6 || it.value == 8 }
.map { it.index }
assertEquals("seed $seed", 4, red.size)
for (i in red.indices) {
for (j in i + 1 until red.size) {
assertTrue(
"seed $seed: red hexes ${red[i]} and ${red[j]} touch",
!CatanBoard.hexesAdjacent(red[i], red[j]),
)
}
}
}
}
@Test
fun `every seed spreads nine harbours, five special and four generic`() {
for (seed in 1L..500L) {
val island = CatanBoard.generate(seed)
assertEquals("seed $seed", 9, island.ports.size)
assertEquals("seed $seed", 5, island.ports.count { it.resource != null })
assertEquals("seed $seed", 4, island.ports.count { it.resource == null })
assertTrue("seed $seed", island.ports.all { port ->
port.ratio == if (port.resource == null) 3 else 2
})
assertEquals(
"seed $seed: every resource has its own harbour",
Resource.entries,
island.ports.mapNotNull { it.resource }.sorted(),
)
assertEquals("seed $seed", 9, island.ports.map { it.edge }.distinct().size)
assertTrue("seed $seed", island.ports.all { it.edge in CatanBoard.COASTAL })
}
}
@Test
fun `the same seed builds the same island`() {
for (seed in listOf(0L, 7L, 42L, 99L, -1L)) {
assertEquals("seed $seed", CatanBoard.generate(seed), CatanBoard.generate(seed))
}
}
/** The two vertices of [edge], as a set. */
private fun ends(edge: Int): Set<Int> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return setOf(a, b)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* Engines playing Cities & Knights against each other. These are the
* playtests: every move is checked for legality, the state is round-tripped
* through its wire format as it goes, nothing may leave a seat without a move,
* and the game has to reach a result.
*/
class CatanCkSelfPlayTest {
private val json = Json { encodeDefaults = true }
private fun config(options: CatanOptions, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
/** What one game looked like, for the summary printed at the end. */
private data class Report(
val seed: Long,
val moves: Int,
val turns: Int,
val attacks: Int,
val progressPlayed: Int,
val metropolises: Int,
val winner: Int?,
val points: List<Int>,
)
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 30_000): Pair<CatanState, Report> {
val ai = CatanAi()
var state = CatanRules.initialState(config(options, seed))
var moves = 0
var attacks = 0
var progress = 0
while (!CatanRules.isFinished(state)) {
assertTrue("seed $seed ran past $cap moves; log tail: ${state.log.takeLast(8)}", moves++ < cap)
val seat = CatanRules.currentSeat(state)
?: error("seed $seed: nobody on the clock in ${state.phase}; log tail: ${state.log.takeLast(8)}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("seed $seed: seat $seat has no legal move in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue(
"seed $seed: seat $seat chose the illegal move $move in ${state.phase}; " +
"log tail: ${state.log.takeLast(5)}",
CatanRules.isLegalMove(state, seat, move),
)
val before = state.ck!!.attacked
val wasAttacks = state.log.count { it.contains("The barbarians land") }
state = CatanRules.applyMove(state, seat, move)
attacks += state.log.count { it.contains("The barbarians land") } - wasAttacks
if (move is PlayProgress) progress++
if (!before && state.ck!!.attacked) Unit
if (moves % 97 == 0) {
val back = CatanRules.decodeState(CatanRules.encodeState(state))
assertEquals("seed $seed: the state did not survive its wire format", state, back)
// A seat's view never carries another seat's progress cards or the decks.
for (viewer in 0 until options.playerCount) {
val view = CatanRules.viewFor(state, viewer)
assertTrue(view.ck!!.decks.values.all { it.isEmpty() })
for (other in 0 until options.playerCount) {
if (other != viewer) {
val spied = (state.ck!!.chores.firstOrNull() as? SpyChore)
val allowed = spied != null && spied.seat == viewer && spied.target == other
if (!allowed) assertTrue(view.ck!!.progressHands[other].isEmpty())
}
}
}
}
checkInvariants(state, seed)
}
val ck = state.ck!!
return state to Report(
seed = seed,
moves = moves,
turns = state.turnNumber,
attacks = attacks,
progressPlayed = progress,
metropolises = ck.metropolises.size,
winner = state.winner,
points = CatanRules.publicVictoryPoints(state),
)
}
/** Things that must hold in every state of every game. */
private fun checkInvariants(state: CatanState, seed: Long) {
val ck = state.ck!!
val tag = "seed $seed turn ${state.turnNumber}"
// Cards are conserved: no good is held in more copies than the bank started with.
for (good in Good.entries) {
val held = (0 until state.playerCount).sumOf { CatanCk.goodsOf(state, it)[good] ?: 0 }
val cap = if (good.isCommodity) BANK_PER_COMMODITY else BANK_PER_RESOURCE
assertTrue("$tag: $held ${good} cards exist, the bank only had $cap", held in 0..cap)
}
for (seat in 0 until state.playerCount) {
assertTrue(
"$tag: seat $seat holds negative cards",
CatanCk.goodsOf(state, seat).values.all { it >= 0 },
)
for (level in 1..3) {
assertTrue(
"$tag: seat $seat has more than two level-$level knights",
ck.knights.count { it.owner == seat && it.level == level } <= KNIGHTS_PER_LEVEL,
)
}
assertTrue("$tag: too many progress cards in seat $seat's hand", ck.progressHands[seat].size <= PROGRESS_HAND_MAX + 1)
assertTrue("$tag: seat $seat has more than three walls", CatanCk.wallsOf(state, seat) <= MAX_WALLS)
assertTrue(
"$tag: seat $seat has more than five settlements",
state.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) } <= MAX_SETTLEMENTS,
)
assertTrue(
"$tag: seat $seat has more than four cities",
state.buildings.count { it == CatanBuilding(BuildingType.CITY, seat) } <= MAX_CITIES,
)
}
// A knight stands on a crossing nothing else stands on, and no two share one.
assertEquals("$tag: two knights share a crossing", ck.knights.size, ck.knights.map { it.vertex }.toSet().size)
for (k in ck.knights) {
assertTrue("$tag: a knight stands on a building at ${k.vertex}", state.buildings[k.vertex] == null)
}
// Metropolises stand on cities of their owners.
for ((_, m) in ck.metropolises) {
assertEquals(
"$tag: a metropolis is not on its owner's city",
CatanBuilding(BuildingType.CITY, m.seat),
state.buildings[m.vertex],
)
}
// Every progress card exists exactly once, in a deck or a hand, or was played face up.
val cards = ck.decks.values.sumOf { it.size } + ck.progressHands.sumOf { it.size } + ck.progressVp.sum()
assertEquals("$tag: progress cards were lost or duplicated", 54, cards)
assertTrue("$tag: barbarians out of range", ck.barbarians in 1..BARBARIAN_STEPS)
}
@Test
fun `three engines play Cities and Knights to a finish`() {
val reports = (1L..12L).map { seed ->
val (state, report) = playOut(
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
seed,
)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
val winner = state.winner
assertTrue("seed $seed finished without a winner", winner != null)
assertTrue(
"seed $seed: the winner fell short of the target",
CatanRules.victoryPoints(state, winner!!) >= 13,
)
report
}
reports.forEach { println("CK3 $it") }
}
@Test
fun `four engines play Cities and Knights to a finish`() {
val reports = (101L..108L).map { seed ->
val (state, report) = playOut(
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
seed,
)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
report
}
reports.forEach { println("CK4 $it") }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
package org.prolibertate.games.game.engine
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class SavedGameStoreTest {
@get:Rule
val folder = TemporaryFolder()
private fun store() = SavedGameStore(File(folder.root, "saves"))
private fun match(gameId: String = "chess", state: String = "{\"turn\":1}", at: Long = 1_000L) = SavedMatch(
gameId = gameId,
config = TableConfig(
gameId = gameId,
seats = listOf(
PlayerSlot(0, "You", PlayerKind.HUMAN_LOCAL, 0),
PlayerSlot(1, "Computer", PlayerKind.AI, 1),
),
optionsJson = "{}",
seed = 42L,
),
localSeat = 0,
stateJson = state,
savedAtMillis = at,
summary = "White to move",
)
@Test
fun `a saved game comes back exactly as it went in`() {
val s = store()
s.save(match())
assertEquals(match(), s.load("chess"))
}
@Test
fun `there is nothing to load before anything is saved`() {
assertNull(store().load("chess"))
assertTrue(store().all().isEmpty())
}
@Test
fun `a game keeps one slot, and saving again replaces it`() {
val s = store()
s.save(match(state = "one", at = 1))
s.save(match(state = "two", at = 2))
assertEquals("two", s.load("chess")!!.stateJson)
assertEquals(1, s.all().size)
}
@Test
fun `clearing forgets the game and leaves the others`() {
val s = store()
s.save(match("chess"))
s.save(match("golf"))
s.clear("chess")
assertNull(s.load("chess"))
assertEquals("golf", s.load("golf")!!.gameId)
s.clear("chess") // clearing nothing is fine
}
@Test
fun `the most recently played game is listed first`() {
val s = store()
s.save(match("chess", at = 10))
s.save(match("golf", at = 30))
s.save(match("catan", at = 20))
assertEquals(listOf("golf", "catan", "chess"), s.all().map { it.gameId })
}
@Test
fun `a file that cannot be read is dropped rather than thrown`() {
val s = store()
s.save(match())
File(folder.root, "saves/chess.json").writeText("{ this is not json")
assertNull(s.load("chess"))
assertFalse(File(folder.root, "saves/chess.json").exists())
}
@Test
fun `a file holding another game is not served as this one`() {
val s = store()
s.save(match("golf"))
File(folder.root, "saves/golf.json").copyTo(File(folder.root, "saves/chess.json"))
assertNull(s.load("chess"))
}
@Test
fun `a save that was cut off half way leaves the previous position`() {
val s = store()
s.save(match(state = "good"))
// What a kill mid-write leaves behind: a stray temporary file.
File(folder.root, "saves/chess.json.tmp").writeText("{ half a wri")
assertEquals("good", s.load("chess")!!.stateJson)
assertEquals(1, s.all().size)
}
@Test
fun `a game id cannot climb out of the folder`() {
val bad = runCatching { store().load("../secret") }
assertTrue(bad.isFailure)
}
@Test
fun `an unknown field from a newer build does not stop a save loading`() {
val s = store()
s.save(match())
val file = File(folder.root, "saves/chess.json")
file.writeText(file.readText().replaceFirst("{", "{\"addedLater\":true,"))
assertEquals("chess", s.load("chess")!!.gameId)
}
}
@@ -0,0 +1,191 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.prolibertate.games.game.engine.GameAi
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.SavedGameStore
import org.prolibertate.games.game.engine.TableConfig
import java.io.File
/**
* Saving a table as it is played, and starting a table from a save.
*
* The game here is a counter: one seat, a move adds one, and it is finished at
* [GOAL]. It has hidden state — [Counter.secret] — so the tests can check that
* what is saved is the true state, not the view a player is shown.
*/
class MatchSaverTest {
@get:Rule
val folder = TemporaryFolder()
private val scopes = mutableListOf<CoroutineScope>()
@After
fun tearDown() {
scopes.forEach { it.cancel() }
}
private data class Counter(val n: Int, val secret: String)
private object Rules : GameRules<Counter, String> {
override val gameId = "counter"
override fun initialState(config: TableConfig) = Counter(0, "hidden-${config.seed}")
override fun currentSeat(state: Counter): Int? = if (state.n >= GOAL) null else 0
override fun legalMoves(state: Counter, seat: Int) = if (state.n >= GOAL) emptyList() else listOf("add")
override fun applyMove(state: Counter, seat: Int, move: String) = state.copy(n = state.n + 1)
override fun isFinished(state: Counter) = state.n >= GOAL
override fun summary(state: Counter) = "at ${state.n}"
override fun viewFor(state: Counter, seat: Int) = state.copy(secret = "")
override fun encodeState(state: Counter) = "${state.n}|${state.secret}"
override fun decodeState(json: String): Counter {
val (n, secret) = json.split("|", limit = 2)
return Counter(n.toInt(), secret)
}
override fun encodeMove(move: String) = move
override fun decodeMove(json: String) = json
}
private val config = TableConfig(
gameId = "counter",
seats = listOf(PlayerSlot(0, "You", PlayerKind.HUMAN_LOCAL, 0)),
optionsJson = "{}",
seed = 7L,
)
private fun scope() = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
private fun controller(): MatchController<Counter, String> = MatchController(
rules = Rules,
ai = object : GameAi<Counter, String> {
override fun chooseMove(state: Counter, seat: Int, legal: List<String>) = legal.first()
},
config = config,
scope = scope(),
role = MatchController.Role.HOST,
localSeats = setOf(0),
primarySeat = 0,
)
private fun store() = SavedGameStore(File(folder.root, "saves"))
private fun waitFor(what: String, check: () -> Boolean) = runBlocking {
repeat(200) {
if (check()) return@runBlocking
delay(10)
}
error("Timed out waiting for $what")
}
private fun saver(s: SavedGameStore, c: MatchController<Counter, String>) =
MatchSaver(s, "counter", config, localSeat = 0, scope = scope(), clock = { 123L }).also {
it.start()
c.onHostState = it::offer
}
@Test
fun `every position the table reaches is kept, with the true state`() {
val s = store()
val c = controller()
saver(s, c)
c.startAsHost(emptyList())
waitFor("the deal") { c.state.value != null }
c.submit("add")
c.submit("add")
waitFor("two moves") { s.load("counter")?.stateJson?.startsWith("2|") == true }
val saved = s.load("counter")!!
assertEquals("2|hidden-7", saved.stateJson) // the hidden part is saved; the view blanks it
assertEquals("at 2", saved.summary)
assertEquals(123L, saved.savedAtMillis)
assertEquals(config, saved.config)
}
@Test
fun `a table started from a save carries on from where it stood`() {
val s = store()
val first = controller()
saver(s, first)
first.startAsHost(emptyList())
waitFor("the deal") { first.state.value != null }
repeat(3) { first.submit("add") }
waitFor("three moves") { s.load("counter")?.stateJson?.startsWith("3|") == true }
first.close()
val second = controller()
assertTrue(second.restore(s.load("counter")!!.stateJson))
second.startAsHost(emptyList())
waitFor("the restored table") { second.state.value != null }
assertEquals(3, second.state.value!!.n)
second.submit("add")
waitFor("a move after resuming") { second.state.value!!.n == 4 }
}
@Test
fun `a save the game cannot read is refused and the table is dealt afresh`() {
val c = controller()
assertFalse(c.restore("not a counter"))
c.startAsHost(emptyList())
waitFor("a fresh deal") { c.state.value != null }
assertEquals(0, c.state.value!!.n)
}
@Test
fun `a finished game is not kept`() {
val s = store()
val c = controller()
saver(s, c)
c.startAsHost(emptyList())
waitFor("the deal") { s.load("counter") != null }
repeat(GOAL) { c.submit("add") }
waitFor("the finish") { c.finished.value }
waitFor("the slot to clear") { s.load("counter") == null }
}
@Test
fun `flush writes the position the background writer has not reached`() {
val s = store()
val saver = MatchSaver(s, "counter", config, 0, scope(), clock = { 1L })
// Not started: nothing is written in the background at all.
saver.offer(MatchController.HostSnapshot(false, "at 5") { "5|x" })
assertNull(s.load("counter"))
saver.flush()
assertEquals("5|x", s.load("counter")!!.stateJson)
}
@Test
fun `ending a game on purpose forgets it and writes no more`() {
val s = store()
val saver = MatchSaver(s, "counter", config, 0, scope(), clock = { 1L })
saver.offer(MatchController.HostSnapshot(false, "at 1") { "1|x" })
saver.flush()
assertNotNull(s.load("counter"))
saver.discard()
assertNull(s.load("counter"))
// Leaving after ending must not put it back.
saver.offer(MatchController.HostSnapshot(false, "at 2") { "2|x" })
saver.flush()
assertNull(s.load("counter"))
}
private companion object {
const val GOAL = 5
}
}
@@ -0,0 +1,177 @@
package org.prolibertate.games.net
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.GameAi
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.ui.seatCountFor
import org.prolibertate.games.game.backgammon.BackgammonAi
import org.prolibertate.games.game.backgammon.BackgammonOptions
import org.prolibertate.games.game.backgammon.BackgammonRules
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.checkers.CheckersAi
import org.prolibertate.games.game.checkers.CheckersOptions
import org.prolibertate.games.game.checkers.CheckersRules
import org.prolibertate.games.game.chess.ChessAi
import org.prolibertate.games.game.chess.ChessOptions
import org.prolibertate.games.game.chess.ChessRules
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
import org.prolibertate.games.game.cribbage.CribbageAi
import org.prolibertate.games.game.cribbage.CribbageOptions
import org.prolibertate.games.game.cribbage.CribbageRules
import org.prolibertate.games.game.euchre.EuchreAi
import org.prolibertate.games.game.euchre.EuchreOptions
import org.prolibertate.games.game.euchre.EuchreRules
import org.prolibertate.games.game.freecell.FreeCellOptions
import org.prolibertate.games.game.freecell.FreeCellRules
import org.prolibertate.games.game.golf.GolfAi
import org.prolibertate.games.game.golf.GolfOptions
import org.prolibertate.games.game.golf.GolfRules
import org.prolibertate.games.game.hearts.HeartsAi
import org.prolibertate.games.game.hearts.HeartsOptions
import org.prolibertate.games.game.hearts.HeartsRules
import org.prolibertate.games.game.kaiser.KaiserAi
import org.prolibertate.games.game.kaiser.KaiserOptions
import org.prolibertate.games.game.kaiser.KaiserRules
import org.prolibertate.games.game.klondike.KlondikeAi
import org.prolibertate.games.game.klondike.KlondikeOptions
import org.prolibertate.games.game.klondike.KlondikeRules
import org.prolibertate.games.game.mastermind.MastermindAi
import org.prolibertate.games.game.mastermind.MastermindOptions
import org.prolibertate.games.game.mastermind.MastermindRules
import org.prolibertate.games.game.morris.MorrisAi
import org.prolibertate.games.game.morris.MorrisOptions
import org.prolibertate.games.game.morris.MorrisRules
import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules
import org.prolibertate.games.game.pirates.PiratesAi
import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.pirates.PiratesRules
import org.prolibertate.games.game.president.PresidentAi
import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.president.PresidentRules
import org.prolibertate.games.game.pyramid.PyramidOptions
import org.prolibertate.games.game.pyramid.PyramidRules
import org.prolibertate.games.game.sequence.SequenceAi
import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.sequence.SequenceRules
import org.prolibertate.games.game.solitaire.FirstLegalAi
import org.prolibertate.games.game.spider.SpiderOptions
import org.prolibertate.games.game.spider.SpiderRules
import org.prolibertate.games.game.tayu.TayuAi
import org.prolibertate.games.game.tayu.TayuOptions
import org.prolibertate.games.game.tayu.TayuRules
import org.prolibertate.games.game.wizard.WizardAi
import org.prolibertate.games.game.wizard.WizardOptions
import org.prolibertate.games.game.wizard.WizardRules
import org.prolibertate.games.game.yahtzee.YahtzeeAi
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
import org.prolibertate.games.game.yahtzee.YahtzeeRules
/**
* Every game can be saved and picked up again.
*
* For each game in the catalogue a table is dealt and played by the computer, and
* at every single move the whole true state is encoded and decoded again — which
* is exactly what saving it to disk and resuming does — and play carries on from
* the decoded copy. A game whose state does not survive that, or that cannot make
* a legal move from a state it has been through it, fails here.
*/
class SaveRoundTripAllGamesTest {
private val json = Json { encodeDefaults = true }
private fun <S : Any, M : Any> exercise(
gameId: String,
rules: GameRules<S, M>,
ai: GameAi<S, M>,
optionsJson: String,
steps: Int = 120,
) {
val seats = seatCountFor(gameId, optionsJson)
val config = TableConfig(
gameId = gameId,
seats = (0 until seats).map {
PlayerSlot(it, "P$it", if (it == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, it)
},
optionsJson = optionsJson,
seed = 20240607L,
)
var state = rules.initialState(config)
var moves = 0
repeat(steps) {
if (rules.isFinished(state)) return
// The save and the resume: what goes to disk, and what comes back.
val saved = rules.encodeState(state)
val back = rules.decodeState(saved)
assertEquals("$gameId: a saved state encodes differently after a resume", saved, rules.encodeState(back))
assertEquals(
"$gameId: a resumed state is not on the same seat's turn",
rules.currentSeat(state),
rules.currentSeat(back),
)
assertEquals(
"$gameId: a resumed state has different legal moves",
rules.currentSeat(state)?.let { rules.legalMoves(state, it) },
rules.currentSeat(back)?.let { rules.legalMoves(back, it) },
)
state = back
val seat = rules.currentSeat(state) ?: return // waiting on a scoreboard, which is not a move
val legal = rules.legalMoves(state, seat)
if (legal.isEmpty()) return
val move = ai.chooseMove(state, seat, legal)
assertTrue("$gameId: move $move is not legal after a resume", rules.isLegalMove(state, seat, move))
state = rules.applyMove(state, seat, move)
moves++
}
assertTrue("$gameId: made no moves", moves > 0)
}
private inline fun <reified O> opts(o: O) = json.encodeToString(o)
@Test fun euchre() = exercise(GameCatalog.EUCHRE, EuchreRules, EuchreAi(), opts(EuchreOptions()))
@Test fun sequence() = exercise(GameCatalog.SEQUENCE, SequenceRules, SequenceAi(), opts(SequenceOptions()))
@Test fun president() = exercise(GameCatalog.PRESIDENT, PresidentRules, PresidentAi(), opts(PresidentOptions()))
@Test fun golf() = exercise(GameCatalog.GOLF, GolfRules, GolfAi(), opts(GolfOptions()))
@Test fun kaiser() = exercise(GameCatalog.KAISER, KaiserRules, KaiserAi(), opts(KaiserOptions()))
@Test fun wizard() = exercise(GameCatalog.WIZARD, WizardRules, WizardAi(), opts(WizardOptions()))
@Test fun crazyEights() = exercise(GameCatalog.CRAZY_EIGHTS, CrazyEightsRules, CrazyEightsAi(), opts(CrazyEightsOptions()))
@Test fun cribbage() = exercise(GameCatalog.CRIBBAGE, CribbageRules, CribbageAi(), opts(CribbageOptions()))
@Test fun tayu() = exercise(GameCatalog.TAYU, TayuRules, TayuAi(), opts(TayuOptions()))
@Test fun checkers() = exercise(GameCatalog.CHECKERS, CheckersRules, CheckersAi(), opts(CheckersOptions()))
@Test fun backgammon() = exercise(GameCatalog.BACKGAMMON, BackgammonRules, BackgammonAi(), opts(BackgammonOptions()))
@Test fun mastermind() = exercise(GameCatalog.MASTERMIND, MastermindRules, MastermindAi(), opts(MastermindOptions()))
@Test fun pirates() = exercise(GameCatalog.PIRATES, PiratesRules, PiratesAi(), opts(PiratesOptions()))
@Test fun hearts() = exercise(GameCatalog.HEARTS, HeartsRules, HeartsAi, opts(HeartsOptions()))
@Test fun pegSolitaire() = exercise(GameCatalog.PEG_SOLITAIRE, PegSolitaireRules, PegSolitaireAi, opts(PegSolitaireOptions()))
@Test fun yahtzee() = exercise(GameCatalog.YAHTZEE, YahtzeeRules, YahtzeeAi, opts(YahtzeeOptions()))
@Test fun klondike() = exercise(GameCatalog.KLONDIKE, KlondikeRules, KlondikeAi, opts(KlondikeOptions()))
@Test fun freecell() = exercise(GameCatalog.FREECELL, FreeCellRules, FirstLegalAi(), opts(FreeCellOptions()))
@Test fun spider() = exercise(GameCatalog.SPIDER, SpiderRules, FirstLegalAi(), opts(SpiderOptions()))
@Test fun pyramid() = exercise(GameCatalog.PYRAMID, PyramidRules, FirstLegalAi(), opts(PyramidOptions()))
@Test fun morris() = exercise(GameCatalog.MORRIS, MorrisRules, MorrisAi(), opts(MorrisOptions()))
@Test fun chess() = exercise(GameCatalog.CHESS, ChessRules, ChessAi(), opts(ChessOptions()), steps = 24)
@Test fun catan() = exercise(GameCatalog.CATAN, CatanRules, CatanAi(), opts(CatanOptions()), steps = 400)
@Test
fun catanCitiesAndKnights() = exercise(
GameCatalog.CATAN,
CatanRules,
CatanAi(),
opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)),
steps = 600,
)
}
@@ -145,6 +145,11 @@ class ScorekeeperSessionTest {
await("$name to be asking") { host.state.requests.any { it.id == deviceId } } await("$name to be asking") { host.state.requests.any { it.id == deviceId } }
host.session.allow(deviceId) host.session.allow(deviceId)
await("$name to be let in") { guest.state.value.connected } await("$name to be let in") { guest.state.value.connected }
// The sheet follows the connection over the socket, so `connected` can
// flip while it is still in flight. Callers read the guest's sheet at
// once (its players, its rounds), and on a slow runner that was an empty
// one: wait until the guest is looking at the host's.
await("$name to receive the sheet") { guest.state.value.sheet == host.state.sheet }
return guest return guest
} }
@@ -164,6 +169,9 @@ class ScorekeeperSessionTest {
await("the guest to be let in") { guest.state.value.connected } await("the guest to be let in") { guest.state.value.connected }
assertFalse(guest.state.value.awaiting) assertFalse(guest.state.value.awaiting)
// The sheet follows the connection over the socket, so on a slow runner
// it can still be in flight when `connected` flips: wait for it.
await("the guest to receive the sheet") { guest.state.value.sheet == host.state.sheet }
assertEquals(host.state.sheet, guest.state.value.sheet) assertEquals(host.state.sheet, guest.state.value.sheet)
assertTrue(guest.state.value.sheet.started) assertTrue(guest.state.value.sheet.started)
assertTrue(host.state.requests.isEmpty()) assertTrue(host.state.requests.isEmpty())
@@ -0,0 +1,27 @@
package org.prolibertate.games.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class SavedGameTextTest {
private val minute = 60_000L
private val hour = 60 * minute
private val day = 24 * hour
@Test
fun `how long ago reads like a person would say it`() {
assertEquals("just now", agoText(1_000, 1_000))
assertEquals("just now", agoText(30_000, 0))
assertEquals("a minute ago", agoText(minute + 20_000, 0))
assertEquals("5 minutes ago", agoText(5 * minute, 0))
assertEquals("an hour ago", agoText(90 * minute, 0))
assertEquals("3 hours ago", agoText(3 * hour, 0))
assertEquals("yesterday", agoText(30 * hour, 0))
assertEquals("4 days ago", agoText(4 * day, 0))
}
@Test
fun `a clock that ran backwards does not say nonsense`() {
assertEquals("just now", agoText(0, 10 * minute))
}
}
@@ -0,0 +1,291 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.DismissCounters
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DiscardGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.HireKnight
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.PickDeck
import org.prolibertate.games.game.catan.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.PromoteKnight
import org.prolibertate.games.game.catan.RelocateKnight
import org.prolibertate.games.game.catan.RemoveKnight
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.SpyTake
import org.prolibertate.games.game.catan.TakeAqueduct
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* Cities & Knights has no device in this test suite, so the screen's logic is
* checked another way: engines play whole games, and at every position the
* player on the clock is treated as the local one. For every move the engine says
* is legal, [CatanInteraction] (and the dialogs' own filters) must offer a way to
* make it — a tap, a button or a dialog choice. A legal move that no control can
* produce would be a stuck game on a real phone.
*/
class CatanCkInteractionTest {
private val json = Json { encodeDefaults = true }
private fun config(players: Int, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
),
seed = seed,
)
/** How a move reaches the engine from the screen, or null if nothing can produce it. */
private fun reachable(state: CatanState, legal: List<CatanMove>, move: CatanMove): String? {
fun tapVertex(vertex: Int, building: BuildKind? = null, moving: Int? = null, armed: ArmedCard? = null): TapOutcome =
CatanInteraction.onVertexTap(vertex, legal, building, null, moving, armed)
return when (move) {
is SetupPlace -> {
val vertex = CatanInteraction.targets(state, legal, null, null).vertices
val edges = CatanInteraction.targets(state, legal, null, move.vertex).edges
"setup".takeIf {
move.vertex in vertex && move.edge in edges &&
CatanInteraction.onVertexTap(move.vertex, legal, null, null) is TapOutcome.PickSetupVertex &&
CatanInteraction.onEdgeTap(move.edge, legal, null, move.vertex) == TapOutcome.Submit(move)
}
}
is BuildRoad -> {
val targets = CatanInteraction.targets(state, legal, BuildKind.ROAD, null)
"road".takeIf {
move.edge in targets.edges &&
CatanInteraction.onEdgeTap(move.edge, legal, BuildKind.ROAD, null) == TapOutcome.Submit(move)
}
}
is BuildSettlement -> viaBuild(state, legal, BuildKind.SETTLEMENT, move.vertex, move)
is BuildCity -> viaBuild(state, legal, BuildKind.CITY, move.vertex, move)
is BuildWall -> viaBuild(state, legal, BuildKind.WALL, move.vertex, move)
is HireKnight -> viaBuild(state, legal, BuildKind.KNIGHT, move.vertex, move)
is MoveRobber -> {
val hex = CatanBoard.hexIndex.getValue(move.hex)
val targets = CatanInteraction.targets(state, legal, null, null)
val tap = CatanInteraction.onHexTap(hex, legal)
"robber".takeIf {
hex in targets.hexes &&
(tap == TapOutcome.Submit(move) || (tap is TapOutcome.PickRobberVictim && move.victim in tap.victims))
}
}
is MoveKnight -> {
val targets = CatanInteraction.targets(state, legal, null, null, movingKnight = move.from)
"moveKnight".takeIf {
move.to in targets.vertices && tapVertex(move.to, moving = move.from) == TapOutcome.Submit(move) &&
move.from in CatanInteraction.targets(state, legal, null, null).knights &&
CatanInteraction.knightActions(legal, move.from).any { it is MoveKnight }
}
}
is ActivateKnight, is ChaseRobber, is PromoteKnight -> {
val vertex = when (move) {
is ActivateKnight -> move.vertex
is ChaseRobber -> move.vertex
else -> (move as PromoteKnight).vertex
}
val smith = legal.any { it is EndSmith }
if (move is PromoteKnight && smith) {
val targets = CatanInteraction.targets(state, legal, null, null)
"smith".takeIf {
vertex in targets.vertices && tapVertex(vertex) == TapOutcome.Submit(move)
}
} else {
val targets = CatanInteraction.targets(state, legal, null, null)
"knightMenu".takeIf {
vertex in targets.knights && tapVertex(vertex) == TapOutcome.PickKnight(vertex) &&
move in CatanInteraction.knightActions(legal, vertex)
}
}
}
is PillageCity -> viaChore(state, legal, move.vertex, move)
is PlaceMetropolis -> viaChore(state, legal, move.vertex, move)
is RelocateKnight -> viaChore(state, legal, move.to, move)
is RemoveKnight -> viaChore(state, legal, move.vertex, move)
is PlaceKnight -> viaChore(state, legal, move.vertex, move)
is PlayProgress -> viaCard(state, legal, move)
is BuyImprovement -> "improve".takeIf { move.track in CatanInteraction.improvable(legal) }
is GoodsBankTrade ->
"bankTrade".takeIf { move.get in CatanInteraction.goodsBankTrades(legal)[move.give].orEmpty() }
RollDice(), is RollDice -> "roll".takeIf { CatanInteraction.canRoll(legal) }
EndTurn -> "endTurn".takeIf { CatanInteraction.canEndTurn(legal) }
EndFreeRoads -> "endRoads".takeIf { CatanInteraction.canEndFreeRoads(legal) }
EndSmith -> "endSmith".takeIf { CatanInteraction.canEndSmith(legal) }
AcceptTrade, DeclineTrade -> "tradeReply".takeIf { CatanInteraction.tradeResponses(legal) != (false to false) }
// Answered in a dialog that offers exactly the moves in the legal list.
is PickDeck, is DropProgress, is TakeAqueduct, is SpyTake -> "choreDialog"
// Structural: the discard and give dialogs build these and ask the engine.
is DiscardGoods, is GiveGoods -> "goodsDialog"
is OfferGoods, is CounterGoods -> "tradeDialog"
is AcceptCounter, DismissCounters -> "counterDialog"
else -> null
}
}
private fun viaBuild(state: CatanState, legal: List<CatanMove>, kind: BuildKind, vertex: Int, move: CatanMove): String? {
val targets = CatanInteraction.targets(state, legal, kind, null)
return "build $kind".takeIf {
vertex in targets.vertices &&
CatanInteraction.onVertexTap(vertex, legal, kind, null) == TapOutcome.Submit(move) &&
kind in CatanInteraction.canBuild(legal)
}
}
private fun viaChore(state: CatanState, legal: List<CatanMove>, vertex: Int, move: CatanMove): String? {
val targets = CatanInteraction.targets(state, legal, null, null)
return "chore".takeIf {
vertex in targets.vertices &&
CatanInteraction.onVertexTap(vertex, legal, null, null) == TapOutcome.Submit(move)
}
}
private fun viaCard(state: CatanState, legal: List<CatanMove>, move: PlayProgress): String? {
val seat = CatanRules.currentSeat(state) ?: return null
val entry = CatanInteraction.progressHand(state, seat, legal).firstOrNull { it.card.id == move.card }
?: return null
val kind = entry.card.kind
if (move !in entry.plays) return null
if (kind !in CatanInteraction.BOARD_CARDS) return "cardDialog"
val armed = ArmedCard(kind, entry.plays)
val targets = CatanInteraction.targets(state, legal, null, null, armed = armed)
return when (kind) {
org.prolibertate.games.game.catan.ProgressType.DIPLOMAT ->
"armed".takeIf {
move.edge in targets.edges &&
CatanInteraction.onEdgeTap(move.edge!!, legal, null, null, armed) == TapOutcome.Submit(move)
}
org.prolibertate.games.game.catan.ProgressType.MERCHANT ->
"armed".takeIf {
move.hexA in targets.hexes &&
CatanInteraction.onHexTap(move.hexA!!, legal, armed) == TapOutcome.Submit(move)
}
org.prolibertate.games.game.catan.ProgressType.INVENTOR -> {
val first = CatanInteraction.onHexTap(move.hexA!!, legal, armed)
if (first != TapOutcome.PickFirstHex(move.hexA!!)) return null
val second = armed.copy(firstHex = move.hexA)
val partners = CatanInteraction.targets(state, legal, null, null, armed = second).hexes
"armed".takeIf {
move.hexB in partners &&
CatanInteraction.onHexTap(move.hexB!!, legal, second) == TapOutcome.Submit(move)
}
}
else ->
"armed".takeIf {
move.vertex in targets.vertices &&
CatanInteraction.onVertexTap(move.vertex!!, legal, null, null, null, armed) == TapOutcome.Submit(move)
}
}
}
private fun playAndCheck(players: Int, seed: Long): Map<String, Int> {
val ai = CatanAi()
var state = CatanRules.initialState(config(players, seed))
val seen = mutableMapOf<String, Int>()
var moves = 0
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
val seat = CatanRules.currentSeat(state) ?: break
val legal = CatanRules.legalMoves(state, seat)
for (move in legal) {
val how = reachable(state, legal, move)
assertTrue(
"seed $seed: no control produces $move in ${state.phase} " +
"(chore ${state.ck?.chores?.firstOrNull()}); log tail: ${state.log.takeLast(3)}",
how != null,
)
seen.merge(how!!, 1, Int::plus)
}
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
return seen
}
@Test
fun `every legal move in a full game has a control that produces it`() {
val total = mutableMapOf<String, Int>()
listOf(3 to 21L, 4 to 22L, 3 to 23L, 4 to 24L).forEach { (players, seed) ->
playAndCheck(players, seed).forEach { (k, v) -> total.merge(k, v, Int::plus) }
}
println("CK UI coverage: ${total.toSortedMap()}")
// The ways the screen builds, moves and answers all got exercised.
listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn")
.forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) }
}
@Test
fun `a sleeping knight with nothing to do can still be tapped for its menu`() {
val base = CatanRules.initialState(config(3, 5L))
.copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
val state = base.copy(
ck = base.ck!!.copy(knights = listOf(org.prolibertate.games.game.catan.Knight(10, 0, 1))),
)
val legal = CatanRules.legalMoves(state, 0)
assertTrue("no grain, so nothing to do", CatanInteraction.knightActions(legal, 10).isEmpty())
val targets = CatanInteraction.targets(state, legal, null, null, localSeat = 0)
assertTrue(10 in targets.idleKnights)
assertTrue("no tap ring for a knight with nothing to do", 10 !in targets.knights)
assertTrue(10 in targets.tappableVertices)
assertEquals(
TapOutcome.PickKnight(10),
CatanInteraction.onVertexTap(10, legal, null, null, ownKnights = setOf(10)),
)
// On somebody else's turn the legal list is empty and nothing is offered.
assertTrue(CatanInteraction.targets(state, emptyList(), null, null, localSeat = 0).idleKnights.isEmpty())
}
}
@@ -0,0 +1,75 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.ui.catanDefaultPoints
import org.prolibertate.games.ui.catanPointsChoices
import org.prolibertate.games.ui.catanSwitchRules
class CatanCkTextTest {
private val json = Json { encodeDefaults = true }
@Test
fun `every position of a Cities and Knights game has a prompt for every seat`() {
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
),
seed = 31L,
)
var state = CatanRules.initialState(config)
val ai = CatanAi()
var moves = 0
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
for (me in 0 until 3) {
val seats = CatanSeats(me, listOf("A", "B", "C"))
val text = CatanText.prompt(state, seats, setupVertexChosen = false, building = null)
assertTrue("blank prompt in ${state.phase} for seat $me", text.isNotBlank())
}
val seat = CatanRules.currentSeat(state) ?: break
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat)))
}
}
@Test
fun `every progress card has a name and a description`() {
ProgressType.entries.forEach {
assertTrue(CatanText.progress(it).isNotBlank())
assertTrue("no help for $it", CatanText.progressHelp(it).length > 10)
}
}
@Test
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
val base = CatanOptions()
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
// A short game picked on purpose is kept when both menus have it.
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
assertEquals(8, short.pointsToWin)
assertEquals(3, ck.playerCount)
}
}
@@ -0,0 +1,53 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanBuilding
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** Shared fixtures for the Katan UI-logic tests: real islands, bent to taste. */
internal object CatanFixtures {
private val json = Json { encodeDefaults = true }
fun config(players: Int = 3, seed: Long = 7L) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(CatanOptions(playerCount = players)),
seed = seed,
)
/** A fresh table, on the first setup placement. */
fun start(players: Int = 3, seed: Long = 7L): CatanState =
CatanRules.initialState(config(players, seed))
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
fun playing(
turn: Int = 0,
players: Int = 3,
adjust: CatanState.() -> CatanState = { this },
): CatanState = start(players)
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
.adjust()
fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
fun CatanState.withHand(seat: Int, hand: Map<Resource, Int>): CatanState =
copy(resources = resources.toMutableList().also { it[seat] = hand })
fun CatanState.withBuilding(vertex: Int, type: BuildingType, owner: Int): CatanState =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
copy(roads = roads.toMutableList().also { it[edge] = owner })
}
@@ -0,0 +1,117 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.catan.CatanBoard
import kotlin.math.abs
import kotlin.math.sqrt
class CatanGeometryTest {
private val sizes = listOf(360f to 800f, 800f to 360f, 500f to 500f, 1000f to 1000f / CatanGeometry.ASPECT)
private fun dist(a: Pt, b: Pt): Float = sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
@Test
fun `the whole island fits inside the box at every shape`() {
for ((w, h) in sizes) {
val layout = CatanGeometry.fit(w, h)
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val p = layout.vertex(v)
assertTrue("vertex $v x=${p.x} outside 0..$w", p.x in 0f..w)
assertTrue("vertex $v y=${p.y} outside 0..$h", p.y in 0f..h)
}
for (e in CatanBoard.PORT_POSITIONS) {
val p = layout.portAnchor(e)
assertTrue("harbour marker x=${p.x} outside 0..$w at ${w}x$h", p.x in 0f..w)
assertTrue("harbour marker y=${p.y} outside 0..$h at ${w}x$h", p.y in 0f..h)
}
}
}
@Test
fun `the layout is as large as the box allows`() {
// Tall and narrow is limited by width, short and wide by height; either
// way the island should use most of the limiting side.
val tall = CatanGeometry.fit(360f, 800f)
val wide = CatanGeometry.fit(800f, 360f)
assertTrue(tall.radius > 20f)
assertTrue(wide.radius > 20f)
val xs = (0 until CatanBoard.VERTEX_COUNT).map { tall.vertex(it).x }
assertTrue("island should span most of the width", xs.max() - xs.min() > 360f * 0.7f)
}
@Test
fun `every edge is one hex radius long and every hex corner is one radius from its centre`() {
val layout = CatanGeometry.fit(500f, 500f)
for (e in 0 until CatanBoard.EDGE_COUNT) {
val (a, b) = layout.edgeEnds(e)
assertEquals("edge $e length", layout.radius, dist(a, b), layout.radius * 0.01f)
}
for (h in 0 until CatanBoard.HEX_COUNT) {
val centre = layout.hexCenter(h)
for (corner in layout.hexCorners(h)) {
assertEquals("hex $h corner", layout.radius, dist(centre, corner), layout.radius * 0.01f)
}
}
}
@Test
fun `all fifty-four vertices sit at distinct places`() {
val layout = CatanGeometry.fit(500f, 500f)
val places = (0 until CatanBoard.VERTEX_COUNT).map {
val p = layout.vertex(it)
Math.round(p.x) to Math.round(p.y)
}.toSet()
assertEquals(CatanBoard.VERTEX_COUNT, places.size)
}
@Test
fun `a tap on a vertex finds that vertex, and a tap far away finds nothing`() {
val layout = CatanGeometry.fit(500f, 500f)
val all = (0 until CatanBoard.VERTEX_COUNT).toList()
for (v in all) {
val p = layout.vertex(v)
assertEquals(v, layout.nearestVertex(p, layout.radius * 0.5f, all))
// A slightly clumsy tap still lands on it.
assertEquals(v, layout.nearestVertex(Pt(p.x + layout.radius * 0.2f, p.y - layout.radius * 0.15f), layout.radius * 0.55f, all))
}
assertNull(layout.nearestVertex(Pt(-100f, -100f), layout.radius * 0.5f, all))
}
@Test
fun `hit testing only considers the candidates it is given`() {
val layout = CatanGeometry.fit(500f, 500f)
val at = layout.vertex(5)
assertNull(layout.nearestVertex(at, layout.radius * 0.3f, listOf(6)))
assertEquals(6, layout.nearestVertex(layout.vertex(6), layout.radius * 0.3f, listOf(6)))
assertEquals(3, layout.nearestEdge(layout.edgeMidpoint(3), 5f, listOf(3, 4)))
assertEquals(7, layout.nearestHex(layout.hexCenter(7), 5f, listOf(7, 8)))
}
@Test
fun `the nearest candidate wins when several are in reach`() {
val layout = CatanGeometry.fit(500f, 500f)
val a = 10
val b = CatanBoard.VERTEX_NEIGHBORS[a].first()
val pa = layout.vertex(a)
val pb = layout.vertex(b)
// A point two-thirds of the way toward b is nearer b, though both are in reach.
val at = Pt(pa.x + (pb.x - pa.x) * 0.66f, pa.y + (pb.y - pa.y) * 0.66f)
assertEquals(b, layout.nearestVertex(at, layout.radius * 2f, listOf(a, b)))
}
@Test
fun `harbour markers sit outside the coast`() {
val layout = CatanGeometry.fit(500f, 500f)
val centre = Pt(500f / 2f, 500f / 2f)
for (e in CatanBoard.PORT_POSITIONS) {
val anchor = layout.portAnchor(e)
val mid = layout.edgeMidpoint(e)
assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre))
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * PORT_OFFSET) < 1f)
}
}
}
@@ -0,0 +1,348 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.DevCard
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayYearOfPlenty
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
import org.prolibertate.games.ui.game.catan.CatanFixtures.withBuilding
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
import org.prolibertate.games.ui.game.catan.CatanFixtures.withRoad
class CatanInteractionTest {
private val rich = hand(
Resource.LUMBER to 5, Resource.BRICK to 5, Resource.WOOL to 5,
Resource.GRAIN to 5, Resource.ORE to 5,
)
// --- Setup ---------------------------------------------------------------
@Test
fun `setup first offers every settlement spot, then only the roads beside the chosen one`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val setup = legal.filterIsInstance<SetupPlace>()
val first = CatanInteraction.targets(state, legal, null, null)
assertEquals(setup.map { it.vertex }.toSet(), first.vertices)
assertTrue(first.edges.isEmpty() && first.hexes.isEmpty())
val vertex = first.vertices.first()
val second = CatanInteraction.targets(state, legal, null, vertex)
assertTrue(second.vertices.isEmpty())
assertEquals(setup.filter { it.vertex == vertex }.map { it.edge }.toSet(), second.edges)
}
@Test
fun `a setup placement is two taps - the settlement, then its road`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
val edge = CatanInteraction.targets(state, legal, null, vertex).edges.first()
assertEquals(TapOutcome.PickSetupVertex(vertex), CatanInteraction.onVertexTap(vertex, legal, null, null))
assertEquals(TapOutcome.Submit(SetupPlace(vertex, edge)), CatanInteraction.onEdgeTap(edge, legal, null, vertex))
// A road tapped before any settlement is chosen means nothing.
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, null, null))
}
@Test
fun `a road that does not touch the chosen settlement is refused`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
val beside = CatanBoard.VERTEX_EDGES[vertex].toSet()
val far = (0 until CatanBoard.EDGE_COUNT).first { it !in beside }
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(far, legal, null, vertex))
}
// --- Building -------------------------------------------------------------
/** Seat 0 owns a settlement at vertex 0 and a road two edges long leading away from it. */
private fun established(): Triple<org.prolibertate.games.game.catan.CatanState, Int, Int> {
val e1 = CatanBoard.VERTEX_EDGES[0][0]
val w = CatanBoard.otherEnd(e1, 0)
val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 }
val x = CatanBoard.otherEnd(e2, w)
val state = playing {
withHand(0, rich).withBuilding(0, BuildingType.SETTLEMENT, 0).withRoad(e1, 0).withRoad(e2, 0)
}
return Triple(state, x, e2)
}
@Test
fun `nothing on the board is tappable until a build mode is chosen`() {
val (state, _, _) = established()
val legal = CatanRules.legalMoves(state, 0)
assertTrue(CatanInteraction.targets(state, legal, null, null).isEmpty)
}
@Test
fun `build modes target exactly what the engine allows`() {
val (state, x, _) = established()
val legal = CatanRules.legalMoves(state, 0)
assertEquals(
legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet(),
CatanInteraction.targets(state, legal, BuildKind.ROAD, null).edges,
)
assertTrue(x in CatanInteraction.targets(state, legal, BuildKind.SETTLEMENT, null).vertices)
assertEquals(setOf(0), CatanInteraction.targets(state, legal, BuildKind.CITY, null).vertices)
assertEquals(setOf(BuildKind.ROAD, BuildKind.SETTLEMENT, BuildKind.CITY), CatanInteraction.canBuild(legal))
}
@Test
fun `a tap builds only when the matching mode is armed and the spot is legal`() {
val (state, x, _) = established()
val legal = CatanRules.legalMoves(state, 0)
val road = legal.filterIsInstance<BuildRoad>().first().edge
assertEquals(TapOutcome.Submit(BuildSettlement(x)), CatanInteraction.onVertexTap(x, legal, BuildKind.SETTLEMENT, null))
assertEquals(TapOutcome.Submit(BuildCity(0)), CatanInteraction.onVertexTap(0, legal, BuildKind.CITY, null))
assertEquals(TapOutcome.Submit(BuildRoad(road)), CatanInteraction.onEdgeTap(road, legal, BuildKind.ROAD, null))
// No mode armed: nothing happens.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, null, null))
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(road, legal, null, null))
// A settlement on an occupied crossing is not offered.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(0, legal, BuildKind.SETTLEMENT, null))
// The wrong mode for the spot.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, BuildKind.CITY, null))
}
@Test
fun `without the cards, nothing can be built`() {
val (state, _, _) = established()
val broke = state.withHand(0, hand())
val legal = CatanRules.legalMoves(broke, 0)
assertTrue(CatanInteraction.canBuild(legal).isEmpty())
assertFalse(CatanInteraction.canBuyDevCard(legal))
assertTrue(CatanInteraction.canEndTurn(legal))
}
@Test
fun `free roads from a road building card need no build mode`() {
val (state, _, _) = established()
val free = state.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
val legal = CatanRules.legalMoves(free, 0)
val targets = CatanInteraction.targets(free, legal, null, null)
assertTrue(targets.edges.isNotEmpty())
val edge = targets.edges.first()
assertEquals(TapOutcome.Submit(BuildRoad(edge)), CatanInteraction.onEdgeTap(edge, legal, null, null))
assertTrue(CatanInteraction.canEndFreeRoads(legal))
}
// --- The robber ---------------------------------------------------------------
@Test
fun `the robber may go to any hex but the one it is on`() {
val state = playing { copy(phase = CatanPhase.ROBBER) }
val legal = CatanRules.legalMoves(state, 0)
val targets = CatanInteraction.targets(state, legal, null, null)
assertEquals((0 until CatanBoard.HEX_COUNT).filter { it != state.robber }.toSet(), targets.hexes)
val hex = targets.hexes.first()
// Nobody has built anything, so there is nobody to rob: the move goes straight through.
assertEquals(
TapOutcome.Submit(MoveRobber(CatanBoard.HEXES[hex], null)),
CatanInteraction.onHexTap(hex, legal),
)
assertEquals(TapOutcome.Ignored, CatanInteraction.onHexTap(state.robber, legal))
}
@Test
fun `a hex with two possible victims asks which`() {
val base = playing { copy(phase = CatanPhase.ROBBER) }
val hex = (0 until CatanBoard.HEX_COUNT).first { it != base.robber }
val corners = CatanBoard.HEX_CORNERS[hex]
val state = base
.withBuilding(corners[0], BuildingType.SETTLEMENT, 1)
.withBuilding(corners[3], BuildingType.SETTLEMENT, 2)
.withHand(1, hand(Resource.ORE to 1))
.withHand(2, hand(Resource.GRAIN to 1))
val legal = CatanRules.legalMoves(state, 0)
val outcome = CatanInteraction.onHexTap(hex, legal)
assertTrue(outcome is TapOutcome.PickRobberVictim)
outcome as TapOutcome.PickRobberVictim
assertEquals(CatanBoard.HEXES[hex], outcome.hex)
assertEquals(setOf(1, 2), outcome.victims.toSet())
}
// --- Discarding -----------------------------------------------------------------
@Test
fun `a seven costs half the hand, rounded down`() {
assertEquals(4, CatanInteraction.discardQuota(9))
assertEquals(4, CatanInteraction.discardQuota(8))
assertEquals(3, CatanInteraction.discardQuota(7))
}
@Test
fun `a discard is found only when it names exactly an allowed set of cards`() {
val state = playing {
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
.withHand(0, hand(Resource.LUMBER to 3, Resource.BRICK to 3, Resource.WOOL to 3))
}
val legal = CatanRules.legalMoves(state, 0)
assertTrue(legal.all { it is Discard })
val good = listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.WOOL)
assertNotNull(CatanInteraction.discardMove(legal, good))
// Order does not matter.
assertNotNull(CatanInteraction.discardMove(legal, good.reversed()))
// Too few, and more than are held.
assertNull(CatanInteraction.discardMove(legal, good.take(3)))
assertNull(CatanInteraction.discardMove(legal, List(4) { Resource.LUMBER }))
}
// --- Trading ---------------------------------------------------------------------
@Test
fun `bank trades list what can be given and what it can buy`() {
val state = playing { withHand(0, hand(Resource.GRAIN to 4)) }
val legal = CatanRules.legalMoves(state, 0)
assertTrue(legal.any { it is MaritimeTrade })
val trades = CatanInteraction.bankTrades(legal)
assertEquals(setOf(Resource.GRAIN), trades.keys)
assertEquals(Resource.entries.filter { it != Resource.GRAIN }, trades.getValue(Resource.GRAIN))
assertEquals(4, CatanInteraction.bankRatio(state, 0, Resource.GRAIN))
}
@Test
fun `an offer is allowed only to another player, for something different, from cards held`() {
val state = playing { withHand(0, hand(Resource.LUMBER to 2)) }
val lumber = listOf(Resource.LUMBER)
val grain = listOf(Resource.GRAIN)
assertTrue(CatanInteraction.offerAllowed(state, 0, 1, lumber, grain))
assertFalse("to oneself", CatanInteraction.offerAllowed(state, 0, 0, lumber, grain))
assertFalse("nothing offered", CatanInteraction.offerAllowed(state, 0, 1, emptyList(), grain))
assertFalse("nothing asked", CatanInteraction.offerAllowed(state, 0, 1, lumber, emptyList()))
assertFalse("like for like", CatanInteraction.offerAllowed(state, 0, 1, lumber, lumber))
assertFalse("more than held", CatanInteraction.offerAllowed(state, 0, 1, List(3) { Resource.LUMBER }, grain))
assertFalse("not their turn", CatanInteraction.offerAllowed(state, 1, 0, lumber, grain))
}
// --- Development cards -------------------------------------------------------------
@Test
fun `a development card is playable unless it was bought this turn or is a victory point`() {
val state = playing {
copy(
devHands = listOf(
listOf(
DevCard(1, DevCardType.KNIGHT, boughtTurn = 0),
DevCard(2, DevCardType.VICTORY_POINT, boughtTurn = 0),
DevCard(3, DevCardType.KNIGHT, boughtTurn = 1),
),
emptyList(),
emptyList(),
),
)
}
val legal = CatanRules.legalMoves(state, 0)
val hand = CatanInteraction.devHand(state, 0, legal)
// Sorted by type then id: the two knights, then the victory point.
assertEquals(listOf(1, 3, 2), hand.map { it.card.id })
assertEquals(listOf(true, false, false), hand.map { it.playable })
assertEquals(listOf<Any>(PlayKnight(1)), hand.first().plays)
}
@Test
fun `monopoly offers one play per resource`() {
val plays = Resource.entries.map { PlayMonopoly(9, it) }
val choices = CatanInteraction.monopolyChoices(plays)
assertEquals(Resource.entries.toSet(), choices.keys)
assertEquals(PlayMonopoly(9, Resource.ORE), choices.getValue(Resource.ORE))
}
@Test
fun `year of plenty narrows to the cards that still complete a take`() {
val plays = listOf(
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.BRICK)),
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.LUMBER)),
PlayYearOfPlenty(4, listOf(Resource.ORE)),
)
assertEquals(
setOf(Resource.LUMBER, Resource.BRICK, Resource.ORE),
CatanInteraction.plentyAvailable(plays, emptyList()),
)
assertEquals(
setOf(Resource.LUMBER, Resource.BRICK),
CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER)),
)
assertEquals(setOf(Resource.LUMBER), CatanInteraction.plentyAvailable(plays, listOf(Resource.BRICK)))
assertTrue(CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER, Resource.LUMBER)).isEmpty())
assertEquals(plays[0], CatanInteraction.plentyMove(plays, listOf(Resource.BRICK, Resource.LUMBER)))
assertEquals(plays[2], CatanInteraction.plentyMove(plays, listOf(Resource.ORE)))
assertNull(CatanInteraction.plentyMove(plays, listOf(Resource.GRAIN)))
}
// --- Trade replies ------------------------------------------------------------------
@Test
fun `the addressee of an offer is given accept and decline`() {
val base = playing {
withHand(0, hand(Resource.LUMBER to 1)).withHand(1, hand(Resource.GRAIN to 1))
}
val offer = org.prolibertate.games.game.catan.TradeOffer(
by = 0, to = 1, give = listOf(Resource.LUMBER), get = listOf(Resource.GRAIN),
)
val pending = base.copy(phase = CatanPhase.TRADE_PENDING, pendingTrade = offer)
val legal = CatanRules.legalMoves(pending, 1)
assertEquals(true to true, CatanInteraction.tradeResponses(legal))
// If the addressee no longer holds what was asked, only decline remains.
val short = pending.withHand(1, hand())
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
}
// --- Rolled-hex highlight --------------------------------------------------
@Test
fun `rolled hexes are those carrying the number, minus the robber's`() {
val base = playing(turn = 0)
val number = base.numbers.first { it != 0 && it != 7 }
val state = base.copy(lastDice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6))
val all = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] == number }
assertTrue(all.isNotEmpty())
assertEquals(all.filter { it != state.robber }.toSet(), CatanInteraction.rolledHexes(state))
val blocked = state.copy(robber = all.first())
assertFalse(all.first() in CatanInteraction.rolledHexes(blocked))
}
@Test
fun `nothing is highlighted before this turn's roll, on a seven, or in setup`() {
val rolled = playing(turn = 0).copy(lastDice = listOf(3, 3))
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.AWAIT_ROLL)).isEmpty())
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = listOf(3, 4))).isEmpty())
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.SETUP_FIRST)).isEmpty())
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = emptyList())).isEmpty())
}
}
@@ -0,0 +1,101 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.Port
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
class CatanTextTest {
private val names = listOf("Ann", "Bo", "Cy")
@Test
fun `seats are named, with You for the local player and a number when a name is unknown`() {
val seats = CatanSeats(me = 1, names = names)
assertEquals("You", seats.label(1))
assertEquals("Ann", seats.label(0))
assertEquals("you", seats.lower(1))
assertEquals("Seat 4", seats.label(3))
assertEquals("Seat 1", CatanSeats(me = 1).label(0))
assertEquals("Seat 1", CatanSeats(me = 1, names = listOf(" ")).label(0))
}
@Test
fun `setup prompts say what to place next`() {
val state = start()
val mine = CatanSeats(0, names)
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = false, building = null).contains("Place a settlement"))
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = true, building = null).contains("road"))
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).startsWith("Ann is placing"))
val second = state.copy(phase = CatanPhase.SETUP_SECOND, setupIndex = 3)
assertTrue(CatanText.prompt(second, CatanSeats(2, names), false, null).contains("second settlement"))
}
@Test
fun `the turn prompts follow the phase`() {
val roll = playing().copy(phase = CatanPhase.AWAIT_ROLL)
assertTrue(CatanText.prompt(roll, CatanSeats(0, names), false, null).contains("Roll the dice"))
assertEquals("Ann is rolling…", CatanText.prompt(roll, CatanSeats(1, names), false, null))
val turn = playing()
assertTrue(CatanText.prompt(turn, CatanSeats(0, names), false, null).contains("Your turn"))
assertEquals("Tap an edge to build a road.", CatanText.prompt(turn, CatanSeats(0, names), false, BuildKind.ROAD))
assertEquals("Bo is playing…", CatanText.prompt(playing(turn = 1), CatanSeats(0, names), false, null))
val robber = playing().copy(phase = CatanPhase.ROBBER)
assertTrue(CatanText.prompt(robber, CatanSeats(0, names), false, null).contains("robber"))
}
@Test
fun `the discard prompt names the quota`() {
val state = playing {
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
.withHand(0, hand(Resource.LUMBER to 9))
}
assertTrue(CatanText.prompt(state, CatanSeats(0, names), false, null).contains("discard 4"))
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).contains("Ann must discard half"))
}
@Test
fun `road building counts down in the singular and the plural`() {
val two = playing().copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
val one = two.copy(pendingFreeRoads = 1)
assertTrue(CatanText.prompt(two, CatanSeats(0, names), false, null).endsWith("2 more roads."))
assertTrue(CatanText.prompt(one, CatanSeats(0, names), false, null).endsWith("1 more road."))
}
@Test
fun `the end of the game names the winner`() {
val over = playing().copy(phase = CatanPhase.GAME_OVER, winner = 1)
assertEquals("Bo won the game.", CatanText.prompt(over, CatanSeats(0, names), false, null))
assertEquals("You won!", CatanText.winnerHeadline(over, CatanSeats(1, names)))
assertEquals("Bo won", CatanText.winnerHeadline(over, CatanSeats(0, names)))
}
@Test
fun `cards read as a sorted tally`() {
assertEquals(
"2 Lumber + 1 Grain",
CatanText.cards(listOf(Resource.GRAIN, Resource.LUMBER, Resource.LUMBER)),
)
assertEquals("nothing", CatanText.cards(emptyList()))
assertEquals(
"Ann offers 1 Lumber to you for 2 Ore",
CatanText.tradeSummary("Ann", "you", listOf(Resource.LUMBER), listOf(Resource.ORE, Resource.ORE)),
)
}
@Test
fun `costs and harbours read as the rules state them`() {
assertEquals("1 lumber + 1 brick", CatanText.ROAD_COST)
assertEquals("2 grain + 3 ore", CatanText.CITY_COST)
assertEquals("2:1 ore", CatanText.portName(Port(edge = 0, resource = Resource.ORE, ratio = 2)))
assertEquals("3:1 any", CatanText.portName(Port(edge = 0, resource = null, ratio = 3)))
}
}
+51
View File
@@ -0,0 +1,51 @@
# docs/
Conventions for this repo's documentation. Seeded at bootstrap (WALA-118).
## Per-game rules: `RULES-<game>.md`
Every game in `GameCatalog` gets one file describing how *this app* implements
it — not a generic rule dump. The rules code
(`app/src/main/java/org/prolibertate/games/game/<game>/`) is the source of
truth; the doc exists so a human or an agent can check intent against
behaviour without reading Kotlin first.
Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
| Game (catalogue id) | Rules doc |
| --- | --- |
| Backgammon | [RULES-backgammon.md](RULES-backgammon.md) |
| Checkers | [RULES-checkers.md](RULES-checkers.md) |
| Chess | [RULES-chess.md](RULES-chess.md) |
| Crazy 8s | [RULES-crazy8s.md](RULES-crazy8s.md) |
| Cribbage | [RULES-cribbage.md](RULES-cribbage.md) |
| Euchre | [RULES-euchre.md](RULES-euchre.md) |
| FreeCell | — (TODO) |
| Golf | [RULES-golf.md](RULES-golf.md) |
| Hearts | — (TODO) |
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) |
| Klondike | — (TODO) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
| Morris | [RULES-morris.md](RULES-morris.md) |
| Peg solitaire | — (TODO) |
| Pirates | [RULES-pirates.md](RULES-pirates.md) |
| President | [RULES-president.md](RULES-president.md) |
| Pyramid | — (TODO) |
| Sequence | [RULES-sequence.md](RULES-sequence.md) |
| Spider | — (TODO) |
| Ta Yü | [RULES-tayu.md](RULES-tayu.md) |
| Wizard | [RULES-wizard.md](RULES-wizard.md) |
| Yahtzee | — (TODO) |
## Other docs
- Build, CI, releases and OTA are described in the CI section of the root
[README](../README.md).
- [SAVED-GAMES.md](SAVED-GAMES.md) — how a game in progress is kept between sessions, and what is
not kept.
- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle,
re-copy or re-lay-out it without touching the rules.
- `licenses/` — third-party licences that must travel with the app
(Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`).
- Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root.
+222
View File
@@ -0,0 +1,222 @@
# Katan: Cities & Knights — implemented rules
This is the second rule set of Katan (see [RULES-catan.md](RULES-catan.md) for the
base game). It is chosen on the setup screen — **Rules: Base game / Cities &
Knights** — and stored as `CatanOptions.ruleSet` (`CatanRuleSet.CITIES_AND_KNIGHTS`).
A table plays one or the other; the choice reaches guests with the rest of the
options.
The expansion is a whole rule set rather than a toggle, because it replaces rules
(no development cards, no Largest Army, a different seven) as well as adding them.
Everything it needs lives behind `CatanState.ck`, which is `null` in the base game,
so a base game's state and rules are untouched. The code is:
| File | Holds |
| --- | --- |
| `CatanCkModel.kt` | the data: commodities, improvement tracks, progress cards, knights, the chore queue, every new move |
| `CatanCk.kt` | the rules: legal moves, applying them, the roll, the barbarians, scoring |
| `CatanCkAi.kt` | the computer opponent for this rule set |
| `CatanRules.kt` | the single entry point; calls into `CatanCk` whenever `state.ck != null` |
Behaviour is pinned by `CatanCkRulesTest` (each rule on a crafted position) and
`CatanCkSelfPlayTest` (whole games, engine against engine). Three or four players,
as in the base game.
## Verification
The rules were checked against the in-game almanac of Catan Universe (rules text
only: costs, card wordings, the barbarian and improvement rules). Where the almanac
is silent or ambiguous the reading chosen is listed under *Choices made here* below.
## What changes from the base game
| | Base game | Cities & Knights |
| --- | --- | --- |
| Points to win | 10 | **13** (setup offers 8 / 10 / 13 / 15) |
| Development cards | 25-card deck | **none** — knights and progress cards replace them |
| Largest Army | 5 *(see RULES-catan.md)* | **none** |
| Longest Road | 5 *(see RULES-catan.md)* | **2** |
| Second setup piece | settlement | **city** (resources only, no commodities) |
| Dice | two | **two number dice + the event die** |
| A seven | discard half over 7; robber moves | discard half over **7 + 2 per wall**; the robber **only moves after the first barbarian attack** |
| Cities | 2 of a resource | forest: 1 lumber + **1 paper**; pasture: 1 wool + **1 cloth**; mountains: 1 ore + **1 coin**; hills / fields: 2 of the resource |
## The dice
The yellow die and the red die are added for production, as in the base game. The
**event die** has three ships and one gate each of yellow (trade), blue (politics)
and green (science). It is resolved **before** production:
- **Ship** — the barbarians come one step nearer (they start 7 steps out).
- **Gate** — every player with at least one level of improvement in that colour,
**in turn order from the roller**, whose **red die is no higher than their level**
in that track, draws a progress card of that colour.
`RollDice(forced, event)` stages both for tests, like the base game's forced roll.
An **Alchemist** played before the roll sets the two number dice (the event die is
still rolled).
## Commodities
Three commodities — **cloth, coin, paper** — join the five resources. The bank holds
12 of each (19 of each resource). Hands, discards, thefts and trades all treat them as
cards; progress cards are not cards for any of these purposes.
When the bank cannot meet the demand for a card type: with **one** seat owed it,
that seat takes what is left; with **two or more** owed, nobody receives it.
## The barbarians
The strip shows how many steps away the ship is. On the seventh ship it **lands**:
- Barbarian strength is the **number of cities on the board** (a metropolis is a city).
- Catan's strength is the sum of the strengths of every **active** knight (basic 1,
strong 2, mighty 3).
- If Catan's strength is **at least** the barbarians', Catan holds: the player with
the strictly highest contribution takes a **Defender of Catan** card (one point);
if several tie for highest, **each draws a progress card from a deck of their
choice** instead.
- Otherwise Catan falls: among the players who own a city that is not a metropolis,
those who contributed **least** each lose one city, **to their own choice**. A lost
city becomes a settlement (or, if the seat has no settlement piece left, is removed),
and its wall goes. **A metropolis can never be pillaged.**
- Either way every knight stands down, the ship returns to seven steps, and the
**robber wakes** — until now it has not been on the board.
## Knights
Each seat has **two knights of each strength** (six in all).
| Action | Cost | Rule |
| --- | --- | --- |
| Hire | 1 ore + 1 wool | a **basic** knight on an empty crossing at the end of one of your roads; only if one of your two basic knights is free |
| Activate | 1 grain | a sleeping knight wakes; only active knights count against the barbarians |
| Promote | 1 ore + 1 wool | one level, once per knight per turn; a **mighty** knight needs **level three of politics** (the fortress); the next level's pieces must be free |
| Move | — | an active knight moves along **its own roads** to an empty crossing, or onto a **strictly weaker** foreign knight, driving it off; it then rests |
| Chase the robber | — | an active knight on a crossing of the robber's hex sends the robber off (only once the robber is awake); it then rests, and the robber move follows |
- **A knight cannot act on the turn it woke.** It can be woken again after it acts.
- A driven-off knight is moved by **its owner** along its own roads to any empty
crossing it can reach; if there is none, it is lost.
- A knight is a wall, like an opposing building: roads cannot be laid past an enemy
knight, a settlement cannot be built on a knight, and the knight cuts **Longest Road**.
- Knights can act (wake, move, chase) **before the roll** as well as after.
## City walls
2 brick, on a city without a wall, **three per player**. Each wall raises the hand
limit on a seven by two (so a seven discards when you hold more than `7 + 2 × walls`).
## City improvements
Three tracks, five levels each, bought with the track's commodity: **trade (cloth,
yellow), politics (coin, blue), science (paper, green)**. Level *n* costs *n* of the
commodity. You must own a city to improve.
| Track | Level 3 gives |
| --- | --- |
| Trade | commodities trade with the bank **2:1** |
| Politics | knights may be promoted to **mighty** |
| Science | the **aqueduct**: when the dice pay you nothing, take any resource |
The level names (Trading Post … Grand Bazaar, Town Hall … Palace, Abbey … University)
are this app's own.
### Metropolises
The first player to reach **level four** in a track takes that track's **metropolis**
(a marker on one of their cities: +2 points; a city carries at most one). Reaching
**level five** takes it from a holder who is still on four. A player with several
cities chooses which; with one it is placed at once; a player with no free city
**waits**, and claims it when a city is built.
## Progress cards
54 cards: 18 in each of three decks. A card played returns to the **bottom** of its
deck. A hand holds **four**; a fifth must be given back at once. They can be played at
any point of your own turn (before or after the roll), except where noted.
**Science (green)** — Alchemist ×2 *(before the roll only)*, Crane ×2, Engineer, Inventor ×2,
Irrigation ×2, Medicine ×2, Mining ×2, Printer, Road Building ×2, Smith ×2.
**Politics (blue)** — Bishop ×2 *(after the first attack)*, Constitution, Deserter ×2,
Diplomat ×2, Intrigue ×2, Saboteur ×2, Spy ×3, Warlord ×2, Wedding ×2.
**Trade (yellow)** — Commercial Harbor ×2, Master Merchant ×2, Merchant ×6,
Merchant Fleet ×2, Resource Monopoly ×4, Trade Monopoly ×2.
**Printer** and **Constitution** are one point each and are **laid face up the moment
they are drawn**; they never sit in a hand.
How each plays is shown on the card in the app (`CatanText.progressHelp`); the engine's
behaviour is in `CatanCk.applyProgress`. Points worth knowing:
- **Saboteur** hits every other player with **as many points as you or more**, who each
discard half their cards (rounded down). **Wedding** takes two cards (or what they
have) from each player with **more** points. **Master Merchant** needs a victim with
more points, and takes two cards you choose.
- **Spy**: you see the target's progress cards and take one; nobody else sees them.
- **Merchant**: placed beside one of your buildings; you trade that hex's resource 2:1
and hold a victory point until someone else plays a Merchant.
- **Deserter**: the victim removes a knight of *their* choice; you place a free one of the
same strength (or a basic if yours is not free), awake or asleep as theirs was.
- **Diplomat**: removes a road with a loose end (an end touching no other road, building
or knight); if it was yours you may rebuild it anywhere for free.
- **Inventor** swaps two tokens, never a 2, 6, 8 or 12.
## The chore queue
Anything that needs an answer from somebody — a discard, a city to lose, a progress card
to hand over, a metropolis site, a knight to place — is queued as a **chore**
(`CatanPhase.CHORE`, `CkState.chores`). The game waits on the seat the first chore names
(not always the player whose turn it is), then resumes where `CkState.back` says. This is
how a seven, a barbarian landing, a wedding or a spy are all one mechanism, and why
the AI and the network driver needed no new concepts.
The two free-form chores — **discarding** and **handing over** cards — are structural
like the base game's trade offers: the engine checks them in `isLegalMove` and the legal
list carries one example, so a seat is never without a move.
## Scoring and winning
Settlement 1, city 2, **metropolis +2**, **Longest Road 2**, **Defender of Catan 1** each,
**Printer / Constitution 1** each, **the Merchant 1**. Nothing is hidden. Win at 13 (default),
checked after every move and when a turn arrives.
## Choices made here, and why
- **The red die must be no higher than the improvement level** to draw a progress card
(level 1 draws on a 1, level 5 on 1–5). The almanac says the red die must match a
number on one of your improvements and that the number rises with the level; this is
the reading that makes both true.
- **One resource is offered to every player for a Commercial Harbor.** The printed card
lets you offer a different one to each.
- **The merchant cannot go on the desert**, which produces nothing to trade.
- **Improvements need a city** to be built.
- **A 3:1 harbour trades commodities too**; a 2:1 harbour trades only its own resource.
- **A knight that cannot find a refuge is lost**, as the almanac says of an intrigue.
- **A claim on a metropolis with no city to carry it waits** and is made when a city is
built.
- **Domestic trade** works as in the base game — an offer to one seat or to the whole
table, asked in seat order, each seat accepting, declining or countering — and may
include commodities (`OfferGoods`, `CounterGoods`; accepting and dismissing counters
reuse the base game's `AcceptCounter` and `DismissCounters`).
- **No Seafarers, no 5–6 player extension**, no scenario or variant of any kind.
## The computer opponent
`CatanCkAi` plays a safe, unambitious game: it settles and raises cities as the base AI
does, spends commodities on improvements as soon as it can (leaning to politics), hires
and wakes knights as the barbarians draw near, chases the robber off hexes it needs, plays
the progress cards whose value is obvious, and answers every chore with the card it values
least. It never plans a knight's route, never plays an Alchemist, Inventor, Diplomat,
Deserter or Intrigue, and makes no domestic offers (though it answers them). Every engine
game in `CatanCkSelfPlayTest` ends in a win at thirteen points.
## Known gaps
- The screen is built and its logic is tested, but **not yet verified on two real
devices**; the catalogue entry stays a dev-channel preview.
- No animation for the event die, the barbarian landing or a knight's move.
- The Commercial Harbor offers a single resource type (see above).
- The AI is not a strong opponent and makes no claim to be.
+345
View File
@@ -0,0 +1,345 @@
# Katan — implemented rules
This is the ruleset the engine enforces. Anything marked **toggle** is exposed
in the game setup screen.
Katan is this app's name for the island game the collection is built around.
The catalogue id is `catan` (so this file is `RULES-catan.md` and the code lives
in `game/catan/`), but the printed name is trademarked, so the game is listed
and titled **Katan**. The rules themselves are the base game for three to four
players, as published.
The engine is a pure state machine (`CatanRules`) with no Android types, and its
behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`.
## Rule sets
A table plays one of two rule sets, chosen on the setup screen
(`CatanOptions.ruleSet`):
- **Base game** — everything in this document.
- **Cities & Knights** — the expansion, documented in
[RULES-catan-ck.md](RULES-catan-ck.md). It reuses this document's board, setup,
building, robber and trade rules and changes or replaces the rest; its state lives
behind `CatanState.ck`, which is `null` in the base game.
## Table
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).
- **Players — *toggle*.** Three or four. `CatanOptions.playerCount`; the engine
refuses anything else.
- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at
least 2. A lower target makes a test game short.
The setup screen exposes all three (players: 3 or 4; game to 5, 8, 10 or 12 points in the
base game, 8, 10, 13 or 15 in Cities & Knights, whose published target is 13).
## Board
The island is the standard layout, derived once from axial hex coordinates in
`CatanBoard` and checked at class-init:
- **19 hexes**, the hexes within distance 2 of the centre.
- **54 vertices** and **72 edges**, shared between neighbouring hexes by exact
integer position, not floating point. `HEX_CORNERS`, `EDGE_VERTICES`,
`VERTEX_EDGES`, `VERTEX_HEXES` and `EDGE_HEXES` are the shared graph.
- **30 coastal edges**, one closed ring.
- **9 harbours**, on fixed positions around the coast.
Terrain is dealt from the physical mix and shuffled: **4 forest, 4 pasture,
4 fields, 3 hills, 3 mountains, 1 desert**.
Number tokens are the 18 the game ships, shuffled over the land hexes: four red
tokens (**6, 6, 8, 8**) and fourteen others
(**2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12**). Seven never appears. The
desert bears no token and the robber starts on it.
### Keeping 6s and 8s apart
The published game asks that the two red numbers not sit on adjacent hexes, and
`generate` guarantees it rather than hoping for it:
1. Shuffle the land and take four hexes; accept the draw if none of the four
touch each other. Retry up to 60 times.
2. If no draw separates them, fall back to four of the six board-corner hexes,
which are pairwise non-adjacent by construction and at most one of which can
be the desert.
`no seed puts two red tokens on adjacent hexes` runs the whole thing over seeds
1..500.
Harbours: the nine **positions** are fixed (the physical frame is not shuffled),
but the nine **types** are shuffled onto them — **five 2:1 resource harbours**
(one per resource) and **four generic 3:1 harbours**. The frame positions are
chosen so no two harbours sit closer than three coastal edges apart.
## Setup
Placement runs in snake order: seats `0, 1, …, n-1` then `n-1, …, 1, 0`, each
placing one settlement and one road. During setup the distance rule still holds
(a settlement may not sit one edge from any other building) but the road need
not connect to anything already placed.
A player's **second** settlement earns one resource card for each adjacent
producing hex, supply permitting — duplicates counted, so a second settlement on
two forests earns two lumber. The first pass earns nothing.
After the last placement, seat 0 leads and the first real turn begins.
## Turn
A turn runs through the phases in `CatanPhase`:
1. **AWAIT_ROLL** — the player on the clock rolls.
2. A seven goes to **DISCARDING** and then **ROBBER**; anything else produces and
goes straight to **PLAYING**.
3. **PLAYING** — build, trade, buy and play development cards, then end the turn.
4. **FREE_ROADS** is a detour inside PLAYING while a Road Building card is being
spent.
### Rolling
The dice are derived from the game seed and a monotonic counter (`rngStep`), not
from a random generator stored in the state: the same seed and the same moves
replay identically. `RollDice(forced = …)` exists so tests can stage a number;
it is deliberately **not** a member of the enumerated move list, so a client
cannot send one over the network — only code holding the state directly can use
it.
### Production
For a non-seven total, each hex whose number matches pays its producing terrain:
a settlement takes **1**, a city takes **2**. The robber's hex pays nothing.
**When the bank cannot meet the demand for a resource, nobody receives that
resource this roll.** This is the official ruling, implemented per-resource:
demand is totalled first and, if it exceeds the bank's supply, every gain of
that resource is dropped.
## The robber
A **seven** sends the robber: first every player holding **more than seven**
resource cards discards half their hand (rounded down, so nine cards means four
gone), in seat order starting from the lowest overfull seat; then the roller
moves the robber and may steal.
The robber **must move to a different hex**. Moving onto a hex lets the roller
steal one card from one opponent who has a building on that hex and at least one
card in hand. The card stolen is chosen at random, derived like the dice. A
hex with no opponent holding cards offers only the move, no steal.
A played Knight enters the same ROBBER phase, so the robber move after a Knight
is the same code path.
## Trading
### Domestic
An offer is addressed to **one** seat or, with `to = null`, to **everyone**, and
names two card multisets: what the offerer gives, what they want.
Seats are asked **one at a time**, in seat order after the offerer (seats holding
no cards are skipped; an addressed offer asks only its addressee). The seat being
asked may:
- **accept** — the trade happens at once and the offer closes (first accept wins);
- **decline** — the offer moves on to the next seat;
- **counter** (`CounterTrade`) — propose different terms: what they would give and
what they want from the offerer. A counter must differ from the offer as made
(that is an accept), and both sides must hold the cards. The offer moves on.
When every seat has answered and nobody accepted: with no counters the offer
simply lapses; with counters the phase becomes `TRADE_COUNTERS` and the offerer
either **takes one** (`AcceptCounter(seat)`, still checked for affordability) or
**turns them all down** (`DismissCounters`).
An offer is legal only when the offerer's turn is in PLAYING, the addressee is
another seat, both legs are non-empty, the two legs are not the same multiset
(`countOf(give) != countOf(get)` — like-for-like is not a trade), and the
offerer actually holds the cards being offered.
A seat may **accept** only if it holds what was asked and the offerer still holds
what was promised; otherwise only decline (or counter) is offered. A refusal
leaves every card where it was.
Offers are free-form intents rather than enumerated moves (the space of card
multisets is combinatorial), so they are validated structurally by
`CatanRules.isLegalMove` and checked by the network driver's gate like any other
move.
### Maritime
Bank trade gives `ratio` of one resource for one of another, where the ratio is
the best the seat can get: **4:1** through the bank, **3:1** through a generic
harbour, **2:1** through a harbour matching the resource being given. A harbour
counts when the seat owns a settlement or city on **either** endpoint vertex of
its edge.
## Building and development cards
| Build | Cost |
| --- | --- |
| Road | 1 lumber + 1 brick |
| Settlement | 1 lumber + 1 brick + 1 wool + 1 grain |
| City | 2 grain + 3 ore |
| Development card | 1 wool + 1 grain + 1 ore |
A settlement goes on an **empty vertex** at least two edges from every standing
building and connected to the seat's own road network. A city upgrades one of
the seat's own settlements. A road goes on an empty edge that connects to the
seat's network: it must start from a crossing where the seat has a building, or
where one of its roads ends.
**An opponent's settlement or city on a crossing breaks the network there.** A
road cannot be laid past it, even from a crossing where one of your own roads
ends — only a crossing you own a building on is a way through. The same rule
applies to the free roads of a Road Building card, and it is the same wall that
cuts Longest Road (below).
**Pieces are limited**: each player has **15 roads, 5 settlements and 4 cities**.
A player who has laid them all cannot build another of that kind. Raising a
settlement into a city returns the settlement to the supply, so the five
settlements remain available to be laid again.
The development deck is the published 25 cards: **14 Knights, 2 Road Building,
2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
top of the deck.
Timing rules the engine enforces:
- **One development card per turn.** `devCardPlayedThisTurn` gates every play.
- **A card cannot be played the turn it was bought.** Each card records
`boughtTurn` and is skipped while that equals the current turn.
- **Victory Point cards are never played.** They are counted straight from the
hand by `victoryPoints`, and are only revealed by winning.
- **Knight** → the robber phase, and counts toward Largest Army.
- **Road Building** → two free road placements (FREE_ROADS); the player may
forfeit any that remain.
- **Monopoly** → seizes every card of the named resource from the other seats.
- **Year of Plenty** → takes up to two cards from the bank; taking two of a kind
requires the bank to hold both.
## Scoring and winning
Victory points are:
- **Settlement 1, city 2.**
- **Longest Road 5** — the longest single road of **five or more** segments.
- **Largest Army 5** — first to **three** played knights.
- **Victory Point cards**, one each.
A seat wins on reaching **points to win** (default 10). The win is checked after
every build, when a turn ends, and when a seat's turn arrives — the last covers
points that changed on someone else's turn, such as a broken Longest Road being
re-awarded. A seat already at the target therefore wins at the start of its own
turn rather than having to act.
### Longest Road
The award goes to the longest **simple path** over a seat's own roads. The path
never passes *through* an opponent's building (it may run up to one) and never
revisits a vertex — so a figure of two loops sharing an intersection counts as
one road of five, not two roads, because the intersection is a branch.
Once held, the award is kept until another seat builds a **strictly longer**
road; an equal length leaves it where it is. If the holder's road falls below
five segments the award reopens to the longest road of five or more, lowest seat
first on a tie. A settlement placed across an opponent's road breaks it, and the
award is recomputed for everyone after any settlement.
### Largest Army
First to three knights takes it. It is stolen only by a **strictly larger** army:
a tie leaves the incumbent ahead, exactly as Longest Road does.
## The computer opponent
`CatanAi` plays a legal, mildly strategic game so a solo player can host against
computers, with the lobby filling empty seats.
It reads **only public information** — terrain, numbers, buildings, roads,
resource counts, development-card counts and knights played — plus its own hand.
It never looks into the deck, another seat's development cards, or the seed
beyond the roll the rules derive from it. That is a design constraint, not an
accident: it runs on the host, where the authoritative state is fully visible.
What it does:
- **Setup.** Rates a settlement by the production its three hexes give (weighted
by the token odds and by how much it wants each resource), plus a bonus for
resource variety and for sitting on a harbour, plus a small term for where the
accompanying road points.
- **Main phase**, in priority order: raise a city, build a settlement, play a
knight, play Year of Plenty or Monopoly when it closes a gap, play Road
Building when two segments are useful, buy a development card, build a road
that reaches a spot worth settling, trade at the bank only when the trade
completes a build this turn, make one domestic offer that closes a build, else
end the turn.
- **The robber.** Moves onto the hex where it blocks the most opposing
production (weighted by token odds), preferring to rob whoever is holding the
most cards and the most points.
- **Trades.** Accepts an offer when what arrives is worth more than what leaves
by its own resource valuations; a mild table, not a shark.
- **Discards.** Throws away what it values least (wool, then brick, then lumber,
before grain and ore).
It makes **at most one domestic offer a turn**. A declined offer changes nothing,
so re-offering the same thing would only bounce between two seats forever; the
engine allows more offers, the AI chooses not to.
`CatanAiTest` plays three- and four-seat tables of engines out to a result from
several seeds, refusing any illegal move the AI chooses, and checks the winner
reached the target.
## Provenance and rule choices
The rules are the published base game. Where the published rules leave room, the
engine picks one reading and records it here.
**Followed from the published rules:** the board and its component mix; the
number tokens; 6s and 8s not adjacent; snake-order setup and second-settlement
income; production weighting; the robber on a seven; discard half over seven;
domestic and maritime trade; build costs; the 25-card development deck and its
timing rules; Longest Road and Largest Army worth five; victory at ten.
**Choices made here, and why:**
- **A bank short of a resource pays nobody that roll.** This is the official
ruling, and it is implemented per-resource rather than per-player.
- **Longest Road and Largest Army are kept on a tie**, and stolen only by a
strictly longer road or a strictly larger army. The alternative reading lets a
tie steal the award; "strictly better" is the common tournament reading and
keeps the award from flickering.
- **A ring road counts as a simple path.** No vertex is revisited, so six
segments around one hex are worth five, not six.
- **The win is checked when a seat's turn arrives**, not only on that seat's own
action, so points that move on another player's turn still win.
- **A bought Victory Point card does not win immediately.** It is counted when
the win is next checked (a build or the end of the turn), which is when the
holder would reveal it.
- **Year of Plenty two-of-a-kind needs the bank to hold both cards**, not one.
- **The robber must move.** Some printings allow staying put; this engine always
requires a different hex.
- **Offers may be open to the whole table** and can be countered; the first accept wins (see Trading).
## Known gaps
- **Longest Road and Largest Army are worth 5 each here.** The published base game awards
**2** each (Catan Universe's almanac says so for Largest Army, and the printed rules
for both). Cities & Knights scores Longest Road at 2, as printed. Left unchanged in
the base game so far; it is a one-constant change in `publicVictoryPoints`.
- **A bank short of a resource pays nobody that roll**, even when only one player is owed
it. The published ruling lets a lone claimant take what is left; Cities & Knights does.
- **No timer or cancel on offers.** An offer waits for each asked seat in turn; the offerer cannot withdraw it mid-round, and a player who never answers stalls the table.
- **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them.
- **No trading away a development card.** Not part of the base game.
- **The screen is built, not yet verified on two devices.** The Katan screen and
setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a
dev-channel preview until two-device play has been verified.
- **The AI does not look ahead.** It scores one move deep with a fixed resource
valuation. It plays legally and plausibly; it is not a strong opponent, and no
strength claim is made.
- **The board is the standard published layout, generated.** Unlike the Sequence
board there is no unverified substitute here — the geometry is the real one —
but the *island* (terrain and token placement) is freshly generated each game,
so it will not match any particular physical set-up. That is the intent.
+43
View File
@@ -0,0 +1,43 @@
# Saved games
A game played against the computer (or on one device) is kept as it is played, and can be
picked up again after the app has been closed.
## What the player sees
- **Main menu → Continue.** Each game left half-played is listed, most recent first
("Chess · saved 5 minutes ago"); tapping it resumes.
- **A game's setup screen** shows a *Game in progress* card with **Resume** and **Discard**.
Starting a new game from that screen replaces the saved one.
- **Leaving the table** (the back arrow, or the app being closed or sent to the background)
keeps the position. **End game → End the game** is the deliberate way to throw it away.
- A game that is **finished** is not kept.
- A **networked** table is not kept: the other player's seat cannot be held for them. Only a
table with no remote human at it (`PlayerKind.HUMAN_REMOTE`) is saved.
One slot per game: there is one saved Chess game, one saved Katan game, and so on.
## How it works
| Piece | Where | Does |
| --- | --- | --- |
| `SavedMatch`, `SavedGameStore` | `game/engine/SavedGame.kt` | one JSON file per game under `filesDir/saved-games/`, written atomically (temp file, then renamed); an unreadable file is deleted and read as absent |
| `MatchController.onHostState`, `restore` | `net/MatchController.kt` | the host reports every position it reaches; `restore(json)` starts the table from a saved position instead of dealing |
| `MatchSaver` | `net/MatchSaver.kt` | takes the newest position and writes it off the main thread; `flush()` writes synchronously on leaving or on `ON_STOP`; `discard()` forgets the game |
| `StartMatch` | `ui/PlayScreen.kt` | wires the two together for every game; consumes the resume state once, so a solitaire "new deal" is not put back |
| `LocalEndGameHandle` | `ui/EndGameHandle.kt` | how each *End game* dialog tells the table to forget its save |
What is saved is `rules.encodeState` of the **host's true state** (hidden cards included),
plus the `TableConfig` (seed, seats, options) and the local seat. Nothing in the store knows
which game it is holding, so a new game is saved with no work, provided its state encodes.
A save this build cannot decode (one written by an older version whose state has since
changed shape) is thrown away and a fresh game is dealt.
## Tests
- `SavedGameStoreTest` — the files: round trip, one slot, ordering, corruption, a cut-off write.
- `MatchSaverTest` — saving as a table is played, resuming from a save, finished games, flush, discard.
- `SaveRoundTripAllGamesTest` — **every game in the catalogue**: the whole state is encoded and
decoded at every move of a computer-played game, and play carries on from the decoded copy.
A new game should be added to it.
+171
View File
@@ -0,0 +1,171 @@
# Katan UI — how it is built, and how to change it
The Katan screen (`app/src/main/java/org/prolibertate/games/ui/game/catan/`) is
built in layers so that **look, wording, layout and interaction can each be
changed on their own**. If you are an agent asked to restyle, retheme,
re-copy or re-lay-out Katan, start here and touch only the layer you were asked
about.
The rules live elsewhere and are not part of the UI: `game/catan/` is a pure
state machine (see [RULES-catan.md](RULES-catan.md)). **Nothing in `ui/game/catan/`
decides what is legal.** The engine's legal-move list is the single source of
truth, so the UI can only ever offer a move the rules would accept.
## The layers
| Layer | File | Owns | Change it to… |
| --- | --- | --- | --- |
| **Style** | `CatanStyle.kt` | Every colour, proportion and the light/dark palettes | Re-skin: new terrain colours, seat colours, piece sizes, token look |
| **Copy** | `CatanText.kt` | Every sentence, button label and name shown | Reword, localise, rename resources |
| **Geometry** | `CatanGeometry.kt` | Engine lattice → pixels, and pixel → nearest vertex/edge/hex | Change board scale, margin, harbour placement, tap reach (rarely) |
| **Interaction** | `CatanInteraction.kt` | What is tappable, what a tap means, which buttons are live | Change *how* something is selected (never *whether* it is legal) |
| **Board drawing** | `CatanBoardView.kt` | The `Canvas`: tiles, tokens, ports, robber, roads, pieces, highlights | Change how a piece is drawn (art, shapes, animation) |
| **Panels** | `CatanPanels.kt` | Resource tiles, hand, player cards, dice, action bar, log | Restyle or rearrange widgets |
| **Dialogs** | `CatanDialogs.kt` | Discard, robber victim, play card, trade, trade reply, game over | Change a dialog's flow or look |
| **Screen** | `CatanScreen.kt` | Wiring to `MatchController`, portrait/landscape layout, which dialog shows | Change the layout or add a new element |
| **Previews** | `CatanPreviews.kt` | Android Studio previews of real positions | Iterate on the look without a match |
Everything outside this folder that mentions Katan is one-line wiring:
`PlayScreen.kt` (builds the `MatchController`), `GameSetupScreen.kt` (players and
points-to-win options), `Catalog.kt` (the catalogue entry).
## Recipes
**Change colours or sizes.** Edit `CatanStyle.kt`. `LightStyle` is the reference
palette; `DarkStyle` is derived from it. Metrics (`roadWidth`, `settlementSize`,
`tokenRadius`, …) are fractions of the hex radius, so they scale with the screen.
To try a whole new theme, provide it at the root:
`CompositionLocalProvider(LocalCatanStyle provides myStyle) { … }`.
**Change wording or translate.** Edit `CatanText.kt`. Composables never build a
sentence themselves. `prompt(...)` is the one-line "what is happening / what do I
do" text; it is a pure function of the state, with tests in `CatanTextTest`.
**Redraw a piece** (settlement, road, robber, port, token). Edit the matching
`draw…` function in `CatanBoardView.kt`. Positions come from `BoardLayout`, so you
only decide the shape. Keep the hit target generous (see Accessibility).
**Rearrange the screen.** Edit `CatanScreen.kt`. It builds four independent
pieces (`board`, `players`, `promptRow`, `handAndActions`) and arranges them
differently in portrait and landscape. Moving or dropping a piece does not affect
the others.
**Add a widget.** Make it a stateless composable in `CatanPanels.kt` (values in,
lambdas out). Do not give it the `MatchController`.
**Change how a selection works** (for example a one-tap build instead of a
build-mode toggle). Edit `CatanInteraction.kt` and its tests, then adjust the
screen's UI state. The legal-move list stays the gate.
## Rules of the road
1. **Never put a rule in a composable.** If a button's enabled state or a tap's
result depends on game logic, it goes in `CatanInteraction` (pure, tested) and
is derived from the engine's `legal` list or `CatanRules`.
2. **Never hard-code a colour or a string** outside `CatanStyle` / `CatanText`.
3. **Stateless by default.** Panels and dialogs take values and report events. The
only state they may own is the transient selection they are helping the player
make (cards picked in a trade, for example).
4. **Hidden information.** The local view comes from `CatanRules.viewFor`: other
players' development cards and the deck are empty on a client. Do not show the
size of the deck; it is not known to a client. Card *counts* (`resourceCount`,
`devHandCounts`) are public.
5. **The state is immutable.** Never mutate it; submit a move.
6. **The layout must not move.** Everything outside the board has a *fixed
height*, and the board takes whatever is left, so the island's scale is the
same all game. Concretely: a control is *enabled or disabled*, never
*present or absent* (the action bar always shows every button); text that can
vary in length is pinned to a fixed number of lines (`minLines = maxLines`);
a line that is sometimes empty is still drawn (a non-breaking space), as the
award line on a player card is. A change that makes any non-board element
taller or shorter at runtime will rescale the board under the player's thumb.
## Interaction model (what the player actually does)
| Phase | The player… |
| --- | --- |
| Setup | taps a highlighted crossing (settlement), then a highlighted edge beside it (road). *Cancel* re-picks the settlement |
| Roll | presses **Roll dice** |
| Seven | **Discard** dialog (forced); then taps a hex to move the robber; a victim dialog appears only if more than one player can be robbed |
| Main turn | arms **Road / Settlement / City** and taps a highlighted spot; **Buy card**; **Play card**; **Trade**; **End turn** |
| Road Building | taps highlighted edges, or **Done with roads** |
| Offered a trade | **Accept / Decline / Counter…** dialog (forced); *Counter…* opens a picker pre-filled with the mirror of the offer |
| Counter-offers received | **Counter-offers** dialog: *Accept* beside the one to take, or *No deal* |
| Game over | result dialog |
Trade offers are free-form (two card multisets), so they are validated with
`CatanRules.isLegalMove`, not looked up in the legal list.
## Testing
Gradle does not run in the agent sandbox; **CI is the compile and test check**
(dispatch `ci.yml` on your branch). What is covered:
- `CatanGeometryTest` — the island fits any box, edges and corners are one radius,
hit-testing finds the right vertex/edge/hex.
- `CatanInteractionTest` — targets and tap outcomes for setup, building, free
roads, the robber (including the victim choice), discards, bank trades, offers,
development cards and trade replies, all against real engine states.
- `CatanTextTest` — the prompts and tallies.
Drawing and dialogs are not unit-tested; use the previews in `CatanPreviews.kt`
and play a game on a dev build.
## Accessibility notes
- Tap targets: vertices and edges accept a tap within `tapReach` (0.7) hex radii,
about 23 dp on a phone. If you shrink the board, keep that physical size.
- Seat colours are blue, orange, white and purple, each outlined in near-black so
they read on any terrain. Colour is the only piece identity today; adding a
shape or letter per seat would help colour-blind players (a good first task).
- The board has a content description but no per-hex semantics; a screen-reader
mode would need one.
## Cities & Knights on the screen
The same layers carry the expansion; the screen only adds pieces, and **none of them
decides what is legal** — each reads the engine's legal-move list.
| Layer | What it adds |
| --- | --- |
| `CatanStyle.kt` | commodity colours, a colour per improvement track, the wall and barbarian colours, the awake-knight halo |
| `CatanText.kt` | goods, track, knight and progress-card names and help text; the prompts for every chore |
| `CatanInteraction.kt` | `BuildKind.WALL` / `KNIGHT`; chore taps; the moving-knight and armed-card modes; `knightActions`, `progressHand`, `goodsBankTrades` |
| `CatanBoardView.kt` | shields for knights (strength dots; faded when asleep, white halo when awake), dashed walls, a pennant for each metropolis, the merchant coin, a faint robber until the first attack |
| `CatanPanels.kt` | commodity tiles in the hand, the barbarian strip, the yellow / red / event dice, the extra player-card lines, the Improve and Progress buttons |
| `CatanCkDialogs.kt` | goods picker (discard, wedding, harbour), improvements, progress cards, the knight menu, deck / aqueduct / spy / drop choices, goods trade |
| `CatanScreen.kt` | the visual state: knight menu, moving knight, armed card, and which chore dialog shows |
**Interaction model.** Tap one of your own knights (it gets a yellow ring when it can do
something) for its menu: wake, promote, move or chase the robber. *Move* arms the board
with the crossings the knight can reach. A progress card that needs a place on the board
(Engineer, Medicine, Merchant, Diplomat, Intrigue, Inventor) closes the dialog and
**arms** the board; the next tap plays it. Chores that are answered with a place — a city
to lose, a metropolis site, a driven knight's refuge, a free knight — light the crossings
on the board and the prompt line says what to tap; the others open a dialog that cannot
be dismissed, because the game is waiting on it.
**Testing.** `CatanCkInteractionTest` plays whole games and checks that for *every* legal
move at *every* position some control (a tap, a button or a dialog choice) produces it, so
a legal move with no way to make it — a stuck phone — fails the build.
## Status and known gaps
The screen is **playable and feature-complete for the engine's rules**, but is
**unverified on two real devices**, so the catalogue entry is `devPreview`
(openable on a dev build, hidden from production). To release it: verify
two-device play, then in `Catalog.kt` set `available = true` and remove
`devPreview = true`, and update `CatalogTest`.
Not done yet, in rough priority order:
1. Two-device verification (host + guest over the LAN) and a full game on a phone
and a tablet.
2. Feedback on events: dice-roll animation, a highlight on hexes that produced,
resource-gain flourishes, haptics. The state carries the dice and a text log;
it does not say which hexes paid out, so that needs either a diff of two states
or an engine addition.
3. Art: terrain textures, resource and harbour icons, a real robber pawn.
4. A timer or cancel button on offers (the engine has neither).
5. A board that shows the *last* move (a faint highlight of what just changed).
6. Landscape polish on large tablets; the right-hand panel scrolls but is plain.
+16 -1
View File
@@ -1,5 +1,12 @@
# Project-wide Gradle settings. # Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # C1-only JIT (-XX:TieredStopAtLevel=1): the Gitea runner's JVMs have been
# crashing intermittently (SIGSEGV in HotSpot's compiler, bad Kotlin compiler
# codegen) on Zulu, Corretto and Temurin alike. Dropping C2 is the test for
# whether the optimising JIT is the trigger; it costs some build speed. The
# Kotlin daemon needs its own copy: setting its options stops it inheriting
# org.gradle.jvmargs.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:TieredStopAtLevel=1
kotlin.daemon.jvm.options=-Xmx2048m,-XX:TieredStopAtLevel=1
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
@@ -23,3 +30,11 @@ android.nonTransitiveRClass=true
# it keeps climbing on its own and an APK's versionCode never goes backwards # it keeps climbing on its own and an APK's versionCode never goes backwards
# when this changes. # when this changes.
plgVersionSeries=1.4 plgVersionSeries=1.4
# The permanent floor under every stamped build number: versionCode, versionName
# and the release tag are all `run number + this`. Run numbers restart at 1 on a
# new CI host (Gitea Actions), and Android refuses any versionCode lower than the
# one installed, so without a floor the first build after a move would look like
# a downgrade. 1000 sits above every number GitHub reached (prod 30, dev ~172).
# Set once and never changed.
plgVersionRunOffset=1000
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Not found</title></head>
<body><h1>404</h1><p>Not found. <a href="/">Pro Libertate Games downloads</a></p></body></html>
+7
View File
@@ -0,0 +1,7 @@
/*/latest.json
Cache-Control: no-cache
Access-Control-Allow-Origin: *
/*.apk
Content-Type: application/vnd.android.package-archive
Cache-Control: public, max-age=300
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pro Libertate Games</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
.card { border: 1px solid #8884; border-radius: .5rem; padding: 1rem; margin: 1rem 0; }
a.dl { display: inline-block; padding: .5rem 1rem; border-radius: .4rem; background: #2563eb; color: #fff; text-decoration: none; }
small { opacity: .7; }
</style>
</head>
<body>
<h1>Pro Libertate Games</h1>
<p>Android builds. Allow <em>Install unknown apps</em> for your browser to sideload.</p>
<div class="card" id="production"><h2>Production</h2><p>Loading…</p></div>
<div class="card" id="dev"><h2>Dev</h2><p>Loading…</p></div>
<script>
for (const [channel, path] of [["production", "prod"], ["dev", "dev"]]) {
const box = document.getElementById(channel);
const title = box.querySelector("h2").outerHTML;
fetch(`/${path}/latest.json`, { cache: "no-cache" })
.then(r => r.ok ? r.json() : Promise.reject())
.then(j => {
box.innerHTML = `${title}<p><a class="dl" href="${j.apkUrl}">Download ${j.tag}</a></p>` +
`<small>Always newest: <a href="/${path}/ProLibertateGames.apk">/${path}/ProLibertateGames.apk</a></small>`;
})
.catch(() => { box.innerHTML = `${title}<p>No build published yet.</p>`; });
}
</script>
</body>
</html>