Compare commits

...
Author SHA1 Message Date
Claude 6c311ad304 Katan: expansions as toggles, scenario chosen in options
CI / Build and check (pull_request) Successful in 10m56s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Replace the single CatanRuleSet with a set of expansions and add a
scenario field; saves with the old ruleSet still decode. Setup shows
a toggle per expansion and a map picker for Seafarers. Combining
expansions is rejected for now (lifted in a later phase).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-11 16:50:24 +00:00
devadmin f8c04cec88 Merge pull request 'Unify computer difficulty; random Katan first seat' (#32) from feature/unify-difficulty-catan-first-seat into dev
CI / Build and check (push) Successful in 10m48s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-11-08 08:33:48 -06:00
Claude 97728daf0a Fold per-game opponent levels into computer difficulty; randomise Katan first seat
CI / Build and check (pull_request) Successful in 11m2s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
The per-game Casual/Club/Strong rows duplicated the new Computer difficulty
setting, so they are gone from setup (Tayu's Gentle stays: it is a table rule).
The saved level is still the Medium baseline, so old saves load unchanged.
Mastermind now takes its level from the difficulty. Katan's first seat is
drawn from the table seed instead of always being seat 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-08 14:22:24 +00:00
devadmin a30b1dfcd0 Merge pull request 'Computer difficulty levels and hidden strategies' (#31) from feature/ai-difficulty into dev
CI / Build and check (push) Successful in 11m5s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-11-07 23:26:08 -06:00
Claude 961ac96689 Merge remote-tracking branch 'origin/dev' into feature/ai-difficulty
CI / Build and check (pull_request) Successful in 11m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-08 05:14:49 +00:00
Claude e1c325825f Computer difficulty levels and hidden per-seat strategies
Every game with computer opponents gains a setup choice (Easy, Medium, Hard,
Mixed), carried in TableConfig.aiDifficulty and through the host lobby. Each
computer seat gets a level and a hidden style (balanced, aggressive, cautious,
spoiler) derived from the table seed, so saves and hosted tables reproduce it.
Medium plays exactly as each AI did before. Easy plays weaker and slips
plausibly; Hard searches deeper or counts cards, never using hidden
information. Covers Chess, Checkers, Morris, Backgammon, Pirates, Katan (base,
Cities & Knights, Seafarers), Euchre, Kaiser, Wizard, Hearts, President, Crazy
Eights, Golf, Cribbage, Sequence, Tayu and Yahtzee.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-08 05:14:49 +00:00
devadmin f967c31368 Merge pull request 'Production: ship a non-debuggable release build' (#29) from fix/prod-release-build into dev
CI / Build and check (push) Successful in 9m0s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
CI / Build and check (pull_request) Successful in 2m8s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-07 14:28:26 -06:00
Claude 4ed110e68c Production release: build and ship a non-debuggable release APK
CI / Build and check (pull_request) Successful in 12m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
release.yml shipped app-debug.apk, so production was debuggable. It now runs
assembleRelease, signed with the same keystore (identical certificate, so
updates still install over existing ones), with isDebuggable = false.
Minification stays off: R8 cannot be tried on a device here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-07 20:15:24 +00:00
devadmin b818997699 Merge pull request 'End game on every card game; Resume game menu' (#27) from feature/end-game-and-resume into dev
CI / Build and check (push) Successful in 9m35s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
CI / Build and check (pull_request) Successful in 9m21s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-07 12:35:01 -06:00
Claude aeda2924f4 End-game button on every card game; Resume game menu
CI / Build and check (pull_request) Successful in 9m33s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
President, Crazy Eights, Cribbage, Euchre, Golf, Kaiser, Sequence and Wizard
gain the "End game" button and the left-the-table dialog the other games have.
The main menu's inline Continue list becomes a Resume game button opening a
screen of all saved games, each with Resume and Delete (confirmed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-07 18:25:11 +00:00
devadmin 2e234c7f20 Merge pull request 'Katan: barbarians and improvement levels in the wide HUD' (#26) from fix/katan-landscape-hud into dev
CI / Build and check (push) Successful in 9m4s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-29 10:12:26 -06:00
Claude f4f53a93b6 Katan: show barbarian strip and improvement levels in the wide HUD
CI / Build and check (pull_request) Successful in 8m47s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-29 16:03:31 +00:00
devadmin bce9b68ece Merge pull request 'Katan: Seafarers rule set' (#14) from Seafarers into dev
CI / Build and check (push) Successful in 9m12s
CI / Build and check (pull_request) Successful in 9m16s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-10-28 21:27:34 -06:00
Claude 68cd799f89 Merge dev into Seafarers
CI / Build and check (pull_request) Successful in 9m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-29 03:04:13 +00:00
devadmin 6c549a8b76 Merge pull request 'Yahtzee: end-of-game stats dialog' (#23) from yahtzee-stats into dev
CI / Build and check (push) Successful in 8m46s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
CI / Build and check (pull_request) Successful in 8m46s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-26 22:36:19 -06:00
Claude ebab7a8415 Yahtzee: end-of-game stats dialog compared with previous games
CI / Build and check (pull_request) Successful in 8m51s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 04:26:52 +00:00
devadmin a0b15b3140 Merge pull request 'Yahtzee: two-column score card, larger text' (#22) from yahtzee-card-layout into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-26 22:00:12 -06:00
Claude 8990dde363 Yahtzee: two-column score card with larger text, scrolls sideways when needed
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 03:50:51 +00:00
devadmin c2b21a07f5 Merge pull request 'Yahtzee: pip dice, confirm zero scores; text size setting' (#21) from yahtzee-improvements into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-10-26 20:03:26 -06:00
Claude e98afbfd8d Yahtzee: pip dice, confirm zero scores; add text size setting
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 01:52:15 +00:00
devadmin 2c90de66d1 Merge pull request 'Release Katan and Meowdoku to production' (#19) from fix/release-katan-meowdoku into dev
CI / Build and check (push) Successful in 8m50s
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 34s
2026-10-26 12:23:15 -06:00
Claude f93eba88e5 Release Katan and Meowdoku: open the production gate
CI / Build and check (pull_request) Successful in 8m43s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 18:14:12 +00:00
devadmin 36ab518309 Merge pull request 'Scorekeeper: name players at setup; refocus after a round' (#17) from feature/scorekeeper-setup-names into dev
CI / Build and check (push) Successful in 9m36s
CI / Build and check (pull_request) Successful in 9m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 36s
2026-10-26 10:35:39 -06:00
Claude 77cc6c5a05 Scorekeeper: ask for each player's name at setup; refocus first cell after a round
CI / Build and check (pull_request) Successful in 9m40s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 04:24:25 +00:00
devadmin 166f85734b Merge pull request 'Scorekeeper: remember the last 10 player names' (#16) from feature/scorekeeper-memory into dev
CI / Build and check (push) Successful in 8m44s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-25 21:34:23 -06:00
Claude 25e38c4bf8 Scorekeeper: remember the last 10 player names and offer them when naming a column
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 03:16:53 +00:00
devadmin 6bf7522042 Merge pull request 'Meowdoku: rules, AI and screen refinements' (#15) from meow2 into dev
CI / Build and check (push) Successful in 20m52s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-25 20:29:24 -06:00
Claude 7942adeaf7 Meowdoku: rules, AI and screen refinements
CI / Build and check (pull_request) Successful in 10m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 01:14:45 +00:00
Claude 401ebe0118 Docs index: Seafarers
CI / Build and check (pull_request) Successful in 8m59s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 17:19:11 +00:00
Claude 3c0469fc81 Seafarers: interaction tests, rules and UI docs 2026-10-24 17:18:59 +00:00
Claude 5d36b7bd62 Seafarers: rules tests and engine self-play, AI heads for new islands 2026-10-24 17:15:16 +00:00
Claude b5b4afa731 Merge remote-tracking branch 'origin/dev' into Seafarers 2026-10-24 17:13:14 +00:00
Claude f88099f4e2 WIP Seafarers engine, board and screen plumbing 2026-10-24 17:13:14 +00:00
devadmin 488f600f15 Merge pull request 'Katan: Catan Universe style HUD and trade board (2D)' (#13) from CatanImprovements into dev
CI / Build and check (push) Successful in 8m49s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-24 11:13:07 -06:00
Claude 9b938ffd8b Merge remote-tracking branch 'origin/dev' into CatanImprovements
CI / Build and check (pull_request) Successful in 8m41s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 16:58:58 +00:00
Claude ccf8d5a1ba Katan HUD: compact landscape layout for phone heights
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WrH771kLJmTHnEfcmV2YDx
2026-10-24 16:58:58 +00:00
devadmin a00df43a44 Merge pull request 'Meowdoku: cat puzzle engine, screen and docs' (#12) from meow into dev
CI / Build and check (push) Successful in 8m38s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-24 10:54:17 -06:00
Claude fae031d2b5 Katan: Catan Universe style landscape HUD and trade board
Drag-to-build with confirm/cancel, pinch/pan board, round turn and trade
buttons, and a trade board panel (bank, player, reply). Findings from the
Catan Universe 2.7.5 reverse engineering are in docs/RE-catan-universe.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WrH771kLJmTHnEfcmV2YDx
2026-10-24 16:49:18 +00:00
Claude d568ff7750 Meowdoku: one cat per row, column and colour, no two touching
CI / Build and check (pull_request) Successful in 8m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 16:32:39 +00:00
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: https://git.wallacenet.ca/devadmin/ProLibertateGames/pulls/2
2026-10-14 15:57:04 -06:00
Claude fb59372802 Restore the Katan catalogue entry the engine depends on
CI / Build and check (pull_request) Successful in 13m46s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
The Option 2 migration dropped Gitea's 5c797c8, which added GameCatalog.CATAN
and a Katan entry (available = false: Coming soon on dev, hidden from
production). CatanRules.kt references GameCatalog.CATAN, so the merge did not
compile without it. Taken verbatim from feature/catan-engine, with the
matching CatalogTest assertions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The installed applicationId is left unchanged in this commit.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 14:21:43 +00:00
157 changed files with 22804 additions and 1971 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Assembles the Cloudflare Pages site for plg.wallacenet.ca into ./pages-out.
#
# Usage: stage-pages-site.sh <prod|dev> <tag> <apk-path>
#
# A Pages deploy replaces the whole site, and both channels live in one site,
# so this stages the channel being published and then re-fetches the *other*
# channel's current files from the live site so the deploy does not erase them.
set -euo pipefail
channel="$1"; tag="$2"; apk="$3"
base="${PAGES_BASE_URL:-https://plg.wallacenet.ca}"
out="pages-out"
limit=$((25 * 1024 * 1024)) # Cloudflare Pages: 25 MiB per file
case "$channel" in prod) other=dev ;; dev) other=prod ;; *) echo "bad channel: $channel" >&2; exit 2 ;; esac
rm -rf "$out"
mkdir -p "$out/$channel" "$out/$other"
cp site/index.html site/404.html site/_headers "$out/"
name="$(basename "$apk")"
cp "$apk" "$out/$channel/$name"
cp "$apk" "$out/$channel/ProLibertateGames.apk"
# node rather than jq: the Gitea runner's image has node but not jq.
node -e 'console.log(JSON.stringify({tag: process.argv[1], apkName: process.argv[2], apkUrl: process.argv[3], channel: process.argv[4]}, null, 2))' \
"$tag" "$name" "$base/$channel/$name" "$channel" > "$out/$channel/latest.json"
# PLG_SKIP_OTHER=1 is for the very first deploy, when the site does not exist
# yet and the domain answers 403 (Cloudflare error 1014) instead of 404.
if [ -n "${PLG_SKIP_OTHER:-}" ]; then
echo "PLG_SKIP_OTHER set; not carrying over $other."
rm -rf "$out/$other"
find "$out" -type f | sort
exit 0
fi
# Carry the other channel over. 404 means it was never published; any other
# failure must stop the deploy rather than silently dropping that channel.
code="$(curl -sS -o "$out/$other/latest.json" -w '%{http_code}' "$base/$other/latest.json" || true)"
: "${code:=000}"
# Cloudflare error 1014 (403 + "error code: 1014") means the domain's CNAME
# points at a Pages project that does not exist yet, so there is nothing to
# preserve — the same as a 404, and what a push-triggered first deploy sees.
if [ "$code" = 403 ] && grep -q 'error code: 1014' "$out/$other/latest.json" 2>/dev/null; then
code=404
fi
if [ "$code" = 404 ]; then
echo "No $other channel published yet; skipping."
rm -rf "$out/$other"
elif [ "$code" = 200 ] && ! other_name="$(node -p 'const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if (!j.apkName) process.exit(1); j.apkName' "$out/$other/latest.json" 2>/dev/null)"; then
# A 200 that is not our JSON: Pages without a 404.html answers unknown paths
# with index.html. Nothing is published on that channel.
echo "No $other channel published yet (200 but no latest.json); skipping."
rm -rf "$out/$other"
elif [ "$code" = 200 ]; then
curl -fsS -o "$out/$other/$other_name" "$base/$other/$other_name"
cp "$out/$other/$other_name" "$out/$other/ProLibertateGames.apk"
else
echo "Fetching $other/latest.json returned HTTP $code; refusing to deploy." >&2
exit 1
fi
find "$out" -name '*.apk' -size +"${limit}c" -print | grep . >&2 && {
echo "APK exceeds Cloudflare Pages' 25 MiB per-file limit." >&2; exit 1; } || true
find "$out" -type f | sort
+91 -37
View File
@@ -1,27 +1,28 @@
name: CI
# Pre-merge checks. Runs on the dev branch and on every pull request aimed at
# dev or main, so breakage is caught before it reaches main.
# dev or main, so breakage is caught before it reaches main and triggers a
# release.
#
# On a push to dev it also publishes the build as a dev-channel prerelease on
# this Gitea instance, so the in-app updater can offer it to anyone on the dev
# channel. Prereleases are used deliberately: the "latest release" endpoint
# skips them, so a production-channel user never sees a dev build. Pull
# requests only build and check — they never publish, which keeps a PR from a
# fork from shipping anything.
#
# These files live in .github/workflows/ on purpose: Gitea Actions reads that
# path as well as .gitea/workflows/, so one file serves both. Publishing goes
# through Gitea's REST API (the same routes the app's updater reads) rather
# than the `gh` CLI, which a runner image need not carry.
# On a push to dev it also publishes the build as a dev-channel prerelease, so
# the in-app updater can offer it to anyone who has switched to that channel.
# Prereleases are used deliberately: GitHub's "latest release" endpoint skips
# them, so a production-channel user never sees a dev build. Pull requests only
# build and check — they never publish, which keeps a PR from a fork from
# shipping anything.
on:
push:
branches: [ dev ]
pull_request:
branches: [ main, dev ]
workflow_dispatch:
inputs:
first_deploy:
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
type: boolean
default: false
# Needed by the job token to create the release and attach the APK.
# Needed to publish the dev-channel prerelease below.
permissions:
contents: write
@@ -34,6 +35,8 @@ jobs:
build:
name: Build and check
runs-on: ubuntu-latest
outputs:
version: ${{ steps.stage.outputs.version }}
env:
# Stamp the APK as a dev-channel build exactly when it is a build of the
# dev branch, which is exactly when it can be published below. Pull
@@ -41,7 +44,10 @@ jobs:
# <series>.0-dev: they are never published and must not claim a channel
# version.
#
# This condition has to stay in step with the publish steps' own.
# This condition has to stay in step with the publish steps' own. Stamping
# a build the publish steps would ship, or shipping one this left
# unstamped, both end the same way — an APK claiming versionCode 1, which
# the updater can never see as an upgrade over anything.
PLG_CHANNEL: ${{ github.ref == 'refs/heads/dev' && 'dev' || '' }}
steps:
- name: Checkout
@@ -69,17 +75,30 @@ jobs:
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: PilgrimsOfKatan-debug-apk
name: ProLibertateGames-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 14
- name: Upload JVM crash logs
# A JVM crash writes hs_err_pid*.log into the workspace, which dies
# with the job container; keep it so the failing frame can be read.
if: failure()
uses: actions/upload-artifact@v4
with:
name: ProLibertateGames-jvm-crash
path: |
hs_err_pid*.log
replay_pid*.log
if-no-files-found: ignore
retention-days: 14
- name: Upload lint and test reports
# Reports are most useful precisely when a step above failed.
if: always()
uses: actions/upload-artifact@v4
with:
name: PilgrimsOfKatan-reports
name: ProLibertateGames-reports
path: |
app/build/reports/
if-no-files-found: ignore
@@ -89,39 +108,74 @@ jobs:
# branch itself, after it has built, tested and linted clean above.
#
# Gated on the branch alone rather than on the event, which is how
# release.yml gates main. A pull request is still excluded without
# needing to name it: a PR build runs against refs/pull/N/merge, never
# refs/heads/dev, so a fork still cannot ship anything.
# release.yml has always gated main. A pull request is still excluded
# without needing to name it: a PR build runs against refs/pull/N/merge,
# never refs/heads/dev, so a fork still cannot ship anything.
- name: Stage dev APK
id: stage
if: github.ref == 'refs/heads/dev'
run: |
set -euo pipefail
# The series and the offset come from gradle.properties, which is
# the same file the build read when it stamped the APK — so the tag
# and what is inside it cannot name different versions. The number
# is run number + offset, exactly the arithmetic build.gradle.kts
# did for versionCode.
# The series comes from gradle.properties, which is the same file the
# build read when it stamped the APK — so the tag and what is inside
# it cannot name different versions.
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$series"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$offset"
stamp="$(( ${{ github.run_number }} + offset ))"
version="v${series}.${stamp}-dev"
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
version="v${series}.$(( ${{ github.run_number }} + offset ))-dev"
src="app/build/outputs/apk/debug/app-debug.apk"
dest="PilgrimsOfKatan-${version}.apk"
dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT"
- name: Publish dev channel prerelease
- name: Upload staged dev APK for the Pages deploy
if: github.ref == 'refs/heads/dev'
uses: actions/upload-artifact@v4
with:
name: plg-dev-staged
path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error
retention-days: 1
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
# the only release channel: the in-app updater reads latest.json from there.
#
# Both workflows deploy into one Pages site, and a deploy replaces the whole
# site, so they share a concurrency group and never run at the same time.
deploy-pages:
name: Deploy to Cloudflare Pages
needs: build
if: github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
concurrency:
group: plg-pages-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download staged APK
uses: actions/download-artifact@v4
with:
name: plg-dev-staged
path: staged
- name: Assemble site
env:
# The job's built-in token: contents: write is declared above.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TAG: ${{ steps.stage.outputs.version }}
RELEASE_APK: ${{ steps.stage.outputs.apk }}
RELEASE_KIND: prerelease
RELEASE_TARGET: ${{ github.sha }}
# One script, also runnable by hand — see docs/CI.md.
run: ./scripts/publish-release.sh
PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
run: .github/scripts/stage-pages-site.sh dev "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
- name: Deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
# Creates the project on first run; fails harmlessly if it exists.
npx --yes wrangler@3 pages project create plg --production-branch main || true
# --branch main makes this the production deployment, which is what
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
+70 -34
View File
@@ -1,17 +1,18 @@
name: Build and Release APK
# Production-channel build. Runs on a push to main, or by hand
# (workflow_dispatch), and publishes to this Gitea instance's releases — which
# is the channel the in-app updater reads. See docs/CI.md.
#
# Publishing goes through Gitea's REST API rather than the `gh` CLI, which a
# runner image need not carry.
# Build an APK and publish it as a GitHub Release whenever commits land on main
# (for example when a pull request is merged). Can also be run manually.
on:
push:
branches: [ main ]
workflow_dispatch:
inputs:
first_deploy:
description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)"
type: boolean
default: false
# Needed by the job token to create the release and attach the APK.
# Allow the built-in token to create releases and upload the APK.
permissions:
contents: write
@@ -23,13 +24,16 @@ concurrency:
jobs:
build:
name: Build APK and publish release
# workflow_dispatch can be aimed at any branch from the Actions UI. The
# production stamp belongs to main alone — anything else is a no-op rather
# than shipping unreviewed code to everybody.
# workflow_dispatch can be aimed at any branch from the Actions UI. Releases
# are what the in-app updater installs, so only main may produce one —
# anything else is a no-op rather than shipping unreviewed code to everyone.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.stage.outputs.version }}
env:
# Stamps the APK as a production-channel build.
# Stamps the APK as a production-channel build, versioned with the run
# number that matches the tag published below.
PLG_CHANNEL: production
steps:
- name: Checkout
@@ -45,44 +49,76 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v4
- name: Build production APK
run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Build release APK
run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: Stage APK
- name: Stage APK for release
id: stage
run: |
set -euo pipefail
# The series and the offset come from gradle.properties, which is
# the same file the build read when it stamped the APK — so the tag
# and what is inside it cannot name different versions. The number
# is run number + offset, exactly the arithmetic build.gradle.kts
# did for versionCode.
# The series comes from gradle.properties, which is the same file the
# build read when it stamped the APK — so the tag and what is inside
# it cannot name different versions.
series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$series"
offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)"
test -n "$offset"
stamp="$(( ${{ github.run_number }} + offset ))"
version="v${series}.${stamp}"
src="app/build/outputs/apk/debug/app-debug.apk"
dest="PilgrimsOfKatan-${version}.apk"
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
version="v${series}.$(( ${{ github.run_number }} + offset ))"
src="app/build/outputs/apk/release/app-release.apk"
dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest"
# The same build again under a name that never changes. GitHub's
# /releases/latest/download/<name> redirect needs a constant file
# name, so this second copy is what makes a permanent download link
# possible — the versioned copy stays as the traceable one.
cp "$src" "ProLibertateGames.apk"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT"
- name: Upload APK as workflow artifact
uses: actions/upload-artifact@v4
with:
name: PilgrimsOfKatan-apk
name: plg-prod-staged
path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error
- name: Publish production release
# Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is
# the only release channel: the in-app updater reads latest.json from there.
#
# Both workflows deploy into one Pages site, and a deploy replaces the whole
# site, so they share a concurrency group and never run at the same time.
deploy-pages:
name: Deploy to Cloudflare Pages
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: plg-pages-deploy
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download staged APK
uses: actions/download-artifact@v4
with:
name: plg-prod-staged
path: staged
- name: Assemble site
env:
# The job's built-in token: contents: write is declared above.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TAG: ${{ steps.stage.outputs.version }}
RELEASE_APK: ${{ steps.stage.outputs.apk }}
RELEASE_KIND: release
RELEASE_TARGET: ${{ github.sha }}
# One script, also runnable by hand — see docs/CI.md.
run: ./scripts/publish-release.sh
PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }}
run: .github/scripts/stage-pages-site.sh prod "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk"
- name: Deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
# Creates the project on first run; fails harmlessly if it exists.
npx --yes wrangler@3 pages project create plg --production-branch main || true
# --branch main makes this the production deployment, which is what
# plg.wallacenet.ca serves, whichever branch the workflow ran on.
npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true
+90 -76
View File
@@ -1,95 +1,109 @@
# Working on this repo (agent notes)
# Working on this repo with Claude
ProLibertateGames — Wallacenet's build of an Android collection of card and
board games, playable against AI or over Wi-Fi (LAN). Upstream:
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
(GPL-3.0, kept as the `upstream` remote). Canonical repo:
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>.
CI runs on **Gitea Actions** at `git.wallacenet.ca/devadmin/ProLibertateGames`
(workflows in `.github/workflows/`), publishing to Cloudflare Pages
(`plg.wallacenet.ca`). The workflow below was measured on the dev machine
(`~/ProLibertateGames`); it replaces the earlier GitHub-sandbox notes, which no
longer apply.
## Ground rules
## Build, test and lint locally
- **Licences travel.** `LICENSE` is GPL-3.0 and must stay. The Uncial Antiqua
font ships with its OFL licence at `docs/licenses/UncialAntiqua-OFL.txt`;
never delete or edit either. Attribution to the upstream lives in the README.
- **No Catan trademarks or brand assets** — the one hard line. The app's own
branding is the upstream's, restored (see README → Branding): tartan launcher
icon and splash, Wallace clan red on production and hunting green on dev,
launcher names *Pro Libertate Games* / *Pro Libertate Games DEV*. The
installed identity is `ca.wallacenet.pilgrimsofkatan` (+ `.dev`).
- **The existing catalogue is the regression safety net.** Keep every game
green; new games (Katan) arrive as separate tasks, not edits to old ones.
The 22 reference games are `available = true`; Katan is listed with
`available = false` (*Coming soon*) until its game task lands.
- **Docs conventions**: every game gets `docs/RULES-<id>.md` (index and gaps in
`docs/README.md`); build/CI/release decisions go in `docs/CI.md`. Update the
docs when behaviour changes — they are the check against the rules code.
Gradle works on the dev machine. Two environment variables are required: the
default JDK is 25, which Gradle 8.14 cannot use, and the Android SDK lives in the
home directory.
## Build
```sh
export JAVA_HOME=... # JDK 17
export ANDROID_HOME=... # SDK with platform 34, build-tools 34
./gradlew assembleDebug # dev-channel APK (default)
PLG_CHANNEL=production ./gradlew assembleDebug # production APK
./gradlew testDebugUnitTest # JVM unit tests (rules/AI/net/score)
./gradlew lintDebug # Android lint (CI runs it)
```
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export ANDROID_HOME=$HOME/android-sdk # platform 34, build-tools 34.0.0
./gradlew testDebugUnitTest lintDebug assembleDebug
```
On the Wallacenet project workspace a ready toolchain sits at
`../.toolchain/` (`jdk17/`, `android-sdk/`) — export `JAVA_HOME` and
`ANDROID_HOME` to it rather than installing again. Both variants must build:
they install side by side (different applicationId, launcher name, colour).
The Katan tests run in seconds; a clean build takes a few minutes. Run the full
suite, lint and `assembleDebug` before pushing: they are what CI runs. There is
no KVM, so no emulator; the engine and the UI logic are covered by unit tests
(see `docs/UI-catan.md`).
## Channels and OTA
## Shipping: branch, PR, CI, merge
One flag — `isDevBuild` in `app/build.gradle.kts` — drives applicationId,
launcher name and brand colour together. Never set them individually.
1. Branch from `dev`; before pushing, merge `origin/dev` in (it moves between
sessions).
2. Plain `git push` has no credentials. Use the Gitea token as a one-off header:
`git -c "http.extraheader=Authorization: token $(cat ~/.config/gitea/token)" push -u origin <branch>`.
3. Open the PR with `POST /api/v1/repos/devadmin/ProLibertateGames/pulls`
(same token, `Authorization: token ...`). CI (`ci.yml`) runs on the PR: build,
unit tests and Android lint; it publishes nothing. Poll
`/actions/runs` until it completes (about 7 minutes).
4. Merge with `POST .../pulls/N/merge {"Do":"merge"}`. The push to `dev` runs
`ci.yml` again and publishes the build (about 9 minutes).
In-app OTA points at this repo's own Gitea releases
(`/api/v1/repos/devadmin/PilgrimsOfKatan/releases…`) and is **on**:
`BuildConfig.OTA_ENABLED` is `true`. The channel is a decision already made and
recorded — `docs/CI.md` documents the endpoints, the build-number scheme, and
what publishing requires. Setting the flag to `false` is the kill switch.
## Merging is a shipping action
## CI
Both long-lived branches publish, so a merge is a release, not just an
integration:
`.github/workflows/` — Actions-compatible so the Gitea instance runs them.
`ci.yml`: build + test + lint + artifact, and a dev prerelease from `dev`.
`release.yml`: production release from `main`. Both publish through
`scripts/publish-release.sh` (Gitea REST API), which is also runnable by hand.
- **`dev`** publishes a `v<series>.N-dev` build to
`https://plg.wallacenet.ca/dev/latest.json`, offered by the in-app updater to
anyone on the Dev channel.
- **`main`** runs `release.yml` and publishes a **production** release for
everyone on the Production channel. Confirm before merging to `main`.
`release.yml` runs `assembleDebug` only (no tests, no lint), so a `main`
release is only as verified as the `dev` build it replays.
**No Actions runner is registered on the instance yet**, so the workflows are
idle until one is — the local gradle commands above are the source of truth,
and CI green means those plus lint. Do not treat a workflow that has never run
as evidence.
## Katan
## Code map
The island game is catalogue id `catan`, shown as **Katan**. It has two expansions
(toggles, `CatanOptions.expansions`; none on is the base game): the base game (`docs/RULES-catan.md`), Cities & Knights
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`CatanState.ck`, which is null in the base game) and Seafarers
(`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind
`CatanState.sea`). The base and Seafarers rules read the board from `state.topo`, not from
`CatanBoard` directly, because a Seafarers map is not the 19-hex island
(Cities & Knights is always the standard island and still uses `CatanBoard`). The
Catan Universe reverse engineering that shaped the landscape HUD and trade board
is in `docs/RE-catan-universe.md`. The screen is layered; see
`docs/UI-catan.md` before changing it.
- `game/` — rules engines and AI, **pure Kotlin, no Android types**, unit
tested. `Catalog.kt` is the registry (id, title, blurb, options, `available`).
- `net/` — wire protocol, LAN transport (mDNS + TCP), lobby, host-authoritative
match driver; `score/` the scorekeeper sheet, also pure.
- `ui/` — Compose screens; `ui/theme/Theme.kt` holds the brand palette
(`BrandField`/`BrandDeep`/`BrandAccent`) and table accents.
- `update/` — OTA policy (`UpdatePolicy.kt`, pure and tested) and the gated
fetch (`Updater.kt`).
- `settings/` — DataStore preferences.
## Bash output is filtered before it reaches the model
Host is authoritative over the wire; clients send intents, host re-validates
against the rules. Keep `game/` free of Android imports — the JVM tests depend
on it.
`.claude/settings.json` registers a `PreToolUse` hook on `Bash` that rewrites
each command through [rtk](https://github.com/rtk-ai/rtk): `git status` runs as
`rtk git status`, and rtk compresses the output. Write commands normally — the
rewrite is transparent and costs nothing to invoke.
## Git
Measured on this repo rather than quoted from rtk's README:
- Branches: `main` (production), `dev` (integration). Feature branches → PR
into `dev`.
- Commits by Paperclip agents end with
`Co-Authored-By: Paperclip <[email protected]>`.
- Pull upstream fixes with `git pull upstream` (the GitHub source); do not
point `origin` anywhere but this repo's Gitea URL.
| Command | Raw | Filtered | Saved |
| --- | --- | --- | --- |
| `git diff HEAD~3` | 521 KB | 28 KB | 95% |
| `grep -rn "fun " app/src/main` | 117 KB | 19 KB | 84% |
| `find . -name "*.kt"` | 7.7 KB | 1.7 KB | 79% |
| `git log -20` | 20 KB | 5.3 KB | 74% |
| `git diff --stat` | 4.0 KB | 4.0 KB | 1% |
| `ls` of a single package | 50 B | 62 B | −24% |
## Legacy files
The saving is all in the large outputs, which is the point — a full diff of a
few commits is otherwise most of a context window. Already-compact output grows
slightly, because rtk adds a header; at those sizes it does not matter. `rtk
gain` reports what the current session has actually saved.
`.claude/` and `.rtk/` are the upstream author's assistant tooling, harmless
and left as inherited. This file supersedes the upstream's CLAUDE.md (which
documented their GitHub sandbox; that history is still in git).
Two limits are worth knowing. The `Read`, `Grep` and `Glob` tools are not
`Bash`, so they never reach the hook and are unaffected; a deliberate `rg` or
`find` in a shell *is* filtered. And filtering never truncates a file: `cat`
becomes `rtk read`, which was checked byte-for-byte against this repo's longest
Kotlin source (`GameSetupScreen.kt`, 946 lines) and came back identical. Commit
messages survive the rewrite intact, quotes and colons included. To bypass
filtering for one command, run `rtk proxy <cmd>`.
## rtk is reinstalled at the start of every session
The container starts empty, so `.claude/hooks/session-start.sh` installs rtk
before the session does anything. It pins `RTK_VERSION`, because the installer
otherwise asks `api.github.com` for the latest tag and the proxy answers 403 for
every repository except this one — the lookup fails and takes the install with
it. Bump that variable by hand.
The hook is written not to matter when it fails. Outside the sandbox it installs
nothing and just prints the command to run, since dropping a binary into
someone's `~/.local/bin` uninvited is not its business; if the download fails it
says so and exits clean. With rtk absent the rewrite hook no-ops, and the only
consequence is verbose output.
+155 -108
View File
@@ -1,32 +1,24 @@
# ProLibertateGames
# Pro Libertate Games
A collection of card and board games for Android, playable against the computer
or against other people over Wi-Fi (LAN).
Wallacenet's build of [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
by adrianoftyriel (GNU GPL v3 — see [LICENSE](LICENSE)), hosted at
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>. Game rules documentation
lives under [docs/](docs/README.md); build, CI and release decisions live in
[docs/CI.md](docs/CI.md). Katan itself arrives in later work — it is listed under
Board Games as *Coming soon* — and this app ships the reference catalogue
unchanged as the regression safety net.
## What works today
The app shell is complete and every game in the catalogue is playable end to end:
| | |
| --- | --- |
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage, Hearts, Klondike, FreeCell, Spider, Pyramid |
| **Table** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Peg Solitaire, Yahtzee, Pirates and Bulgars, Ta Yü, Katan *(coming soon)* |
| **Cards** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Cribbage |
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
One entry is marked *Coming soon*: Katan, the island game, whose engine and
screen land with their own task. The machinery is still there and still tested:
a game listed in `GameCatalog` with `available = false` appears in the menu
marked *Coming soon* **on dev builds only**, so a production release lists just
what can actually be played and nobody installs it and taps into a dead end. The
menu decides this from the installed APK's own version name — the same source
the updater uses to know which channel it is on.
Nothing is marked *Coming soon* at the moment, because nothing is waiting. The
machinery for it is still there and still tested: a game listed in `GameCatalog`
with `available = false` appears in the menu marked *Coming soon* **on dev builds
only**, so a production release lists just what can actually be played and nobody
installs it and taps into a dead end. The menu decides this from the installed
APK's own version name — the same source the updater uses to know which channel
it is on.
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
@@ -119,53 +111,55 @@ it: every rule above is a pure function and unit-tested on the JVM.
- **Sound** on/off.
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
the computer appears to think.
- **Updates** — check on launch, or check on demand. The channel is this
project's own Gitea releases ([docs/CI.md](docs/CI.md)), and **there is no
channel to choose**: a build updates from its own channel and no other. The
settings screen states which one is running rather than offering a switch.
`BuildConfig.OTA_ENABLED` is the kill switch, not a preference.
- **Updates** — check on launch, or check on demand. **There is no channel to
choose**: a build updates from its own channel and no other. A production
install is offered production builds from `main`; a dev install is offered dev
builds from `dev`. The settings screen states which one is running rather than
offering a switch.
The check on launch **only ever speaks when there is a build to install**. It
runs behind the splash and waits for it to clear, and an app that is already
current — or a phone that cannot reach the release server — says nothing,
because opening the app was not a question. The button in Settings is a
question, so it answers every time, including "you're on the latest" and
whatever went wrong.
current — or a phone that cannot reach GitHub — says nothing, because opening
the app was not a question. The button in Settings is a question, so it
answers every time, including "you're on the latest" and whatever went wrong.
Wanting both means installing both — they are separate apps and each updates
itself, see [Both channels on one device](#both-channels-on-one-device).
## Branding
The app wears the upstream's own branding, restored (WALA-118 review): the
Wallace tartan is the launcher icon and the splash, drawn as vector drawables and
Compose code so it stays sharp at every density and needs no PNGs. The app opens
on the tartan held under the name, set in a Celtic uncial hand for two seconds
before it fades into the menu.
The launcher icon is a single square of the Wallace tartan set on the bias, and
the app opens on the same cloth carrying the name in a Celtic uncial hand for two
seconds before fading into the menu.
A dev build wears the **hunting green** sett where production wears the **clan
red**, and installs as *Pro Libertate Games DEV*. That is what tells the two
copies apart when both are on the phone; see
[Both channels on one device](#both-channels-on-one-device). The tartan is drawn
by `ui/theme/Tartan.kt` (`TartanBackground`, `threadWidthFor`) off
`BuildConfig.DEV_BUILD`; the matching vector colours are generated per build by
`resValue` in `app/build.gradle.kts` — the icon vectors reference
`@color/tartan_field` rather than a literal, which is how one set of vectors
serves both colourways.
A dev build wears **Wallace Hunting** instead — the same threadcount with green
where the clan sett has red — and installs as *Pro Libertate Games DEV*. That is
what tells the two copies apart when both are on the phone; see
[Both channels on one device](#both-channels-on-one-device).
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
warp and then as weft at half opacity, which is what produces the blended
squares instead of a flat grid. The icon is the same construction as a vector
drawable, so it stays sharp at every density and needs no PNGs.
Two honest caveats:
- **The sett is the published threadcount: `K/4 R32 K32 Y/4`.** The slashes are
pivots, so `WallaceSett` holds the half-sett and reflects it out to
`K4 R32 K32 Y4 K32 R32` — 136 threads, about half black, half red, with a
yellow line worth 3%. Two details define the pattern and are easy to get
backwards: the yellow overstripe runs down the *centre of a wide black band*,
and the narrow black guard sits *between two red blocks*. The icon is
generated from the same numbers, so the icon and the splash cannot drift.
Weave scale is derived from the sett rather than fixed, so changing the
threadcount rescales both instead of silently zooming in. The hunting
colourway is the same threadcount passed a different field colour, not a
second copy of the numbers, so it cannot fall out of step either.
- **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
the SIL Open Font License 1.1. The full licence is at
[docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and
must stay with the font — the OFL requires it to be distributed alongside.
- **Katan is the one place this app deliberately diverges.** No Catan
trademarks or brand assets ship anywhere in it; the island game is listed
under its own name with original art only. The GPL-licensed history that
produced this app remains in git, and the source stays at
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
as the `upstream` remote.
## Screen sizes
@@ -189,70 +183,122 @@ smaller than a fingertip.
Requires JDK 17 and the Android SDK (compileSdk 34).
A local build is a dev build: it installs as
`ca.wallacenet.pilgrimsofkatan.dev` under the name *Pro Libertate Games DEV* on
the hunting-green sett, so it goes on beside a production copy rather than over
it. Set `PLG_CHANNEL=production` to build the production package
(`ca.wallacenet.pilgrimsofkatan`, clan red).
A local build is a dev build: it installs as `org.prolibertate.games.dev` under
the name *Pro Libertate Games DEV* in the hunting sett, so it goes on beside an
installed release rather than over it. Set `PLG_CHANNEL=production` to build the
production package instead.
## CI
Full decision record: [docs/CI.md](docs/CI.md).
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
requests into `dev` or `main`, and publishes a dev prerelease from `dev`.
- `.github/workflows/release.yml` — builds and publishes the production release
on push to `main`, or by hand (`workflow_dispatch`).
- Both publish through [scripts/publish-release.sh](scripts/publish-release.sh)
against Gitea's REST API, which is also runnable by hand. **No Actions runner
is registered on the instance yet**, so until one is, the local build is the
guaranteed path (`./gradlew assembleDebug && ./gradlew testDebugUnitTest`)
and releases go out via the script.
requests into `dev` or `main`. Its APK artifact is a dev build, versioned
`<series>.0-dev` — the series being whatever `plgVersionSeries` says in
`gradle.properties`, currently **1.3**.
- `.github/workflows/release.yml` — on push to `main`, builds the APK and
publishes it as a GitHub Release tagged `v<series>.<run-number>`. The in-app updater
reads that tag, which is why the APK's `versionCode` is the same run number.
### Hosting: plg.wallacenet.ca
Both channels are also deployed to one Cloudflare Pages project (`plg`), served at
`https://plg.wallacenet.ca`:
```
/ landing page (reads both latest.json files)
/prod/latest.json {tag, apkName, apkUrl, channel} — what the updater reads
/prod/ProLibertateGames.apk, /prod/ProLibertateGames-v<series>.<n>.apk
/dev/... same, for the dev channel
```
Each workflow's `deploy-pages` job runs `.github/scripts/stage-pages-site.sh`,
which stages its own channel and re-fetches the other channel from the live site,
because a Pages deploy replaces everything. The two share a concurrency group so
they cannot race. Needs repo secrets `CLOUDFLARE_API_TOKEN` (Account → Cloudflare
Pages → Edit) and `CLOUDFLARE_ACCOUNT_ID`. Pages caps files at 25 MiB.
**First deploy:** the site does not exist yet, so the domain answers 403 (Cloudflare error 1014) and the carry-over fetch would abort. Dispatch the workflow once with `first_deploy` ticked, then attach `plg.wallacenet.ca` as a custom domain on the new `plg` Pages project. Later runs need no input.
The in-app updater reads these `latest.json` files, and this is the only release
channel: the workflows no longer create GitHub or Gitea releases. They run on
Gitea Actions (`https://git.wallacenet.ca/devadmin/ProLibertateGames`); the GitHub
repository is frozen and should stay public with its last releases, because
installs that predate the Cloudflare updater still read them to reach it.
Build numbers are `run number + plgVersionRunOffset` (1000, in `gradle.properties`),
so moving CI hosts, which restarts run numbers, can never publish a `versionCode`
lower than one already installed.
### Installing without the app
The newest production build is always at **https://plg.wallacenet.ca/prod/ProLibertateGames.apk** (dev: `/dev/ProLibertateGames.apk`). The GitHub link below is the frozen pre-migration release:
**https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk**
Every release carries the same APK twice — once named for its version, and once
as plain `ProLibertateGames.apk`, because GitHub's `/releases/latest/download/`
redirect needs a file name that stays put. The in-app updater deliberately picks
the versioned copy, so an installed build can always be traced back to its tag.
There is no equivalent link for the dev channel: `/releases/latest` skips
prereleases by design, and that is exactly what keeps a production install from
being handed a dev build. A dev build is reached by the in-app updater of a dev
install — which looks at the prerelease list rather than that endpoint — or from
the releases page.
### The version series
A version is **`<series>.<build number>`**. Both values are set by hand, once,
in `gradle.properties`:
A version is **`<series>.<run number>`**. The series is set by hand, once, in
`gradle.properties`:
```properties
plgVersionSeries=1.4
plgVersionRunOffset=1000
plgVersionSeries=1.3
```
Both workflows read those same properties when they name a release tag, and the
build reads them when it stamps the APK, so the tag and what is inside it cannot
name different versions. Changing either is a one-line change to that file and
nothing else — but they are not equivalent:
Both workflows read that same property when they name a release tag, and the
build reads it when it stamps the APK, so the tag and what is inside it cannot
name different versions. Bumping a series is a one-line change to that file and
nothing else.
- The **series** is a name for people. Bumping it changes nothing about
ordering.
- The **build number** is what the machines compare: `versionCode`, the tag's
trailing number, and the number in `versionName`, all the same figure —
`CI run number + plgVersionRunOffset`. Because the offset never changes and
the run number only climbs, going from 1.4 to 1.5 never looks like a downgrade
to Android and never interrupts an OTA update.
The offset exists because Gitea Actions restarts run numbers at 1 for each
workflow: without a floor, the first release of a fresh instance would collide
with the `versionCode 1` every unstamped local build carries, and a counter
reset would make Android refuse the next build outright. Full reasoning in
[docs/CI.md](docs/CI.md).
The number after it is the CI run number and keeps climbing on its own, which is
what makes a series bump safe: `versionCode` is the run number *alone*, so
going from 1.0 to 1.1 never looks like a downgrade to Android and never
interrupts an OTA update. The series is a name for people; the run number is
what the machines compare.
### Update channels
`ci.yml` stamps builds of `dev` as dev-channel, `release.yml` stamps `main` as
production, so an APK's identity is decided where it is built. Publishing goes
to this project's Gitea releases, and the rules it keeps:
Both pipelines publish, to two separate channels:
- production comes only from `main` (`release.yml` refuses any other branch);
- dev prereleases are marked so the "latest release" endpoint can never hand one
to a production install;
- pull requests build and check but never publish.
| Channel | Published by | Tag | Marked |
| --- | --- | --- | --- |
| Production | `release.yml`, push to `main` | `v<series>.<n>` | release |
| Dev | `ci.yml`, any build of `dev` | `v<series>.<n>-dev` | prerelease |
Dev builds are published as **prereleases** deliberately: GitHub's "latest
release" endpoint skips prereleases, so a production-channel install can never
be handed a dev build even by accident. Pull requests build and check but never
publish, so a PR from a fork cannot ship anything.
**Both pipelines gate publishing on the branch rather than on what triggered
the build**, so a manual run from the Actions tab publishes exactly as a push
does — useful when a push cannot fire the workflow itself, which is the case
for anything pushed by an automation token. The safety property is unchanged,
because publishing still happens only after that same job has built, tested and
linted clean. A pull request is still excluded without having to be named: a PR
build runs against `refs/pull/<n>/merge`, never `refs/heads/dev`.
In `ci.yml` the `PLG_CHANNEL` stamp is gated on the same condition as the
publish steps, and the two have to stay in step. Stamping a build that then
ships, or shipping one that was left unstamped, both end with an APK claiming
`versionCode` 1 — which the updater can never see as an upgrade over anything.
`release.yml` refuses to run off any branch but `main`, so a manual dispatch
aimed elsewhere does nothing rather than shipping unreviewed code.
**The two channels have independent version sequences** — a build's `versionCode`
is the build number of the workflow that produced it (run number + offset), and
the two workflows count separately, so the numbers say nothing across the
divide.
is the run number of the workflow that produced it, and the two workflows count
separately. Dev is on 69 while production is on 40, so the numbers say nothing
across the divide.
That is why **an install only ever updates within its own channel**, and why
there is no setting for it. The app reads the `-dev` suffix in its own
@@ -264,18 +310,18 @@ unit-tested on the JVM like the rules engines are.
### Both channels on one device
A dev build installs under its own `applicationId`,
`ca.wallacenet.pilgrimsofkatan.dev`, so it is a separate app as far as Android
is concerned and can sit beside the production copy rather than replacing it. Each channel still updates itself
A dev build installs under its own `applicationId`, `org.prolibertate.games.dev`,
so it is a separate app as far as Android is concerned and can sit beside the
production copy rather than replacing it. Each channel still updates itself
normally, because an update within a channel keeps the same package.
Three things move together, all off `isDevBuild` in `app/build.gradle.kts`:
| | Production | Dev |
| --- | --- | --- |
| `applicationId` | `ca.wallacenet.pilgrimsofkatan` | `ca.wallacenet.pilgrimsofkatan.dev` |
| `applicationId` | `org.prolibertate.games` | `org.prolibertate.games.dev` |
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
| Tartan sett | clan red | hunting green |
| Sett | Wallace (red) | Wallace Hunting (green) |
Anything that is not an explicit `PLG_CHANNEL=production` build is a dev build,
so a local `assembleDebug` is a dev build too and installs alongside a release
@@ -285,11 +331,11 @@ They have to move together. A dev package wearing the production name and
colours is precisely the mix-up that having both installed is meant to prevent,
which is why one flag drives all three rather than each being set by hand. The
name and the colours reach the resources through `resValue`, so `app_name`,
`brand_field` and `splash_background` are generated by the build and are
deliberately *not* in `res/values/` — the icon vectors reference
`@color/brand_field` rather than a literal, which is how one set of vectors
serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag into
Kotlin, where `BrandField` picks the matching Compose colour for the splash.
`tartan_field`, `splash_background` and `ic_launcher_background` are generated
by the build and are deliberately *not* in `res/values/` — the icon vectors
reference `@color/tartan_field` rather than a literal, which is how one set of
vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
Because they are separate apps, having both is a matter of installing both, and
each then updates itself down its own line. Nothing in the app moves a phone
@@ -303,7 +349,7 @@ for a signature mismatch.
## Rules
The exact rules implemented, and the options that vary them, are documented per
game (index: [docs/README.md](docs/README.md)):
game:
- [Euchre](docs/RULES-euchre.md)
- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout**
@@ -327,7 +373,9 @@ game (index: [docs/README.md](docs/README.md)):
the game is out of print and the rulebook could not be obtained, so the
document says which parts are attested and which were derived
- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the
rules engine and AI are in place, the screen lands with its own task
rules engine, AI and screen are in place
- [Katan: Cities & Knights](docs/RULES-catan-ck.md) — the expansion rule set, chosen on
the setup screen: commodities, knights, barbarians, city improvements and progress cards
## Layout
@@ -350,12 +398,11 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
mastermind/ Mastermind codes, scoring, and a code breaker
pirates/ Pirates and Bulgars — the cross board, the hunt, search
tayu/ Ta Yü tile geometry, river rules, scoring, AI
catan/ Katan island geometry, production, trade, dev cards, heuristic AI
catan/ Katan island geometry, production, trade, dev cards, Cities & Knights, heuristic AI
net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
settings/ DataStore-backed preferences
update/ OTA updater against this repo's Gitea releases, and the
channel rule — pure, tested
update/ Cloudflare Pages OTA updater, and the channel rule — pure, tested
ui/ Compose screens
```
+14 -28
View File
@@ -16,18 +16,10 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty()
val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampVersion = buildChannel.isNotEmpty() && runNumber > 0
// The fixed floor every stamped build number is measured from, in
// gradle.properties. Gitea Actions restarts its run numbers at 1 for each
// workflow, so an unstamped build (versionCode 1) and the first release of a
// fresh instance would otherwise collide — and once a phone has installed
// versionCode N, Android refuses anything smaller as a downgrade. Adding a
// permanent offset is what makes "the counter never resets" true by
// construction: it is read from one place, it never changes, and the tag,
// versionName and versionCode all get the same number (see docs/CI.md).
// See plgVersionRunOffset in gradle.properties: the stamped build number is the
// CI run number plus a permanent floor, so a counter that restarts on a new CI
// host can never produce a lower versionCode than one already installed.
val runOffset = (project.findProperty("plgVersionRunOffset") as String?)?.toIntOrNull() ?: 0
// The stamped build number: what the release tag, the versionName and the
// versionCode are all named after.
val buildNumber = runNumber + runOffset
// The version series, from gradle.properties. Both workflows read the same
@@ -46,30 +38,25 @@ val versionSeries = (project.findProperty("plgVersionSeries") as String?) ?: "1.
val isDevBuild = buildChannel != "production"
android {
// Deliberately left at the upstream source package: R and BuildConfig stay
// in org.prolibertate.games for both channels, so no source file has to
// care which applicationId it ended up installed as. The *installed*
// identity is the applicationId below — that is what the rebrand changes
// (WALA-118), not the JVM package names.
namespace = "org.prolibertate.games"
compileSdk = 34
defaultConfig {
// A dev build installs under its own package, so a dev copy and a
// production copy can sit side by side on one device instead of one
// replacing the other.
applicationId = if (isDevBuild) "ca.wallacenet.pilgrimsofkatan.dev" else "ca.wallacenet.pilgrimsofkatan"
// replacing the other. The namespace above is deliberately left alone:
// R and BuildConfig stay in org.prolibertate.games for both, so no
// source has to care which package it ended up installed as.
applicationId = if (isDevBuild) "org.prolibertate.games.dev" else "org.prolibertate.games"
minSdk = 24
targetSdk = 34
// The -dev suffix in versionName is what the installed app reads to
// know its own channel, so it must survive into the shipped APK. It
// tracks isDevBuild rather than the stamp alone, so an unstamped local
// build cannot end up claiming a channel its applicationId contradicts.
// versionCode is the stamped build number and nothing else, so it keeps
// versionCode stays the run number and nothing else, so it keeps
// climbing across a change of series: bumping 1.0 to 1.1 must not make
// an update look like a downgrade to Android. An unstamped build is 1,
// below every release, which is the honest answer for a build nobody
// stamped.
// an update look like a downgrade to Android.
versionCode = if (stampVersion) buildNumber else 1
versionName = when {
stampVersion && buildChannel == "production" -> "$versionSeries.$buildNumber"
@@ -100,12 +87,6 @@ android {
resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B")
buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString())
// In-app OTA updates, pointed at this project's own Gitea releases
// (docs/CI.md). True in this build — the channel exists. Flipping it
// false is the kill switch: every check then reports that and fetches
// nothing, with the machinery and its tests still compiled.
buildConfigField("boolean", "OTA_ENABLED", "true")
}
signingConfigs {
@@ -123,6 +104,11 @@ android {
buildTypes {
release {
// Signed with the same key as the debug builds, so a release installs
// over what is already on a device; a different key would make the
// updater's installs fail with a signature mismatch.
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
+1 -3
View File
@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- LAN play over TCP sockets, plus hand-a-down permission for in-app
installs — the OTA updater installs releases published on this repo's
Gitea; see docs/CI.md. -->
<!-- OTA updates from plg.wallacenet.ca, plus LAN play over TCP sockets. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Network Service Discovery needs multicast to see hosts on the LAN. -->
@@ -10,11 +10,14 @@ import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.settings.YahtzeeStatsRepository
import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme
import org.prolibertate.games.update.Updater
import java.io.File
import java.util.UUID
import org.prolibertate.games.game.engine.SavedGameStore
class MainActivity : ComponentActivity() {
@@ -40,6 +43,8 @@ class MainActivity : ComponentActivity() {
deviceId = peerId,
),
peerId = peerId,
saves = SavedGameStore(File(filesDir, "saved-games")),
yahtzeeStats = YahtzeeStatsRepository(applicationContext),
)
setContent {
@@ -42,7 +42,20 @@ data class GameDescriptor(
val teamBased: Boolean,
val blurb: String,
val available: Boolean,
/**
* Playable on a dev build while still hidden from production.
*
* [available] is the release gate: it is what a production build lists and
* lets people open. A game that has an engine and a screen but has not yet
* been verified on real devices sets this instead, so it can be played and
* tested from the dev channel without being offered to everyone. Flip
* [available] on (and this off) once it has been verified.
*/
val devPreview: Boolean = false,
) {
/** Whether the menu should let this game be opened in the given kind of build. */
fun playableIn(devBuild: Boolean): Boolean = available || (devBuild && devPreview)
/**
* The one menu this game is listed under.
*
@@ -93,6 +106,7 @@ object GameCatalog {
const val TAYU = "tayu"
const val MORRIS = "morris"
const val PIRATES = "pirates"
const val MEOWDOKU = "meowdoku"
/**
* The island game the collection is named for. Listed under Board Games
@@ -347,11 +361,19 @@ object GameCatalog {
minPlayers = 3,
maxPlayers = 4,
teamBased = false,
blurb = "Trade, build, and settle the island.",
// Listed, not playable: the engine and screen land with the Katan
// game task. Dev builds show the entry as *Coming soon*; a
// production build lists only what can actually be played.
available = false,
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
available = true,
),
GameDescriptor(
id = MEOWDOKU,
title = "Meowdoku",
menus = setOf(GameMenu.BOARD, GameMenu.SOLITAIRE),
minPlayers = 1,
maxPlayers = 1,
teamBased = false,
blurb = "One cat in every row, column and colour, and no two touching. " +
"Wrong taps cost a heart; the board only ever accepts a cat that belongs.",
available = true,
),
)
@@ -1,6 +1,9 @@
package org.prolibertate.games.game.backgammon
import kotlin.random.Random
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -22,8 +25,19 @@ import org.prolibertate.games.game.engine.GameAi
* out and its first step returned; the rest follows on the moves after, because
* the same calculation reaches the same turn.
*/
class BackgammonAi(private val level: BackgammonLevel? = null) :
GameAi<BackgammonState, BackgammonMove> {
class BackgammonAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: BackgammonLevel? = null,
) : GameAi<BackgammonState, BackgammonMove> {
/**
* The turn an easy seat has settled on, so that a deliberate slip is made
* once per roll rather than re-rolled on every checker (which would stitch
* halves of different turns together).
*/
private var plan: List<BackgammonMove> = emptyList()
private var planned = 0
private var planRoll = -1
override fun chooseMove(
state: BackgammonState,
@@ -31,48 +45,104 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
legal: List<BackgammonMove>,
): BackgammonMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
val profile = profiles.of(seat)
val base = level ?: state.options.level
val setting = when (profile.level) {
AiLevel.MEDIUM -> base
// Easy never looks at the dice to come.
AiLevel.EASY -> if (base == BackgammonLevel.STRONG) BackgammonLevel.CLUB else base
AiLevel.HARD -> BackgammonLevel.STRONG
}
val tuning = tuningFor(profile)
val random = Random(state.rollsMade * 31L + state.moveLog.size)
if (setting == BackgammonLevel.CASUAL) {
// Not thoughtless: it still prefers to hit, to come in off the bar
// and to bear off, which is most of what a beginner knows.
val scored = legal.map { move -> move to rough(state, seat, move) }
val scored = legal.map { move -> move to rough(state, seat, move, profile) }
val best = scored.maxOf { it.second }
val equal = scored.filter { it.second == best }
return equal[random.nextInt(equal.size)].first
}
val easy = profile.level == AiLevel.EASY
if (easy && planRoll == state.rollsMade) {
val next = plan.getOrNull(planned)
if (next != null && next in legal) {
planned++
return next
}
}
val sequences = BackgammonRules.deepestSequences(state, seat)
if (sequences.isEmpty()) return legal.first()
var best = sequences.first()
var bestScore = -Double.MAX_VALUE
val scored = ArrayList<Pair<List<BackgammonMove>, Double>>(sequences.size)
for (sequence in sequences) {
val after = BackgammonRules.playSequence(state, seat, sequence)
val score = if (setting == BackgammonLevel.STRONG) {
// What the position is worth once the dice have had their say.
evaluate(after, seat) - averageReply(after, other(seat))
} else {
evaluate(after, seat)
}
if (score > bestScore) {
bestScore = score
best = sequence
}
scored += sequence to evaluate(after, seat, tuning)
}
return best.first()
if (setting == BackgammonLevel.STRONG) {
// What the position is worth once the dice have had their say. A hard
// seat only spends the twenty-one-roll average on the turns that
// already look best, which keeps a phone's thinking time bounded.
val finalists = if (profile.level == AiLevel.HARD && scored.size > FINALISTS) {
scored.sortedByDescending { it.second }.take(FINALISTS)
} else {
scored
}
val rescored = finalists.map { (sequence, _) ->
val after = BackgammonRules.playSequence(state, seat, sequence)
sequence to (evaluate(after, seat, tuning) - averageReply(after, other(seat)))
}
return chooseFrom(rescored, profile, state, legal)
}
return chooseFrom(scored, profile, state, legal)
}
/** Best sequence first move; an easy seat sometimes takes a worse (but better-half) turn. */
private fun chooseFrom(
scored: List<Pair<List<BackgammonMove>, Double>>,
profile: AiProfile,
state: BackgammonState,
legal: List<BackgammonMove>,
): BackgammonMove {
var best = scored.first()
for (entry in scored) if (entry.second > best.second) best = entry
var chosen = best.first
if (profile.level == AiLevel.EASY) {
val ranked = scored.sortedByDescending { it.second }.map { it.first }
chosen = profile.pick(ranked)
plan = chosen
planned = 1
planRoll = state.rollsMade
}
val first = chosen.first()
return if (first in legal) first else legal.first()
}
private fun tuningFor(profile: AiProfile): Tuning {
val style = profile.style
var blot = 1.0 - 0.3 * style.boldness
if (profile.level == AiLevel.EASY) blot *= 0.6 // overlooks some of the danger
return Tuning(blot, style.boldness, style.spite)
}
/** What a seat's temperament does to the evaluation; [NEUTRAL] is the standard player. */
private class Tuning(val blot: Double, val bold: Double, val spite: Double)
/** A quick opinion of one checker, for the level that does not think. */
private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove): Int {
private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove, profile: AiProfile): Int {
val landing = BackgammonRules.destinationOf(seat, move.from, move.die)
var score = 0
if (move.from == BAR) score += 50
if (landing == null) score += 40
if (landing != null && state.countOn(landing, other(seat)) == 1) score += 60
if (landing != null && state.countOn(landing, other(seat)) == 1) {
score += 60 + ((profile.style.boldness + profile.style.spite) * 20).toInt()
}
if (landing != null && state.countOn(landing, seat) >= 1) score += 15
return score
}
@@ -98,8 +168,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
val rolled = state.copy(turn = opponent, dice = dice, roll = listOf(high, low))
val replies = BackgammonRules.deepestSequences(rolled, opponent)
val bestReply = replies.maxOfOrNull { sequence ->
evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent)
} ?: evaluate(rolled, opponent)
evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent, NEUTRAL)
} ?: evaluate(rolled, opponent, NEUTRAL)
total += chance * bestReply
weight += chance
}
@@ -117,7 +187,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
* checkers are, and a made point in your own home board is worth having
* because it is one more number your opponent cannot come in on.
*/
private fun evaluate(state: BackgammonState, seat: Int): Double {
private fun evaluate(state: BackgammonState, seat: Int, tuning: Tuning): Double {
val enemy = other(seat)
if (state.phase == BackgammonPhase.GAME_OVER) {
@@ -135,6 +205,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
score -= state.off[enemy] * BORNE_OFF
score -= state.bar[seat] * ON_BAR
score += state.bar[enemy] * ON_BAR
// A gambler and a spoiler both like having an opponent on the bar.
score += state.bar[enemy] * (tuning.bold * 4.0 + tuning.spite * 8.0)
for (point in 0 until POINTS) {
val mine = state.countOn(point, seat)
@@ -142,6 +214,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
if (mine >= 2) {
score += POINT_MADE
// A cautious seat likes covered points a little more, a gambler less.
score -= tuning.bold * 1.5
// A point in your own home board is a door shut in the face of
// anyone you send to the bar.
if (point in homeOf(seat)) score += HOME_POINT
@@ -151,7 +225,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
// A blot, weighted by how easily it can be reached: something
// six away is in real danger, something twenty away is not.
val distance = shortestEnemyDistance(state, seat, point)
score -= when {
score -= tuning.blot * when {
distance <= 0 -> 0.0
distance <= 6 -> BLOT_NEAR
distance <= 12 -> BLOT_FAR
@@ -160,7 +234,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
}
}
score += primeLength(state, seat) * PRIME
score += primeLength(state, seat) * (PRIME + tuning.spite * 3.0)
return score
}
@@ -199,6 +273,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) :
private companion object {
const val WIN = 10_000.0
const val FINALISTS = 8
val NEUTRAL = Tuning(1.0, 0.0, 0.0)
const val PIP = 1.0
const val BORNE_OFF = 12.0
@@ -1,5 +1,8 @@
package org.prolibertate.games.game.catan
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -17,31 +20,51 @@ import org.prolibertate.games.game.engine.GameAi
* dev-card counts, knights played — plus its own hand, and never looks
* into the deck, another seat's development cards, or the seed beyond the
* roll the rules derive from it.
*
* Each seat's [AiProfiles] entry shapes how it plays. The default profile
* (medium, balanced) is the AI described above. Easy settles for worse
* spots, trades and builds, now and then ends its turn early, and takes
* slightly bad trades. Hard weighs setup spots by balance and port synergy,
* values cards by what its next build is missing, races for Longest Road,
* keeps the robber off its own hexes and off the table's non-leaders, and
* is choosier about whom it trades with. Style nudges the tradeoffs: bold
* seats expand, cautious seats spend down before a seven can bite, spiteful
* seats go after the leader.
*/
class CatanAi : GameAi<CatanState, CatanMove> {
class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<CatanState, CatanMove> {
/** Plays the Cities & Knights tables; this class keeps the base game. */
private val ck = CatanCkAi(this, profiles)
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val profile = profiles.of(seat)
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
"No legal move for seat $seat"
}
if (state.isCk) return ck.choose(state, seat, legal)
return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>(), profile)
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
// Gold: take what the seat wants most.
CatanPhase.GOLD -> chooseGold(state, seat, legal, profile)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>(), profile)
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>(), profile)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal, profile)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal, profile)
CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("The game is over; nobody chooses a move")
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, seat, legal, profile)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile)
CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
}
@@ -49,12 +72,68 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// Setup
// -----------------------------------------------------------------------
private fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
internal fun chooseSetup(
state: CatanState,
seat: Int,
places: List<SetupPlace>,
profile: AiProfile = AiProfile.DEFAULT,
): SetupPlace {
require(places.isNotEmpty()) { "No legal settlement placement" }
return places.maxByOrNull { place ->
spotValue(state, place.vertex) +
0.25 * placeDirection(state, place)
} ?: places.first()
// A bold seat cares more where its road leads; a cautious one less.
val direction = 0.25 + 0.1 * profile.style.boldness
return when (profile.level) {
// Easy looks only at the spot itself, and sometimes settles for the runner-up.
AiLevel.EASY -> profile.pick(places.sortedByDescending { spotValue(state, it.vertex) })
AiLevel.HARD -> places.maxByOrNull { place ->
hardSpot(state, seat, place.vertex) + direction * placeDirection(state, place)
} ?: places.first()
AiLevel.MEDIUM -> places.maxByOrNull { place ->
spotValue(state, place.vertex) + direction * placeDirection(state, place)
} ?: places.first()
}
}
/**
* A spot as a seat that plans would see it: the plain production value,
* plus resources the seat does not yet draw, less piling onto one it
* already has plenty of, and ports worth what the seat will produce to
* trade through them.
*/
internal fun hardSpot(state: CatanState, seat: Int, vertex: Int): Double {
if (state.buildings[vertex] != null) return -1.0
val topo = state.topo
val owned = HashMap<Resource, Double>()
for (v in state.buildings.indices) {
val b = state.buildings[v] ?: continue
if (b.owner != seat) continue
val mult = if (b.type == BuildingType.CITY) 2.0 else 1.0
for (h in topo.VERTEX_HEXES[v]) {
val res = state.terrains[h].produces ?: continue
owned[res] = (owned[res] ?: 0.0) + mult * CatanBoard.numberOdds(state.numbers[h]) / 36.0
}
}
val here = HashMap<Resource, Double>()
for (h in topo.VERTEX_HEXES[vertex]) {
val res = state.terrains[h].produces ?: continue
here[res] = (here[res] ?: 0.0) + CatanBoard.numberOdds(state.numbers[h]) / 36.0
}
var value = spotValue(state, vertex)
value += 0.15 * here.keys.count { (owned[it] ?: 0.0) <= 0.0 }
for ((res, p) in here) {
if ((owned[res] ?: 0.0) > 0.15) value -= 0.3 * p
}
val total = owned.values.sum() + here.values.sum()
for (e in topo.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) {
0.05 + 0.15 * total
} else {
0.9 * ((owned[port.resource] ?: 0.0) + (here[port.resource] ?: 0.0))
}
}
return value
}
/**
@@ -64,9 +143,10 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* 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] }
val topo = state.topo
val far = topo.otherEnd(place.edge, place.vertex)
return topo.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in topo.VERTEX_NEIGHBORS[place.vertex] }
.maxOfOrNull { spotValue(state, it) }
?: 0.0
}
@@ -75,56 +155,145 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// Dice, discards and the robber
// -----------------------------------------------------------------------
private fun chooseDiscard(state: CatanState, seat: Int, discards: List<Discard>): Discard {
private fun chooseDiscard(
state: CatanState,
seat: Int,
discards: List<Discard>,
profile: AiProfile,
): Discard {
require(discards.isNotEmpty()) { "No legal discard" }
// Throw away what it wants least; grain and ore are the city, wool
// is mostly a dev-card tax.
return discards.minByOrNull { discard ->
discard.cards.sumOf { want(it) }
} ?: discards.first()
return when (profile.level) {
AiLevel.EASY -> profile.pick(discards.sortedBy { d -> d.cards.sumOf { want(it) } })
// Keep the hand that is closest to paying for something.
AiLevel.HARD -> {
val hand = state.resources[seat]
discards.maxByOrNull { d ->
val left = hand.toMutableMap()
for (c in d.cards) left[c] = (left[c] ?: 0) - 1
progress(left) - 0.01 * d.cards.sumOf { want(it) }
} ?: discards.first()
}
AiLevel.MEDIUM -> discards.minByOrNull { discard ->
discard.cards.sumOf { want(it) }
} ?: discards.first()
}
}
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
/** How close a hand is to the nearest of the things worth buying, a card count over a price, plus one if it pays. */
private fun progress(hand: Map<Resource, Int>): Double = SHOPPING.maxOf { cost ->
val have = cost.entries.sumOf { (r, n) -> minOf(maxOf(hand[r] ?: 0, 0), n) }
have.toDouble() / cost.values.sum() + if (covers(hand, cost)) 1.0 else 0.0
}
private fun chooseGold(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val picks = legal.filterIsInstance<PickGold>()
if (picks.isEmpty()) return legal.first()
return when (profile.level) {
AiLevel.EASY -> profile.pick(picks.sortedByDescending { p -> p.cards.sumOf { want(it) } })
AiLevel.HARD -> picks.maxByOrNull { p -> p.cards.sumOf { need(state, seat, it) } } ?: picks.first()
AiLevel.MEDIUM -> picks.maxByOrNull { pick -> pick.cards.sumOf { want(it) } } ?: legal.first()
}
}
private fun chooseRobber(
state: CatanState,
seat: Int,
moves: List<MoveRobber>,
profile: AiProfile,
): MoveRobber {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val hex = CatanBoard.hexIndex.getValue(move.hex)
val hard = profile.level == AiLevel.HARD
val spite = profile.style.spite
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
val victimWeight = 0.3 + 0.1 * profile.style.boldness
fun score(move: MoveRobber): Double {
val hex = state.topo.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
for (v in state.topo.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
val raw = worth * CatanBoard.numberOdds(state.numbers[hex])
if (building.owner == seat) {
// A hard seat will not sit the robber on its own income.
if (hard) score -= 0.8 * raw / 36.0
continue
}
score += raw / 36.0
if (building.owner == leader) score += spite * raw / 36.0
}
if (move.victim != null) {
score += 0.15 * state.resourceCount(move.victim) +
0.3 * points[move.victim]
val cards = state.resourceCount(move.victim)
if (!hard || cards > 0) {
score += 0.15 * cards + victimWeight * points[move.victim]
if (move.victim == leader) score += 0.4 * spite
}
}
score
} ?: moves.first()
return score
}
return if (profile.level == AiLevel.EASY) {
profile.pick(moves.sortedByDescending { score(it) })
} else {
moves.maxByOrNull { score(it) } ?: moves.first()
}
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
// A city doubles the best production the seat already owns.
legal.filterIsInstance<BuildCity>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val easy = profile.level == AiLevel.EASY
val hard = profile.level == AiLevel.HARD
val boldness = profile.style.boldness
val endTurn = legal.lastOrNull { it is EndTurn } ?: legal.first()
legal.filterIsInstance<BuildSettlement>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
// Easy loses the thread now and then and just passes.
if (easy && profile.slips() && profile.random.nextBoolean()) return endTurn
val cities = legal.filterIsInstance<BuildCity>()
val settlements = legal.filterIsInstance<BuildSettlement>()
if (hard && (cities.isNotEmpty() || settlements.isNotEmpty())) {
// A city doubles what the seat has; a settlement spreads it and
// takes a spot nobody else will. Bold seats lean to the spread.
val city = cities.maxByOrNull { spotValue(state, it.vertex) }
val settlement = settlements.maxByOrNull { hardSpot(state, seat, it.vertex) }
val cityScore = if (city != null) spotValue(state, city.vertex) + 0.15 else Double.NEGATIVE_INFINITY
val settleScore = if (settlement != null) {
hardSpot(state, seat, settlement.vertex) + 0.3 + 0.1 * boldness
} else {
Double.NEGATIVE_INFINITY
}
if (city != null && cityScore >= settleScore) return city
if (settlement != null) return settlement
} else {
// A city doubles the best production the seat already owns.
pickBest(cities, profile) { spotValue(state, it.vertex) }?.let { return it }
pickBest(settlements, profile) { spotValue(state, it.vertex) }?.let { return it }
}
// Seafarers: a ship toward an island nobody has landed on is worth more than a card.
legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.5 }
.maxByOrNull { shipValue(state, seat, it.edge) }
?.let { return it }
// A knight is worth playing whenever one is playable: it blocks an
// opponent's best hex and marches toward Largest Army.
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
if (!(easy && profile.slips())) {
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
}
yearOfPlenty(state, legal)?.let { return it }
if (!(easy && profile.slips())) {
yearOfPlenty(state, seat, legal, profile)?.let { return it }
monopoly(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.
@@ -134,11 +303,25 @@ class CatanAi : GameAi<CatanState, CatanMove> {
return legal.filterIsInstance<PlayRoadBuilding>().first()
}
// Hard seat in a Longest Road race puts the road down before spending on cards.
val roadThreshold = 0.1 - 0.04 * boldness
if (hard && roadRace(state, seat)) {
legal.filterIsInstance<BuildRoad>()
.maxByOrNull { roadValue(state, it) }
?.let { return it }
}
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
// Seafarers: a ship that heads for a settlement spot worth having.
legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.15 }
.maxByOrNull { shipValue(state, seat, it.edge) }
?.let { return it }
// A paid road only when it reaches toward a spot worth settling.
legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.1 }
.filter { roadValue(state, it) > roadThreshold }
.maxByOrNull { roadValue(state, it) }
?.let { return it }
@@ -149,16 +332,94 @@ class CatanAi : GameAi<CatanState, CatanMove> {
.maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 }
?.let { return it }
offer(state, seat)?.let { return it }
if (hard) {
// Pile of one card, short of another: convert before it is stuck in hand.
legal.filterIsInstance<MaritimeTrade>()
.filter { surplusTrade(state, seat, it) }
.minByOrNull { CatanRules.bestPortRatio(state, seat, it.give) }
?.let { return it }
}
return legal.lastOrNull { it is EndTurn } ?: legal.first()
// Stay under the seven: a cautious (or careful) seat sells down a big hand.
if ((hard || boldness < 0) && state.resourceCount(seat) > DISCARD_LIMIT) {
legal.filterIsInstance<MaritimeTrade>()
.maxByOrNull { t ->
(state.resources[seat][t.give] ?: 0) * 10.0 -
CatanRules.bestPortRatio(state, seat, t.give) * 3.0 + need(state, seat, t.get)
}
?.let { return it }
}
if (!(easy && profile.slips())) {
offer(state, seat, profile)?.let { return it }
}
return endTurn
}
/** Picks the best of [items] by [score]; easy sometimes takes a near miss. */
private fun <T> pickBest(items: List<T>, profile: AiProfile, score: (T) -> Double): T? {
if (items.isEmpty()) return null
if (profile.level != AiLevel.EASY) return items.maxByOrNull(score)
return profile.pick(items.sortedByDescending(score))
}
/** The cheapest things worth saving for, as the shopping list a hard seat weighs hands against. */
private val SHOPPING = listOf(
CatanRules.CITY_COST,
CatanRules.SETTLEMENT_COST,
CatanRules.DEV_COST,
CatanRules.ROAD_COST,
)
/** The shopping list entry the hand is closest to paying for, among a city, a settlement and a card. */
private fun nearestTarget(hand: Map<Resource, Int>): Map<Resource, Int> =
listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST, CatanRules.DEV_COST)
.minByOrNull { cost -> cost.entries.sumOf { (r, n) -> maxOf(0, n - (hand[r] ?: 0)) } }
?: CatanRules.CITY_COST
/** What one card of [res] is worth to this seat right now: more if the next build lacks it, less if it is piled up. */
private fun need(state: CatanState, seat: Int, res: Resource): Double {
val hand = state.resources[seat]
var v = want(res)
if ((hand[res] ?: 0) < (nearestTarget(hand)[res] ?: 0)) v += 0.6
if ((hand[res] ?: 0) >= 4) v -= 0.4
return v
}
/** Whether this bank trade turns a pile (beyond any price) into a card the next build lacks. */
private fun surplusTrade(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
val hand = state.resources[seat]
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
val keep = SHOPPING.maxOf { it[move.give] ?: 0 }
val target = nearestTarget(hand)
return (hand[move.give] ?: 0) - keep >= ratio &&
(hand[move.get] ?: 0) < (target[move.get] ?: 0)
}
/** Whether [seat] is in the hunt for Longest Road: close enough to the holder, or the road is unclaimed. */
private fun roadRace(state: CatanState, seat: Int): Boolean {
val holder = state.longestRoadOwner
if (holder == seat) return false
val mine = CatanRules.longestRoadFor(state, seat)
if (mine < 3) return false
return holder == null || mine + 1 >= CatanRules.longestRoadFor(state, holder)
}
/** The opponent with the most public points, if any. */
private fun leaderOf(state: CatanState, seat: Int): Int? {
val points = CatanRules.publicVictoryPoints(state)
return (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
}
/** Year of Plenty: two of whatever the nearest build is shortest of. */
private fun yearOfPlenty(state: CatanState, legal: List<CatanMove>): PlayYearOfPlenty? {
private fun yearOfPlenty(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): PlayYearOfPlenty? {
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
if (takes.isEmpty()) return null
val shortage = shortage(state) ?: return null
if (profile.level == AiLevel.HARD) {
return takes.maxByOrNull { take -> take.take.sumOf { need(state, seat, it) } }
}
return takes.maxByOrNull { take ->
take.take.count { it == shortage } * 2 + take.take.size
}
@@ -210,7 +471,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* 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? {
private fun offer(state: CatanState, seat: Int, profile: AiProfile): OfferTrade? {
if (offeredThisTurn(state, seat)) return null
val seatHand = state.resources[seat]
val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
@@ -223,11 +484,16 @@ class CatanAi : GameAi<CatanState, CatanMove> {
.filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) }
.sortedByDescending { seatHand[it] ?: 0 }
if (surplus.isEmpty()) continue
val target = (0 until state.playerCount)
val holders = (0 until state.playerCount)
.filter { it != seat }
.filter { (state.resources[it][need] ?: 0) > 0 }
.maxByOrNull { state.resources[it][need] ?: 0 }
?: continue
val target = (if (profile.level == AiLevel.HARD) {
// Do not feed the leader when someone else can make the swap.
val points = CatanRules.publicVictoryPoints(state)
holders.maxByOrNull { (state.resources[it][need] ?: 0) - 0.5 * points[it] }
} else {
holders.maxByOrNull { state.resources[it][need] ?: 0 }
}) ?: continue
val give = listOf(surplus.first(), surplus.first())
val trade = OfferTrade(to = target, give = give, get = listOf(need))
if (CatanRules.isLegalMove(state, seat, trade)) return trade
@@ -250,25 +516,75 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// 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 chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>().filter { roadValue(state, it) > 0.05 }
val road = if (profile.level == AiLevel.EASY) {
profile.pick(roads.sortedByDescending { roadValue(state, it) }.ifEmpty { return freeRoadFallback(legal) })
} else {
roads.maxByOrNull { roadValue(state, it) }
}
return road ?: freeRoadFallback(legal)
}
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull() ?: run {
return legal.first { it is DeclineTrade }
private fun freeRoadFallback(legal: List<CatanMove>): CatanMove =
legal.firstOrNull { it is EndFreeRoads }
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
?: legal.first()
/**
* How much extra the incoming cards must be worth to take a swap: nothing
* for the standard seat, a hard or spiteful seat wants more from the
* table's leader, and an easy seat is not too fussy.
*/
private fun tradeMargin(state: CatanState, seat: Int, other: Int, profile: AiProfile): Double {
var margin = 0.0
if (profile.level == AiLevel.EASY) margin -= 0.6
val leader = leaderOf(state, seat)
if (leader != null && other == leader) {
if (profile.level == AiLevel.HARD) margin += 0.4
margin += 0.5 * profile.style.spite
}
return margin
}
private fun tradeValue(state: CatanState, seat: Int, res: Resource, profile: AiProfile): Double =
if (profile.level == AiLevel.HARD) need(state, seat, res) else want(res)
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull()
val offer = state.pendingTrade
if (accept == null || offer == null) return legal.first { it is DeclineTrade }
// Accept when what arrives (the offerer's give) is worth more than what
// leaves (the offerer's get): a mild table, not a shark.
val incoming = offer.give.sumOf { tradeValue(state, seat, it, profile) }
val outgoing = offer.get.sumOf { tradeValue(state, seat, it, profile) }
return if (incoming > outgoing + tradeMargin(state, seat, offer.by, profile)) {
accept
} else {
legal.first { it is DeclineTrade }
}
}
/**
* Picks the counter-offer that gains the most by the same valuation, or
* turns them all down. The AI never counters itself: it only answers.
*/
private fun chooseCounter(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val offer = state.pendingTrade ?: return legal.first()
// Accept when what arrives is worth more than what leaves: a mild
// table, not a shark.
val incoming = offer.get.sumOf { want(it) }
val outgoing = offer.give.sumOf { want(it) }
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
fun gain(c: TradeCounter) = c.give.sumOf { tradeValue(state, seat, it, profile) } -
c.get.sumOf { tradeValue(state, seat, it, profile) }
val best = legal.filterIsInstance<AcceptCounter>()
.map { choice -> choice to offer.counters.first { it.by == choice.seat } }
.maxByOrNull { (_, c) -> gain(c) }
if (best != null) {
val (choice, c) = best
if (c.give.sumOf { tradeValue(state, seat, it, profile) } >
c.get.sumOf { tradeValue(state, seat, it, profile) } + tradeMargin(state, seat, c.by, profile)
) {
return choice
}
}
return legal.first { it is DismissCounters }
}
// -----------------------------------------------------------------------
@@ -276,17 +592,17 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// -----------------------------------------------------------------------
/** What a seat would think of building on [vertex]: production and ports. */
private fun spotValue(state: CatanState, vertex: Int): Double {
internal fun spotValue(state: CatanState, vertex: Int): Double {
if (state.buildings[vertex] != null) return -1.0
var value = 0.0
val kinds = mutableSetOf<Resource>()
for (h in CatanBoard.VERTEX_HEXES[vertex]) {
for (h in state.topo.VERTEX_HEXES[vertex]) {
val resource = state.terrains[h].produces ?: continue
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
kinds += resource
}
value += 0.12 * kinds.size // variety keeps a build plan alive
for (e in CatanBoard.VERTEX_EDGES[vertex]) {
for (e in state.topo.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) 0.1 else 0.2
}
@@ -297,23 +613,65 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* What a road on this edge leads to: the best settlement spot it brings
* within reach, discounted for being one step further out.
*/
private fun roadValue(state: CatanState, move: BuildRoad): Double {
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
var best = 0.0
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
val (from, to) = state.topo.EDGE_VERTICES[move.edge]
for (endpoint in listOf(from, to)) {
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
for (near in state.topo.VERTEX_NEIGHBORS[endpoint]) {
best = maxOf(best, spotValue(state, near) * 0.8)
}
}
return best
}
/**
* What a ship on [edge] is heading for: the best open settlement spot reachable by sea from
* its far end (the end the fleet does not already hold), discounted by how far off it is,
* with a bonus for an island nobody has landed on.
*/
internal fun shipValue(state: CatanState, seat: Int, edge: Int): Double {
val topo = state.topo
val sea = state.sea ?: return 0.0
val (a, b) = topo.EDGE_VERTICES[edge]
fun held(v: Int): Boolean =
state.buildings[v]?.owner == seat || topo.VERTEX_EDGES[v].any { sea.ships[it] == seat }
val front = listOf(a, b).filter { !held(it) }.ifEmpty { return 0.0 }
var best = 0.0
val seen = mutableSetOf<Int>()
var frontier = front
var depth = 0
while (frontier.isNotEmpty() && depth <= 5) {
val next = mutableListOf<Int>()
for (v in frontier) {
if (!seen.add(v)) continue
if (CatanRules.vertexOpenForSettlement(state, v)) {
val island = topo.vertexIsland[v]
val fresh = island >= 0 && island != sea.home && sea.islandBonus[island] == NO_PLAYER
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
best = maxOf(best, value)
}
for (e in topo.VERTEX_EDGES[v]) {
if (!topo.edgeSea[e] || sea.ships[e] != NO_PLAYER) continue
next += topo.otherEnd(e, v)
}
}
frontier = next
depth++
}
return best
}
/** How much the AI wants to hold one card of [resource]. */
private fun want(resource: Resource): Double = when (resource) {
internal fun want(resource: Resource): Double = when (resource) {
Resource.ORE -> 1.20 // the city
Resource.GRAIN -> 1.15 // the city and the dev cards
Resource.LUMBER -> 1.0 // every road and settlement
Resource.BRICK -> 0.95
Resource.WOOL -> 0.85 // abundant and only half of a dev card
}
private companion object {
/** A seven makes a seat holding more than this discard half. */
const val DISCARD_LIMIT = 7
}
}
@@ -5,13 +5,14 @@ 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.
* The geometry of one board: its hexes, the shared vertices and edges between
* them, and which hexes are land.
*
* Everything is derived once, deterministically, from axial coordinates —
* there is no randomness and no state here; the layout of a particular
* game (which terrain and number sits on which hex) is [generate], and the
* live state lives in [CatanState].
* game (which terrain and number sits on which hex) is generated elsewhere
* ([CatanBoard.generate], [SeaBoards.generate]), and the live state lives in
* [CatanState].
*
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
* position is kept on an exact integer lattice so vertices shared by
@@ -20,41 +21,25 @@ import kotlin.random.Random
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
* in half-units of the hex grid; rendering scales and flips them (lattice
* +Y is toward the top of the board).
*
* The standard island ([CatanBoard.base]) is the 19-hex hexagon. A Seafarers
* map is a larger frame whose hexes are mostly sea; [land] says which.
*/
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))
}
}
}
class Topology(
/** Every hex of the frame, land and sea alike. */
val HEXES: List<HexCoord>,
/** Whether each hex is land. The base island is all land; Seafarers maps are mostly sea. */
val land: List<Boolean> = List(HEXES.size) { true },
/** The standard 19-hex island: checks its counts and uses the fixed nine-harbour frame. */
private val standardFrame: Boolean = false,
/** Harbours wanted on a non-standard map. */
harbours: Int = 0,
) {
val HEX_COUNT: Int = HEXES.size
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
/**
* 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
// -----------------------------------------------------------------------
@@ -93,9 +78,28 @@ object CatanBoard {
/** The coastal edges (those with only one hex) in ring order. */
val COASTAL: List<Int>
/** The nine fixed harbour positions, spread around the coast. */
/** The harbour positions: nine on the standard frame. */
val PORT_POSITIONS: List<Int>
// --- Land and sea (all land on the standard island) --------------------
/** A settlement may stand here: at least one of its hexes is land. */
val vertexLand: List<Boolean>
/** A road may lie here: at least one of its hexes is land. */
val edgeLand: List<Boolean>
/** A ship may sail here: at least one of its hexes is sea. */
val edgeSea: List<Boolean>
/** Which island each hex is on (a connected group of land hexes), or -1 for sea. */
val islandOf: List<Int>
val islandCount: Int
/** Which island each vertex touches, or -1 for open water. */
val vertexIsland: List<Int>
init {
// Vertices: assign ids in first-seen order over hexes then corners.
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
@@ -169,27 +173,99 @@ object CatanBoard {
// 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.
// origin. The standard board is a hexagon, so this yields one closed ring.
COASTAL = EDGE_HEXES.withIndex()
.filter { it.value.size == 1 }
.map { (e, _) -> e }
.sortedBy { e -> angleOf(midpoint(e)) }
// 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" }
vertexLand = List(VERTEX_COUNT) { v -> VERTEX_HEXES[v].any { land[it] } }
edgeLand = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { land[it] } }
edgeSea = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { !land[it] } }
// Islands: connected groups of land hexes.
val island = IntArray(HEX_COUNT) { -1 }
var islands = 0
for (h in 0 until HEX_COUNT) {
if (!land[h] || island[h] != -1) continue
val stack = ArrayDeque(listOf(h))
island[h] = islands
while (stack.isNotEmpty()) {
val at = stack.removeLast()
for (n in HEX_NEIGHBORS[at]) {
if (land[n] && island[n] == -1) {
island[n] = islands
stack.addLast(n)
}
}
}
islands++
}
islandOf = island.toList()
islandCount = islands
vertexIsland = List(VERTEX_COUNT) { v ->
VERTEX_HEXES[v].firstOrNull { land[it] }?.let { island[it] } ?: -1
}
PORT_POSITIONS = if (standardFrame) {
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
} else {
seaPorts(harbours)
}
if (standardFrame) {
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
}
}
/**
* Harbour sites on a map with islands: edges where land meets sea, taken
* round the map by angle about the land's centre, [count] of them as evenly
* spaced as the coast allows, never two sharing a corner.
*/
private fun seaPorts(count: Int): List<Int> {
if (count <= 0) return emptyList()
val shore = (0 until EDGE_COUNT).filter { e ->
EDGE_HEXES[e].size == 2 && EDGE_HEXES[e].any { land[it] } && EDGE_HEXES[e].any { !land[it] }
}
if (shore.isEmpty()) return emptyList()
val landCentre = HEXES.indices.filter { land[it] }.let { hs ->
val pts = hs.map { hexCenter(it) }
(pts.sumOf { it.first } / pts.size.toDouble()) to (pts.sumOf { it.second } / pts.size.toDouble())
}
val ring = shore.sortedBy { e ->
val m = midpoint(e)
atan2(m.second - landCentre.second, m.first - landCentre.first)
}
val picked = mutableListOf<Int>()
val used = mutableSetOf<Int>()
val step = ring.size.toDouble() / count
for (i in 0 until count) {
val start = (i * step).toInt()
// The first edge at or after the ideal spot whose corners are free.
val e = (0 until ring.size).map { ring[(start + it) % ring.size] }.firstOrNull { cand ->
val (a, b) = EDGE_VERTICES[cand]
a !in used && b !in used && cand !in picked
} ?: continue
picked += e
val (a, b) = EDGE_VERTICES[e]
used += a
used += b
}
return picked
}
/** The other endpoint of [edge], given one [vertex] on it. */
@@ -207,19 +283,14 @@ object CatanBoard {
/** 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))
/** Lattice extent of the whole frame: min x, max x, min y, max y. */
val lattice: IntArray by lazy {
intArrayOf(
VERTEX_POS.minOf { it.first }, VERTEX_POS.maxOf { it.first },
VERTEX_POS.minOf { it.second }, VERTEX_POS.maxOf { it.second },
)
}
/**
* 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]
@@ -230,6 +301,84 @@ object CatanBoard {
private fun angleOf(point: Pair<Double, Double>): Double =
atan2(point.second, point.first)
companion object {
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** Hex-grid distance between two axial positions. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
val dq = q1 - q2
val dr = r1 - r2
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
}
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
}
}
/**
* The standard island: the 19 hexes, the 54 shared vertices, the 72 shared
* edges, the coast and the nine harbour positions. Every table below is the
* standard frame's [Topology]; the rules read the topology of the *state* they
* are working on ([CatanState.topo]), which is this one in every game but Seafarers.
*/
object CatanBoard {
/** The standard island's geometry. */
val base: Topology = Topology(
HEXES = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (Topology.hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
},
standardFrame = true,
)
val HEXES: List<HexCoord> get() = base.HEXES
val HEX_COUNT: Int get() = base.HEX_COUNT
val hexIndex: Map<HexCoord, Int> get() = base.hexIndex
val VERTEX_POS: List<Pair<Int, Int>> get() = base.VERTEX_POS
val VERTEX_COUNT: Int get() = base.VERTEX_COUNT
val HEX_CORNERS: List<List<Int>> get() = base.HEX_CORNERS
val EDGE_VERTICES: List<Pair<Int, Int>> get() = base.EDGE_VERTICES
val EDGE_COUNT: Int get() = base.EDGE_COUNT
val VERTEX_EDGES: List<List<Int>> get() = base.VERTEX_EDGES
val VERTEX_NEIGHBORS: List<List<Int>> get() = base.VERTEX_NEIGHBORS
val VERTEX_HEXES: List<List<Int>> get() = base.VERTEX_HEXES
val EDGE_HEXES: List<List<Int>> get() = base.EDGE_HEXES
val EDGE_NEIGHBORS: List<List<Int>> get() = base.EDGE_NEIGHBORS
val HEX_NEIGHBORS: List<List<Int>> get() = base.HEX_NEIGHBORS
val COASTAL: List<Int> get() = base.COASTAL
val PORT_POSITIONS: List<Int> get() = base.PORT_POSITIONS
fun otherEnd(edge: Int, vertex: Int): Int = base.otherEnd(edge, vertex)
fun hexCenter(h: Int): Pair<Int, Int> = base.hexCenter(h)
fun hexesAdjacent(a: Int, b: Int): Boolean = base.hexesAdjacent(a, b)
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int = Topology.hexDistance(q1, r1, q2, r2)
fun numberOdds(number: Int): Int = Topology.numberOdds(number)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// -----------------------------------------------------------------------
// Island generation
// -----------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,466 @@
package org.prolibertate.games.game.catan
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
/**
* The computer opponent for Cities & Knights, reached through [CatanAi].
*
* It plays the same cautious game as the base AI — settle well, raise cities —
* and adds the three things this rule set asks of a player: keep enough active
* knights standing when the barbarians land, spend commodities on improvements
* as soon as it can, and answer every chore the table raises with the card it
* values least. It reads only what a player at the table could see, plus its
* own hand.
*
* It is not a strong player. It never moves knights about (they stay where
* hired, and are activated when the barbarians draw near), never plays the
* progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat,
* Deserter, Intrigue), and makes no domestic offers, though it answers them.
*
* The seat's profile shapes it as in [CatanAi]: easy plays on a whim and
* discards badly, hard sizes its knights to the barbarian threat, values cards
* by what its improvements still need and keeps its robber off its own hexes,
* and the style leans it toward knights and expansion, toward spending down
* a big hand, or toward the leader.
*/
internal class CatanCkAi(
private val ai: CatanAi,
private val profiles: AiProfiles = AiProfiles.DEFAULT,
) {
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val profile = profiles.of(seat)
return choose(state, seat, legal, profile)
}
private fun choose(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove = when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>(), profile)
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
CatanPhase.CHORE -> chooseChore(state, seat, legal, profile)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>(), profile)
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal, profile)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal, profile)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile)
CatanPhase.DISCARDING, CatanPhase.GOLD, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
// -----------------------------------------------------------------------
// Chores
// -----------------------------------------------------------------------
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val ck = state.ck!!
return when (val chore = ck.chores.first()) {
is DiscardChore -> {
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
val hand = CatanCk.goodsList(state, seat)
// Easy now and then throws away whatever comes to hand.
val order = if (profile.slips()) {
hand.shuffled(profile.random)
} else {
hand.sortedBy { worth(state, seat, it, profile) }
}
DiscardGoods(order.take(n))
}
is GiveChore -> {
val pool = CatanCk.goodsList(state, seat)
.filter { !chore.commoditiesOnly || it.isCommodity }
.sortedBy { worth(state, seat, it, profile) }
GiveGoods(pool.take(minOf(chore.count, pool.size)))
}
is PillageChore ->
legal.filterIsInstance<PillageCity>().minByOrNull { production(state, it.vertex) } ?: legal.first()
is PickDeckChore -> {
val picks = legal.filterIsInstance<PickDeck>()
picks.maxByOrNull { CatanCk.improvement(state, seat, it.track) } ?: legal.first()
}
is DropProgressChore ->
legal.filterIsInstance<DropProgress>().minByOrNull { drop ->
ck.progressHands[seat].first { it.id == drop.card }.kind.let(::cardWorth)
} ?: legal.first()
is MetropolisChore ->
legal.filterIsInstance<PlaceMetropolis>().maxByOrNull { production(state, it.vertex) }
?: legal.first()
is AqueductChore ->
legal.filterIsInstance<TakeAqueduct>().maxByOrNull { ai.want(it.resource) } ?: legal.first()
is RelocateChore -> legal.first()
is RemoveKnightChore ->
legal.filterIsInstance<RemoveKnight>().minByOrNull {
CatanCk.knightAt(state, it.vertex)?.level ?: 0
} ?: legal.first()
is PlaceKnightChore -> legal.first()
is SpyChore -> legal.first()
is SmithChore ->
legal.filterIsInstance<PromoteKnight>().firstOrNull() ?: legal.first { it is EndSmith }
}
}
// -----------------------------------------------------------------------
// The robber
// -----------------------------------------------------------------------
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>, profile: AiProfile): MoveRobber {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
val hard = profile.level == AiLevel.HARD
val spite = profile.style.spite
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
val victimWeight = 0.3 + 0.1 * profile.style.boldness
fun score(move: MoveRobber): Double {
val hex = CatanBoard.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
score += if (building.owner == seat) {
-worth * odds * (if (hard) 1.5 else 1.0)
} else {
worth * odds + if (building.owner == leader) spite * worth * odds else 0.0
}
}
if (move.victim != null) {
val cards = CatanCk.goodsCount(state, move.victim)
if (!hard || cards > 0) {
score += 0.15 * cards + victimWeight * points[move.victim]
if (move.victim == leader) score += 0.4 * spite
}
}
return score
}
return if (profile.level == AiLevel.EASY) {
profile.pick(moves.sortedByDescending { score(it) })
} else {
moves.maxByOrNull { score(it) } ?: moves.first()
}
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val ck = state.ck!!
val easy = profile.level == AiLevel.EASY
val hard = profile.level == AiLevel.HARD
val boldness = profile.style.boldness
// A cautious seat starts getting ready a step earlier.
val soon = ck.barbarians <= (if (boldness < 0) 4 else 3)
val endTurn = legal.lastOrNull { it is EndTurn }
// Easy loses the thread now and then and just passes.
if (easy && endTurn != null && profile.slips() && profile.random.nextBoolean()) return endTurn
// Chase the robber off a hex it is squatting on.
if (ck.attacked) {
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
if (onMine != null && robberHurtsMe) return onMine
}
// The cheap upgrade first, then the full-price one.
legal.filterIsInstance<PlayProgress>()
.filter { kindOf(state, seat, it) == ProgressType.MEDICINE }
.maxByOrNull { production(state, it.vertex!!) }
?.let { return it }
val cities = legal.filterIsInstance<BuildCity>()
val settlements = legal.filterIsInstance<BuildSettlement>()
if (hard) {
val city = cities.maxByOrNull { production(state, it.vertex) }
val settlement = settlements.maxByOrNull { ai.hardSpot(state, seat, it.vertex) }
val cityScore = if (city != null) production(state, city.vertex) + 0.15 else Double.NEGATIVE_INFINITY
val settleScore = if (settlement != null) {
ai.hardSpot(state, seat, settlement.vertex) + 0.3 + 0.1 * boldness
} else {
Double.NEGATIVE_INFINITY
}
if (city != null && cityScore >= settleScore) return city
if (settlement != null) return settlement
} else if (easy) {
if (cities.isNotEmpty()) return profile.pick(cities.sortedByDescending { production(state, it.vertex) })
if (settlements.isNotEmpty()) {
return profile.pick(settlements.sortedByDescending { ai.spotValue(state, it.vertex) })
}
} else {
cities.maxByOrNull { production(state, it.vertex) }?.let { return it }
settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it }
}
// Improvements spend cards nothing else needs. Favour the track furthest along.
legal.filterIsInstance<BuyImprovement>()
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
?.let { return it }
// Knights: stand ready by the time the barbarians land.
if (!(easy && profile.slips())) defence(state, seat, legal, soon, profile)?.let { return it }
if (!(easy && profile.slips())) progress(state, seat, legal)?.let { return it }
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
if (CatanCk.goodsCount(state, seat) >= (if (boldness < 0) 5 else 7)) return it
}
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.1 - 0.04 * boldness }
.maxByOrNull { ai.roadValue(state, it) }
?.let { return it }
legal.filterIsInstance<GoodsBankTrade>()
.filter { completes(state, seat, it) }
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
?.let { return it }
// Stay under the seven: a cautious (or careful) seat sells down a big hand.
if ((hard || boldness < 0) && CatanCk.goodsCount(state, seat) > 7) {
legal.filterIsInstance<GoodsBankTrade>()
.maxByOrNull { t ->
held(state, seat, t.give) * 10.0 - CatanCk.bankRatio(state, seat, t.give) * 3.0 +
worth(state, seat, t.get, profile)
}
?.let { return it }
}
return endTurn ?: legal.first()
}
/** Hire, promote and activate so the knights are standing when the barbarians land. */
private fun defence(
state: CatanState,
seat: Int,
legal: List<CatanMove>,
soon: Boolean,
profile: AiProfile,
): CatanMove? {
val mine = CatanCk.knightsOf(state, seat)
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
// Hard sizes the defence to the threat: the barbarians bring one strength
// per city on the board, and once the table's active knights outweigh them
// and this seat pulls its weight, the rest of its goods go elsewhere.
if (profile.level == AiLevel.HARD && soon && CatanCk.strength(state, seat) >= 1) {
val threat = state.buildings.count { it?.type == BuildingType.CITY }
val table = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
if (table > threat) return null
}
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
val want = if (soon) 4 else if (profile.style.boldness > 0.5) 3 else 2
if (mine.size < want) {
legal.filterIsInstance<HireKnight>().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire ->
// Keep a grain back for waking it, once the ship is close.
if (!soon || grainLeft >= 1) return hire
}
}
if (soon || mine.size >= 2) {
legal.filterIsInstance<PromoteKnight>().firstOrNull()?.let { return it }
}
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
return null
}
/** Progress cards worth playing without a plan. */
private fun progress(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove? {
val ck = state.ck!!
val soon = ck.barbarians <= 4
val plays = legal.filterIsInstance<PlayProgress>()
fun kindOf(play: PlayProgress) = ck.progressHands[seat].first { it.id == play.card }.kind
fun first(kind: ProgressType) = plays.firstOrNull { kindOf(it) == kind }
first(ProgressType.IRRIGATION)?.let { return it }
first(ProgressType.MINING)?.let { return it }
first(ProgressType.ENGINEER)?.let { return it }
first(ProgressType.SMITH)?.let { return it }
first(ProgressType.WEDDING)?.let { return it }
if (soon) first(ProgressType.WARLORD)?.let { return it }
plays.filter { kindOf(it) == ProgressType.MASTER_MERCHANT }
.maxByOrNull { play -> play.goods.orEmpty().sumOf { worth(state, seat, it) } }
?.let { return it }
first(ProgressType.SPY)?.let { return it }
first(ProgressType.BISHOP)?.let { return it }
if (legal.filterIsInstance<BuildRoad>().size >= 2) first(ProgressType.ROAD_BUILDING)?.let { return it }
// Crane: only when it makes the difference to an improvement.
first(ProgressType.CRANE)?.let { crane ->
val helps = Track.entries.any { track ->
val level = CatanCk.improvement(state, seat, track)
val have = ck.commodities[seat][track.commodity] ?: 0
level < IMPROVEMENT_MAX && have == level // one short of the next level's price
}
if (helps) return crane
}
// Saboteur: when someone level or ahead is sitting on a pile.
first(ProgressType.SABOTEUR)?.let { saboteur ->
val vp = CatanRules.publicVictoryPoints(state)
val pile = (0 until state.playerCount).filter { it != seat && vp[it] >= vp[seat] }
.sumOf { CatanCk.goodsCount(state, it) / 2 }
if (pile >= 4) return saboteur
}
// Monopolies: only when the table is holding a pile of it.
plays.filter { kindOf(it) == ProgressType.RESOURCE_MONOPOLY || kindOf(it) == ProgressType.TRADE_MONOPOLY }
.maxByOrNull { held(state, seat, it.good!!) }
?.let { best ->
val each = if (kindOf(best) == ProgressType.RESOURCE_MONOPOLY) 2 else 1
val take = (0 until state.playerCount).filter { it != seat }
.sumOf { minOf(each, CatanCk.goodsOf(state, it)[best.good!!] ?: 0) }
if (take >= 3) return best
}
// The merchant, on the best hex beside a building of ours.
plays.filter { kindOf(it) == ProgressType.MERCHANT }
.maxByOrNull { play ->
val hex = play.hexA!!
CatanBoard.numberOdds(state.numbers[hex]).toDouble()
}?.let { return it }
// A commercial harbour turns spare resources into commodities.
plays.filter { kindOf(it) == ProgressType.COMMERCIAL_HARBOR }
.maxByOrNull { held(state, seat, it.good!!) }
?.let { if (held(state, seat, it.good!!) >= 2) return it }
return null
}
private fun kindOf(state: CatanState, seat: Int, play: PlayProgress): ProgressType =
state.ck!!.progressHands[seat].first { it.id == play.card }.kind
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
private fun production(state: CatanState, vertex: Int): Double =
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
}
private fun held(state: CatanState, seat: Int, good: Good): Int = CatanCk.goodsOf(state, seat)[good] ?: 0
/** Whether the trade leaves a hand that can pay for a city, a settlement or an improvement. */
private fun completes(state: CatanState, seat: Int, trade: GoodsBankTrade): Boolean {
val hand = CatanCk.goodsOf(state, seat).toMutableMap()
hand[trade.give] = (hand[trade.give] ?: 0) - CatanCk.bankRatio(state, seat, trade.give)
hand[trade.get] = (hand[trade.get] ?: 0) + 1
fun covers(cost: Map<Resource, Int>) = cost.all { (r, n) -> (hand[Good.of(r)] ?: 0) >= n }
if (covers(CatanRules.CITY_COST) || covers(CatanRules.SETTLEMENT_COST)) return true
return Track.entries.any { track ->
val level = CatanCk.improvement(state, seat, track)
level < IMPROVEMENT_MAX &&
(hand[Good.of(track.commodity)] ?: 0) >= level + 1 &&
(state.ck!!.commodities[seat][track.commodity] ?: 0) < level + 1
}
}
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>().filter { ai.roadValue(state, it) > 0.05 }
val road = when {
roads.isEmpty() -> null
profile.level == AiLevel.EASY -> profile.pick(roads.sortedByDescending { ai.roadValue(state, it) })
else -> roads.maxByOrNull { ai.roadValue(state, it) }
}
return road
?: legal.firstOrNull { it is EndFreeRoads }
?: legal.first()
}
/** Extra the incoming cards must be worth: easy is not fussy, hard and spiteful seats are tougher on the leader. */
private fun tradeMargin(state: CatanState, seat: Int, other: Int, profile: AiProfile): Double {
var margin = 0.0
if (profile.level == AiLevel.EASY) margin -= 0.6
val points = CatanRules.publicVictoryPoints(state)
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
if (leader != null && other == leader) {
if (profile.level == AiLevel.HARD) margin += 0.4
margin += 0.5 * profile.style.spite
}
return margin
}
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val decline = legal.first { it is DeclineTrade }
val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline
val offer = state.ck?.goodsOffer
if (offer != null) {
val incoming = offer.give.sumOf { worth(state, seat, it, profile) }
val outgoing = offer.get.sumOf { worth(state, seat, it, profile) }
return if (incoming > outgoing + tradeMargin(state, seat, offer.by, profile)) accept else decline
}
val plain = state.pendingTrade ?: return decline
val incoming = plain.give.sumOf { ai.want(it) }
val outgoing = plain.get.sumOf { ai.want(it) }
return if (incoming > outgoing + tradeMargin(state, seat, plain.by, profile)) accept else decline
}
/** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove =
legal.firstOrNull { it is DismissCounters } ?: legal.first()
// -----------------------------------------------------------------------
// Valuations
// -----------------------------------------------------------------------
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
private fun worth(state: CatanState, seat: Int, good: Good, profile: AiProfile = AiProfile.DEFAULT): Double {
val base = good.resource?.let { ai.want(it) } ?: when (good) {
Good.PAPER -> 0.95
Good.COIN -> 0.9
else -> 0.85
}
if (profile.level != AiLevel.HARD) return base
// Hard keeps the commodity an improvement is about to need.
var bonus = if (good.resource == Resource.ORE || good.resource == Resource.GRAIN) 0.1 else 0.0
for (track in Track.entries) {
if (Good.of(track.commodity) != good) continue
val level = CatanCk.improvement(state, seat, track)
if (level < IMPROVEMENT_MAX && held(state, seat, good) <= level + 1) bonus += 0.5
}
return base + bonus
}
/** Which track to lean toward when two are level: politics for the knights, then science. */
private fun trackLean(track: Track): Int = when (track) {
Track.POLITICS -> 2
Track.SCIENCE -> 1
Track.TRADE -> 0
}
private fun cardWorth(kind: ProgressType): Double = when (kind) {
ProgressType.INVENTOR, ProgressType.ALCHEMIST, ProgressType.DIPLOMAT,
ProgressType.DESERTER, ProgressType.INTRIGUE, ProgressType.MERCHANT_FLEET,
-> 0.0
ProgressType.MERCHANT, ProgressType.COMMERCIAL_HARBOR, ProgressType.CRANE -> 0.5
else -> 1.0
}
}
@@ -0,0 +1,486 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/*
* The data of the Cities & Knights rule set. The rules themselves live in
* [CatanCk]; docs/RULES-catan-ck.md describes them and the choices made.
*
* Everything here is reached through [CatanState.ck], which is null for the
* base game, so a base game never sees any of it.
*/
/** Steps the barbarian ship must make before it lands. */
const val BARBARIAN_STEPS = 7
/** Commodity cards of each type the bank starts with (resources have 19). */
const val BANK_PER_COMMODITY = 12
/** Knights of each strength one player owns. */
const val KNIGHTS_PER_LEVEL = 2
/** City walls one player owns. */
const val MAX_WALLS = 3
/** Levels of city improvement in each track. */
const val IMPROVEMENT_MAX = 5
/** Progress cards a player may keep in hand. */
const val PROGRESS_HAND_MAX = 4
/** Cards a player may hold through a seven, before walls. */
const val BASE_HAND_LIMIT = 7
/** Longest Road is worth two points here; there is no Largest Army. */
const val CK_LONGEST_ROAD_VP = 2
/** Improvement level that unlocks the track's special power. */
const val IMPROVEMENT_POWER_LEVEL = 3
/** Level at which a track can claim its metropolis, and the level that can steal one. */
const val METROPOLIS_LEVEL = 4
/**
* What the three cities-of-industry give in place of a second resource:
* forest gives paper, pasture cloth and mountains coin.
*/
@Serializable
enum class Commodity { CLOTH, COIN, PAPER }
/**
* Any card a player can hold in hand and trade: a resource or a commodity.
* Progress cards are not goods.
*/
@Serializable
enum class Good {
LUMBER, BRICK, WOOL, GRAIN, ORE, CLOTH, COIN, PAPER;
val resource: Resource?
get() = when (this) {
LUMBER -> Resource.LUMBER
BRICK -> Resource.BRICK
WOOL -> Resource.WOOL
GRAIN -> Resource.GRAIN
ORE -> Resource.ORE
else -> null
}
val commodity: Commodity?
get() = when (this) {
CLOTH -> Commodity.CLOTH
COIN -> Commodity.COIN
PAPER -> Commodity.PAPER
else -> null
}
val isCommodity: Boolean get() = commodity != null
companion object {
fun of(resource: Resource): Good = when (resource) {
Resource.LUMBER -> LUMBER
Resource.BRICK -> BRICK
Resource.WOOL -> WOOL
Resource.GRAIN -> GRAIN
Resource.ORE -> ORE
}
fun of(commodity: Commodity): Good = when (commodity) {
Commodity.CLOTH -> CLOTH
Commodity.COIN -> COIN
Commodity.PAPER -> PAPER
}
val RESOURCES: List<Good> = Resource.entries.map { of(it) }
val COMMODITIES: List<Good> = Commodity.entries.map { of(it) }
}
}
/**
* The three tracks of city improvement. Each is bought with one commodity, has
* a colour of progress-card deck and a power that unlocks at level three.
*/
@Serializable
enum class Track(val commodity: Commodity) {
/** Yellow, bought with cloth: trade commodities two for one. */
TRADE(Commodity.CLOTH),
/** Blue, bought with coin: knights may become mighty. */
POLITICS(Commodity.COIN),
/** Green, bought with paper: the aqueduct. */
SCIENCE(Commodity.PAPER),
}
/**
* One face of the event die: a ship, or a city gate of a track's colour.
* Three faces are ships.
*/
@Serializable
enum class EventFace { SHIP, TRADE, POLITICS, SCIENCE }
/** The progress cards: [copies] of each in the deck of its [track]. */
@Serializable
enum class ProgressType(val track: Track, val copies: Int, val scoresAtOnce: Boolean = false) {
// Science (green)
ALCHEMIST(Track.SCIENCE, 2),
CRANE(Track.SCIENCE, 2),
ENGINEER(Track.SCIENCE, 1),
INVENTOR(Track.SCIENCE, 2),
IRRIGATION(Track.SCIENCE, 2),
MEDICINE(Track.SCIENCE, 2),
MINING(Track.SCIENCE, 2),
PRINTER(Track.SCIENCE, 1, scoresAtOnce = true),
ROAD_BUILDING(Track.SCIENCE, 2),
SMITH(Track.SCIENCE, 2),
// Politics (blue)
BISHOP(Track.POLITICS, 2),
CONSTITUTION(Track.POLITICS, 1, scoresAtOnce = true),
DESERTER(Track.POLITICS, 2),
DIPLOMAT(Track.POLITICS, 2),
INTRIGUE(Track.POLITICS, 2),
SABOTEUR(Track.POLITICS, 2),
SPY(Track.POLITICS, 3),
WARLORD(Track.POLITICS, 2),
WEDDING(Track.POLITICS, 2),
// Trade (yellow)
COMMERCIAL_HARBOR(Track.TRADE, 2),
MASTER_MERCHANT(Track.TRADE, 2),
MERCHANT(Track.TRADE, 6),
MERCHANT_FLEET(Track.TRADE, 2),
RESOURCE_MONOPOLY(Track.TRADE, 4),
TRADE_MONOPOLY(Track.TRADE, 2),
}
/** One progress card; [id] is stable for the life of the game. */
@Serializable
data class ProgressCard(val id: Int, val kind: ProgressType)
/**
* A knight on the board. [level] is its strength: 1 basic, 2 strong, 3 mighty.
*
* [activatedTurn] and [promotedTurn] record the turn number each happened on:
* a knight cannot act on the turn it was activated, and cannot be promoted twice
* in one turn.
*/
@Serializable
data class Knight(
val vertex: Int,
val owner: Int,
val level: Int,
val active: Boolean = false,
val activatedTurn: Int = 0,
val promotedTurn: Int = 0,
)
/** A metropolis stands on one city: [vertex] of seat [seat]. */
@Serializable
data class MetropolisAt(val seat: Int, val vertex: Int)
/** The merchant sits on a hex, controlled by [seat] (who holds its point). */
@Serializable
data class MerchantAt(val seat: Int, val hex: Int)
/** A counter-proposal to a [GoodsOffer]: seat [by] would [give] these if the offerer handed over [get]. */
@Serializable
data class GoodsCounter(val by: Int, val give: List<Good>, val get: List<Good>)
/**
* A domestic trade offer in goods, which may include commodities. It is the
* base game's [TradeOffer] in cards of any kind, and works the same way: [to] is
* the one seat it is addressed to or null for the whole table, [asking] is the
* seat that may answer now, and [counters] collect for the offerer to choose from.
*/
@Serializable
data class GoodsOffer(
val by: Int,
val to: Int?,
val give: List<Good>,
val get: List<Good>,
val asking: Int = to ?: -1,
val counters: List<GoodsCounter> = emptyList(),
)
/** Where play goes when the queue of [Chore]s runs out. */
@Serializable
enum class Back { PLAYING, AWAIT_ROLL, AFTER_EVENT, AFTER_DISCARDS, AFTER_PRODUCTION }
/**
* Something one seat must decide before the game can go on. A chore is queued
* when a roll, an attack or a card needs an answer from somebody (possibly not
* the player whose turn it is); the first in [CkState.chores] is the one being
* asked.
*/
@Serializable
sealed interface Chore {
val seat: Int
}
/** Give [count] cards to the bank, of the seat's choosing (a seven, a saboteur). */
@Serializable
@SerialName("c_discard")
data class DiscardChore(override val seat: Int, val count: Int) : Chore
/**
* Hand [count] cards of the seat's choosing to seat [to] (a wedding, a
* commercial harbour). [commoditiesOnly] restricts them to commodities, and
* [reward] is the resource [to] gives back for each.
*/
@Serializable
@SerialName("c_give")
data class GiveChore(
override val seat: Int,
val count: Int,
val to: Int,
val commoditiesOnly: Boolean = false,
val reward: Good? = null,
) : Chore
/** The barbarians won and this seat lost the contest: pick a city to fall. */
@Serializable
@SerialName("c_pillage")
data class PillageChore(override val seat: Int) : Chore
/** Choose a colour of progress deck to draw from. */
@Serializable
@SerialName("c_pickDeck")
data class PickDeckChore(override val seat: Int) : Chore
/** More progress cards than the hand holds: give one back. */
@Serializable
@SerialName("c_dropProgress")
data class DropProgressChore(override val seat: Int) : Chore
/** Choose the city that will bear the [track] metropolis. */
@Serializable
@SerialName("c_metropolis")
data class MetropolisChore(override val seat: Int, val track: Track) : Chore
/** The aqueduct pays out: take any one resource. */
@Serializable
@SerialName("c_aqueduct")
data class AqueductChore(override val seat: Int) : Chore
/** A knight was driven from its place; move it, or lose it. [knight] stood on [Knight.vertex]. */
@Serializable
@SerialName("c_relocate")
data class RelocateChore(override val seat: Int, val knight: Knight) : Chore
/** A deserter was played against this seat: remove one of your knights. */
@Serializable
@SerialName("c_removeKnight")
data class RemoveKnightChore(override val seat: Int, val by: Int) : Chore
/** Place a knight of [level] on the board for free, active or not as given. */
@Serializable
@SerialName("c_placeKnight")
data class PlaceKnightChore(override val seat: Int, val level: Int, val active: Boolean) : Chore
/** Choose one of [target]'s progress cards to take. */
@Serializable
@SerialName("c_spy")
data class SpyChore(override val seat: Int, val target: Int) : Chore
/** Promote up to [left] more knights for free. */
@Serializable
@SerialName("c_smith")
data class SmithChore(override val seat: Int, val left: Int) : Chore
/**
* Everything Cities & Knights adds to the table.
*
* Counts that are public ([progressCounts], the knights, the improvement
* levels) are stored directly; the decks and the other seats' progress hands are
* here in full because the host owns the truth, and [CatanRules.viewFor] keeps
* them off the wire.
*/
@Serializable
data class CkState(
/** Commodity cards per seat. */
val commodities: List<Map<Commodity, Int>>,
/** Improvement level (0..5) per seat and track. */
val improvements: List<Map<Track, Int>>,
/** The metropolis of each track that has one. */
val metropolises: Map<Track, MetropolisAt> = emptyMap(),
val knights: List<Knight> = emptyList(),
/** Vertices whose city has a wall. */
val walls: List<Int> = emptyList(),
/** Steps left before the barbarians land, [BARBARIAN_STEPS] at the start. */
val barbarians: Int = BARBARIAN_STEPS,
/** Whether the barbarians have landed once: until then the robber sleeps. */
val attacked: Boolean = false,
val eventDie: EventFace? = null,
/** Progress decks, top first. Order is hidden. */
val decks: Map<Track, List<ProgressCard>>,
val progressHands: List<List<ProgressCard>>,
/** Public mirror of the progress-hand sizes. */
val progressCounts: List<Int>,
/** Victory points from Constitution and Printer, played face up at once. */
val progressVp: List<Int>,
/** Defender of Catan cards won, one point each. */
val defenderVp: List<Int>,
val merchant: MerchantAt? = null,
/** A crane was played this turn and its discount is unspent. */
val crane: Boolean = false,
/** A merchant fleet was played this turn for this good. */
val fleet: Good? = null,
/** Dice named by an alchemist, used by the next roll. */
val alchemist: List<Int>? = null,
/** The robber is being moved by a bishop, which robs everyone nearby. */
val bishop: Boolean = false,
val chores: List<Chore> = emptyList(),
val back: Back = Back.PLAYING,
val goodsOffer: GoodsOffer? = null,
/** Seats that have already drawn a defender or winners' card this attack, for the log only. */
val lastAttack: String? = null,
)
// ---------------------------------------------------------------------------
// Moves
// ---------------------------------------------------------------------------
/** Buy the next level of improvement in [track]. */
@Serializable
@SerialName("buyImprovement")
data class BuyImprovement(val track: Track) : CatanMove
/** Hire a basic knight onto [vertex], an empty crossing at the end of one of your roads. */
@Serializable
@SerialName("hireKnight")
data class HireKnight(val vertex: Int) : CatanMove
/** Pay a grain to activate the knight on [vertex]. */
@Serializable
@SerialName("activateKnight")
data class ActivateKnight(val vertex: Int) : CatanMove
/** Promote the knight on [vertex] one level (paid, or free under a Smith). */
@Serializable
@SerialName("promoteKnight")
data class PromoteKnight(val vertex: Int) : CatanMove
/** Move the active knight on [from] along your roads to [to], driving off a weaker knight there. */
@Serializable
@SerialName("moveKnight")
data class MoveKnight(val from: Int, val to: Int) : CatanMove
/** The active knight on [vertex] chases the robber; the robber move follows. */
@Serializable
@SerialName("chaseRobber")
data class ChaseRobber(val vertex: Int) : CatanMove
/** Raise a wall around the city on [vertex]. */
@Serializable
@SerialName("buildWall")
data class BuildWall(val vertex: Int) : CatanMove
/**
* Play a progress card. Which of the optional arguments are used depends on
* the card (see [CatanCk]); the engine's legal list names every combination it
* accepts, so a client only ever chooses among them.
*/
@Serializable
@SerialName("progress")
data class PlayProgress(
val card: Int,
val target: Int? = null,
val vertex: Int? = null,
val edge: Int? = null,
val hexA: Int? = null,
val hexB: Int? = null,
val good: Good? = null,
val dice: List<Int>? = null,
val goods: List<Good>? = null,
) : CatanMove
/**
* Trade [give] to the bank for [get], at the best ratio this seat has for the
* card it gives. Replaces [MaritimeTrade] in Cities & Knights, where
* commodities trade too.
*/
@Serializable
@SerialName("goodsBank")
data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove
/**
* Offer a trade in goods to seat [to], or to the whole table when [to] is null.
* Free-form like [OfferTrade], so it is checked structurally and is not in the
* enumerated move list.
*/
@Serializable
@SerialName("offerGoods")
data class OfferGoods(val to: Int?, val give: List<Good>, val get: List<Good>) : CatanMove
/**
* Answer the goods offer on the table with different terms. Structural, like
* [OfferGoods]; the answers to the counters are the shared [AcceptCounter] and
* [DismissCounters].
*/
@Serializable
@SerialName("counterGoods")
data class CounterGoods(val give: List<Good>, val get: List<Good>) : CatanMove
/**
* Give up exactly the cards named, to a [DiscardChore]. Structural, not
* enumerated; the legal list carries one example so a seat is never without a move.
*/
@Serializable
@SerialName("discardGoods")
data class DiscardGoods(val cards: List<Good>) : CatanMove
/** Hand over the cards named to a [GiveChore]. Structural, like [DiscardGoods]. */
@Serializable
@SerialName("giveGoods")
data class GiveGoods(val cards: List<Good>) : CatanMove
/** Let the city on [vertex] fall to the barbarians. */
@Serializable
@SerialName("pillageCity")
data class PillageCity(val vertex: Int) : CatanMove
/** Draw from the deck of this track's colour. */
@Serializable
@SerialName("pickDeck")
data class PickDeck(val track: Track) : CatanMove
/** Give up one progress card from a hand that is over the limit. */
@Serializable
@SerialName("dropProgress")
data class DropProgress(val card: Int) : CatanMove
/** Put the metropolis on the city at [vertex]. */
@Serializable
@SerialName("placeMetropolis")
data class PlaceMetropolis(val vertex: Int) : CatanMove
/** Take one [resource] from the bank for an aqueduct. */
@Serializable
@SerialName("aqueduct")
data class TakeAqueduct(val resource: Resource) : CatanMove
/** Move the knight that was driven off to [to]. */
@Serializable
@SerialName("relocateKnight")
data class RelocateKnight(val to: Int) : CatanMove
/** Take the knight on [vertex] off the board, for a deserter. */
@Serializable
@SerialName("removeKnight")
data class RemoveKnight(val vertex: Int) : CatanMove
/** Place the free knight from a deserter on [vertex]. */
@Serializable
@SerialName("placeKnight")
data class PlaceKnight(val vertex: Int) : CatanMove
/** Take the spied progress card with this id. */
@Serializable
@SerialName("spyTake")
data class SpyTake(val card: Int) : CatanMove
/** Stop promoting knights under a Smith. */
@Serializable
@SerialName("endSmith")
data object EndSmith : CatanMove
@@ -1,7 +1,16 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
const val NO_PLAYER = -1
@@ -12,6 +21,11 @@ const val LONGEST_ROAD_MIN = 5
/** Knights required to take Largest Army. */
const val LARGEST_ARMY_MIN = 3
/** Pieces each player has: a player who has laid them all cannot build another. */
const val MAX_ROADS = 15
const val MAX_SETTLEMENTS = 5
const val MAX_CITIES = 4
/** Resource cards of each type the bank starts with. */
const val BANK_PER_RESOURCE = 19
@@ -34,6 +48,12 @@ enum class Terrain(val produces: Resource?) {
HILLS(Resource.BRICK),
MOUNTAINS(Resource.ORE),
DESERT(null),
/** Seafarers: open water. Ships sail on it; nothing is built on it and nothing produces. */
SEA(null),
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
GOLD(null),
}
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
@@ -69,17 +89,34 @@ enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTOR
data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0)
/**
* A domestic trade offer on the table. [by] is the offering seat and [to]
* the only seat that may respond — v1 has no counter-offers and no open
* auctions, so an offer is always addressed to one player.
* A counter-proposal to a [TradeOffer]: seat [by] would [give] these cards to
* the offerer if the offerer would hand over [get].
*/
@Serializable
data class TradeOffer(val by: Int, val to: Int, val give: List<Resource>, val get: List<Resource>)
data class TradeCounter(val by: Int, val give: List<Resource>, val get: List<Resource>)
/**
* A domestic trade offer on the table. [by] is the offering seat. [to] is the
* one seat it is addressed to, or null for an open offer to the whole table.
* Seats are asked one at a time, in seat order after the offerer; [asking] is
* the seat that may answer now. Each may accept (the trade happens at once),
* decline, or counter; counters are kept in [counters] and, if nobody accepts,
* the offerer chooses among them in [CatanPhase.TRADE_COUNTERS].
*/
@Serializable
data class TradeOffer(
val by: Int,
val to: Int?,
val give: List<Resource>,
val get: List<Resource>,
val asking: Int = to ?: -1,
val counters: List<TradeCounter> = emptyList(),
)
/**
* Where the game stands. The phases that involve more than the player
* whose turn it is carry their own bookkeeping: [discardSeat] during
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING].
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING] and [CatanPhase.TRADE_COUNTERS].
*/
@Serializable
enum class CatanPhase {
@@ -92,6 +129,9 @@ enum class CatanPhase {
/** The player whose turn it is must roll the dice. */
AWAIT_ROLL,
/** Seafarers: the first seat in [SeaState.gold] is choosing the resources its gold fields paid. */
GOLD,
/** Players holding more than 7 resource cards discard half. */
DISCARDING,
@@ -104,20 +144,84 @@ enum class CatanPhase {
/** A played Road Building card: up to two roads remain to be placed. */
FREE_ROADS,
/** The offered seat must accept or decline. */
/** The [TradeOffer.asking] seat must accept, decline or counter. */
TRADE_PENDING,
/** Nobody accepted but some seats countered: the offerer picks one or none. */
TRADE_COUNTERS,
GAME_OVER,
/**
* Cities & Knights only: the first seat in [CkState.chores] has something
* to decide (a discard, a city to give up, a card to draw…) and the game
* waits on it. Play resumes where [CkState.back] says.
*/
CHORE,
}
/** An expansion a table can switch on. The base game is what is left with none selected. */
@Serializable
enum class CatanExpansion {
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS,
/** Seafarers: ships, a pirate, gold fields and islands to settle (see `docs/RULES-catan-seafarers.md`). */
SEAFARERS,
}
/**
* What a table plays: the expansions switched on, and the Seafarers map when that one is.
*
* Saves written before expansions became toggles carry `"ruleSet"` instead of `"expansions"`;
* [CatanOptionsSerializer] reads either.
*/
@Serializable(with = CatanOptionsSerializer::class)
data class CatanOptions(
val playerCount: Int = 3,
val pointsToWin: Int = 10,
val expansions: Set<CatanExpansion> = emptySet(),
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
) {
init {
require(playerCount in 3..4) { "The island seats three or four players" }
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
// Lifted once Cities & Knights runs on a Seafarers map.
require(expansions.size <= 1) { "Combining expansions is not supported yet" }
}
val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions
val citiesAndKnights: Boolean get() = CatanExpansion.CITIES_AND_KNIGHTS in expansions
}
/** The wire shape of [CatanOptions]. */
@Serializable
private class CatanOptionsWire(
val playerCount: Int = 3,
val pointsToWin: Int = 10,
val expansions: Set<CatanExpansion> = emptySet(),
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
)
/** Reads the legacy single `ruleSet` field as an expansion set. */
object CatanOptionsSerializer : KSerializer<CatanOptions> {
override val descriptor: SerialDescriptor = CatanOptionsWire.serializer().descriptor
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
CatanOptionsWire.serializer(),
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario),
)
override fun deserialize(decoder: Decoder): CatanOptions {
val input = decoder as JsonDecoder
var obj = input.decodeJsonElement().jsonObject
val legacy = (obj["ruleSet"] as? JsonPrimitive)?.content
if (legacy != null) {
val expansions = if (legacy == "BASE") emptyList() else listOf(JsonPrimitive(legacy))
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
}
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario)
}
}
@@ -143,6 +247,8 @@ data class CatanState(
val turn: Int,
/** How many setup placements are done, 0 until 2 * playerCount. */
val setupIndex: Int,
/** The seat that places first and takes the first turn; drawn from the table seed. */
val firstSeat: Int = 0,
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
val turnNumber: Int,
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
@@ -185,9 +291,22 @@ data class CatanState(
val rngStep: Int,
val winner: Int?,
val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null,
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
val sea: SeaState? = null,
) {
val playerCount: Int get() = options.playerCount
/** The geometry this game is played on: the standard island, or a Seafarers map. */
val topo: Topology get() = sea?.let { SeaBoards.topology(it.scenario) } ?: CatanBoard.base
/** Whether this table plays Seafarers. */
val isSea: Boolean get() = sea != null
/** Whether this table plays Cities & Knights. */
val isCk: Boolean get() = ck != null
/** Total resource cards held by [seat]. */
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
@@ -222,7 +341,11 @@ data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
*/
@Serializable
@SerialName("roll")
data class RollDice(val forced: List<Int>? = null) : CatanMove
data class RollDice(
val forced: List<Int>? = null,
/** Test-only, like [forced]: the event die of a Cities & Knights roll. */
val event: EventFace? = null,
) : CatanMove
/** Discard exactly these cards. */
@Serializable
@@ -273,7 +396,8 @@ data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
/**
* Offer a domestic trade to [to]: [give] of ours for [get] of theirs.
* Offer a domestic trade to [to] (null: everyone, asked in turn): [give] of
* ours for [get] of theirs.
*
* Like a forced dice roll this is a free-form intent rather than an
* enumerated move — the space of two card multisets is combinatorial — so
@@ -282,7 +406,7 @@ data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
*/
@Serializable
@SerialName("offer")
data class OfferTrade(val to: Int, val give: List<Resource>, val get: List<Resource>) : CatanMove
data class OfferTrade(val to: Int?, val give: List<Resource>, val get: List<Resource>) : CatanMove
@Serializable
@SerialName("accept")
@@ -292,6 +416,25 @@ data object AcceptTrade : CatanMove
@SerialName("decline")
data object DeclineTrade : CatanMove
/**
* Answer the offer on the table with different terms: we would [give] these
* cards if the offerer handed over [get]. Free-form like [OfferTrade], so
* validated by [CatanRules.isLegalMove] rather than listed.
*/
@Serializable
@SerialName("counter")
data class CounterTrade(val give: List<Resource>, val get: List<Resource>) : CatanMove
/** The offerer takes the counter-proposal made by [seat]. */
@Serializable
@SerialName("acceptCounter")
data class AcceptCounter(val seat: Int) : CatanMove
/** The offerer takes none of the counter-proposals. */
@Serializable
@SerialName("dismissCounters")
data object DismissCounters : CatanMove
/** Bank trade: [give] at the best ratio this seat can trade at for [get]. */
@Serializable
@SerialName("bank")
@@ -305,3 +448,48 @@ data object EndTurn : CatanMove
@Serializable
@SerialName("endFreeRoads")
data object EndFreeRoads : CatanMove
/**
* Seafarers state: the ships, the pirate and the islands, alongside the base game's
* roads, robber and buildings (which keep working exactly as before).
*/
@Serializable
data class SeaState(
val scenario: SeaScenario,
/** Owning seat per edge, or [NO_PLAYER]: a ship on that edge. Roads stay in [CatanState.roads]. */
val ships: List<Int>,
/** The hex the pirate occupies: always a sea hex. */
val pirate: Int,
/** Ships built this turn (they may not sail until the next) and whether a ship has sailed this turn. */
val builtThisTurn: List<Int> = emptyList(),
val shipMoved: Boolean = false,
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
val islandBonus: List<Int>,
/** The island the players start on. */
val home: Int,
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
val gold: List<GoldOwed> = emptyList(),
)
@Serializable
data class GoldOwed(val seat: Int, val count: Int)
/** Build a ship on [edge]: 1 lumber and 1 wool, on a sea edge joined to your fleet or a harbour town. */
@Serializable
@SerialName("ship")
data class BuildShip(val edge: Int) : CatanMove
/** Sail the ship on [from] to [to]: once a turn, never the turn it was built, only from the open end of a route. */
@Serializable
@SerialName("moveShip")
data class MoveShip(val from: Int, val to: Int) : CatanMove
/** Move the pirate to the sea hex [hex] and rob [victim], a seat with a ship beside it, if there is one. */
@Serializable
@SerialName("pirate")
data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
/** Choose the [cards] a gold field paid. */
@Serializable
@SerialName("gold")
data class PickGold(val cards: List<Resource>) : CatanMove
@@ -54,26 +54,43 @@ object CatanRules : GameRules<CatanState, CatanMove> {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
val board = CatanBoard.generate(config.seed)
val sea = options.seafarers
val scenario = options.scenario
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
?: CatanBoard.generate(config.seed)
// A different random stream than the island, so the same seed still
// produces both a fixed island and a fixed deck.
val devDeck = DEV_DECK
.mapIndexed { id, type -> DevCard(id, type) }
.shuffled(Random(config.seed + 1))
val ck = options.citiesAndKnights
// Cities & Knights has no development cards: knights and progress
// cards take their place.
val devDeck = if (ck) {
emptyList()
} else {
DEV_DECK
.mapIndexed { id, type -> DevCard(id, type) }
.shuffled(Random(config.seed + 1))
}
// Seat 0 used to lead every game; a separate stream from the board and
// the deck keeps the same seed giving the same table.
val firstSeat = Random(config.seed + 2).nextInt(options.playerCount)
return CatanState(
options = options,
seed = config.seed,
phase = CatanPhase.SETUP_FIRST,
turn = 0,
turn = firstSeat,
setupIndex = 0,
firstSeat = firstSeat,
turnNumber = 0,
terrains = board.terrains,
numbers = board.numbers,
robber = board.robber,
ports = board.ports,
buildings = List(CatanBoard.VERTEX_COUNT) { null },
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER },
buildings = List(topo.VERTEX_COUNT) { null },
roads = List(topo.EDGE_COUNT) { NO_PLAYER },
resources = List(options.playerCount) {
Resource.entries.associateWith { 0 }
},
@@ -91,17 +108,33 @@ object CatanRules : GameRules<CatanState, CatanMove> {
rngStep = 0,
winner = null,
log = listOf(
"Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.",
"Catan${if (ck) " — Cities & Knights" else if (sea) " — Seafarers: ${scenario.title}" else ""} — ${options.playerCount} players, " +
"first to ${options.pointsToWin} victory points.",
),
ck = if (ck) CatanCk.initial(options, config.seed) else null,
sea = seaBoard?.let {
SeaState(
scenario = scenario,
ships = List(topo.EDGE_COUNT) { NO_PLAYER },
pirate = it.pirate,
islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home,
)
},
)
}
override fun currentSeat(state: CatanState): Int? = when (state.phase) {
CatanPhase.GAME_OVER -> null
CatanPhase.SETUP_FIRST -> state.setupIndex
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
CatanPhase.SETUP_FIRST -> (state.firstSeat + state.setupIndex) % state.playerCount
CatanPhase.SETUP_SECOND ->
(state.firstSeat + 2 * state.playerCount - 1 - state.setupIndex) % state.playerCount
CatanPhase.DISCARDING -> state.discardSeat
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
CatanPhase.TRADE_COUNTERS -> state.turn
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
CatanPhase.GOLD -> state.sea?.gold?.firstOrNull()?.seat
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn
@@ -130,12 +163,19 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is OfferTrade -> offerLegal(state, seat, move)
else -> move in legalMoves(state, seat)
is CounterTrade -> counterLegal(state, seat, move)
else -> if (state.isCk) {
CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat))
} else {
move in legalMoves(state, seat)
}
}
}
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
if (currentSeat(state) != seat) return emptyList()
if (state.isCk) return CatanCk.legalMoves(state, seat)
return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
@@ -143,25 +183,34 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.DISCARDING -> discardMoves(state, seat)
CatanPhase.ROBBER -> robberMoves(state, seat)
CatanPhase.ROBBER -> robberMoves(state, seat) +
(if (state.isSea) SeaRules.pirateMoves(state, seat) else emptyList())
CatanPhase.PLAYING -> playingMoves(state, seat)
CatanPhase.FREE_ROADS ->
roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
roadMoves(state, seat) +
(if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) +
listOf<CatanMove>(EndFreeRoads)
CatanPhase.GOLD -> SeaRules.goldMoves(state)
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
CatanPhase.GAME_OVER -> emptyList()
CatanPhase.TRADE_COUNTERS -> counterChoices(state)
CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList()
}
}
private fun setupMoves(state: CatanState): List<SetupPlace> {
internal fun setupMoves(state: CatanState): List<SetupPlace> {
val moves = mutableListOf<SetupPlace>()
for (v in 0 until CatanBoard.VERTEX_COUNT) {
for (v in 0 until state.topo.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue
for (e in CatanBoard.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER) continue
// Seafarers: everyone starts on the home island.
if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
moves += SetupPlace(v, e)
}
}
@@ -194,18 +243,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val moves = mutableListOf<MoveRobber>()
for (h in 0 until CatanBoard.HEX_COUNT) {
if (h == state.robber) continue
val victims = CatanBoard.HEX_CORNERS[h]
for (h in 0 until state.topo.HEX_COUNT) {
if (h == state.robber || !state.topo.land[h]) continue
val victims = state.topo.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] }
.map { it.owner }
.filter { it != seat }
.distinct()
.filter { state.resourceCount(it) > 0 }
if (victims.isEmpty()) {
moves += MoveRobber(CatanBoard.HEXES[h])
moves += MoveRobber(state.topo.HEXES[h])
} else {
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
}
}
return moves
@@ -224,6 +273,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
moves += BuyDevCard
}
if (state.isSea) {
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
moves += SeaRules.moveShipMoves(state, seat)
}
if (!state.devCardPlayedThisTurn) {
for (card in state.devHands[seat]) {
@@ -272,11 +325,22 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return takes
}
/** Road placements that connect to [seat]'s network, cost aside. */
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
(0 until CatanBoard.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
.map { BuildRoad(it) }
/**
* Road placements that connect to [seat]'s network, cost aside. A player
* with all fifteen roads laid has none.
*/
internal fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList()
} else {
(0 until state.topo.EDGE_COUNT)
.filter {
state.roads[it] == NO_PLAYER && state.topo.edgeLand[it] &&
state.sea?.ships?.get(it).let { ship -> ship == null || ship == NO_PLAYER } &&
roadConnects(state, seat, it)
}
.map { BuildRoad(it) }
}
/**
* Settlement placements: an open vertex at least two edges from every
@@ -284,18 +348,33 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* connection requirement does not apply — that is why setup uses its
* own move.)
*/
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
internal fun settlementVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList()
} else {
(0 until state.topo.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) &&
state.topo.VERTEX_EDGES[v].any { state.roads[it] == seat || state.sea?.ships?.get(it) == seat }
}
}
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
internal fun cityVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList()
} else {
(0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
}
}
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
private fun hasKnight(state: CatanState, vertex: Int): Boolean =
state.ck?.knights?.any { it.vertex == vertex } == true
/** How many settlements or cities [seat] currently has standing. */
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
state.buildings.count { it?.owner == seat && it.type == type }
internal fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
val offer = state.pendingTrade ?: return emptyList()
val responses = mutableListOf<CatanMove>()
if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) {
@@ -305,23 +384,61 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return responses
}
/** An empty vertex with no building on it or one edge away. */
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/** What the offerer may do with the counters: take one that is still affordable, or none. */
internal fun counterChoices(state: CatanState): List<CatanMove> {
val offer = state.pendingTrade ?: return emptyList()
val moves = mutableListOf<CatanMove>()
for (counter in offer.counters) {
if (state.holdsAll(counter.by, counter.give) && state.holdsAll(offer.by, counter.get)) {
moves += AcceptCounter(counter.by)
}
}
moves += DismissCounters
return moves
}
/** A road on [edge] would join [seat]'s network of roads and buildings. */
private fun counterLegal(state: CatanState, seat: Int, move: CounterTrade): Boolean {
val offer = state.pendingTrade ?: return false
if (state.phase != CatanPhase.TRADE_PENDING || offer.asking != seat) return false
if (move.give.isEmpty() || move.get.isEmpty()) return false
if (countOf(move.give) == countOf(move.get)) return false
// Taking the offer as made is Accept, not a counter.
if (countOf(move.give) == countOf(offer.get) && countOf(move.get) == countOf(offer.give)) return false
return state.holdsAll(seat, move.give) && state.holdsAll(offer.by, move.get)
}
/** An empty vertex with no building on it or one edge away. */
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/**
* A road on [edge] would join [seat]'s network of roads and buildings.
*
* It must start from a crossing the network actually reaches. An opponent's
* settlement or city on a crossing breaks the network there: roads on either
* side of it do not join, so a road cannot be laid *past* it, even with one
* of your own roads ending at the same crossing.
*/
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } ||
CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } ||
state.buildings[a]?.owner == seat ||
state.buildings[b]?.owner == seat
val (a, b) = state.topo.EDGE_VERTICES[edge]
return networkReaches(state, seat, a) || networkReaches(state, seat, b)
}
/** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
val building = state.buildings[vertex]
// A knight is a wall to everyone but its owner, like a building.
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
if (knight != null && knight.owner != seat) return false
// Your own building is always a way in; anyone else's is a wall.
if (building != null) return building.owner == seat
return state.topo.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
}
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
if (state.phase != CatanPhase.PLAYING || state.turn != seat) return false
if (move.to !in 0 until state.playerCount || move.to == seat) return false
if (move.to != null && (move.to !in 0 until state.playerCount || move.to == seat)) return false
if (move.give.isEmpty() || move.get.isEmpty()) return false
// Like-for-like is not a trade; the published game rejects it too.
if (countOf(move.give) == countOf(move.get)) return false
@@ -342,6 +459,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is RollDice -> applyRoll(state, seat, move)
is Discard -> applyDiscard(state, seat, move)
is MoveRobber -> applyRobber(state, seat, move)
is BuildShip -> applyBuildShip(state, seat, move)
is MoveShip -> applyMoveShip(state, seat, move)
is MovePirate -> applyMovePirate(state, seat, move)
is PickGold -> applyPickGold(state, seat, move)
is BuildRoad -> applyBuildRoad(state, seat, move)
is BuildSettlement -> applyBuildSettlement(state, seat, move)
is BuildCity -> applyBuildCity(state, seat, move)
@@ -353,27 +474,34 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is OfferTrade -> applyOfferTrade(state, seat, move)
AcceptTrade -> applyAcceptTrade(state, seat)
DeclineTrade -> applyDeclineTrade(state, seat)
is CounterTrade -> applyCounterTrade(state, seat, move)
is AcceptCounter -> applyAcceptCounter(state, move)
DismissCounters -> applyDismissCounters(state)
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
EndTurn -> applyEndTurn(state, seat)
EndFreeRoads -> applyEndFreeRoads(state, seat)
}
else -> CatanCk.apply(state, seat, move)
}.let { if (it.isCk) CatanCk.settle(it) else it }
}
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
val n = state.playerCount
val second = state.phase == CatanPhase.SETUP_SECOND
// In Cities & Knights the second placement is a city.
val first = if (state.isCk && second) BuildingType.CITY else BuildingType.SETTLEMENT
var s = state.copy(
buildings = state.buildings.toMutableList().also {
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
it[move.vertex] = CatanBuilding(first, seat)
},
roads = state.roads.toMutableList().also { it[move.edge] = seat },
log = state.log + "Seat $seat settles at ${move.vertex} and lays a road on ${move.edge}.",
log = state.log + ("Seat $seat ${if (first == BuildingType.CITY) "raises a city" else "settles"} " +
"at ${move.vertex} and lays a road on ${move.edge}."),
)
if (second) {
// The second settlement earns one card from each adjacent
// producing hex, supply permitting.
val adjacent = CatanBoard.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
val adjacent = state.topo.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) ->
need <= bankSupply(state, res)
}
@@ -390,9 +518,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return when {
newIndex >= 2 * n -> s.copy(
phase = CatanPhase.AWAIT_ROLL,
turn = 0,
turn = state.firstSeat,
turnNumber = 1,
log = s.log + "Setup done — seat 0 leads.",
log = s.log + "Setup done — seat ${state.firstSeat} leads.",
)
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
@@ -401,6 +529,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
if (state.isCk) return CatanCk.applyRoll(state, seat, move)
val dice = move.forced ?: derivedDice(state)
val total = dice[0] + dice[1]
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
@@ -428,11 +557,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
for (resource in Resource.entries) {
var demand = 0
for (h in 0 until CatanBoard.HEX_COUNT) {
for (h in 0 until state.topo.HEX_COUNT) {
if (state.numbers[h] != number) continue
if (h == state.robber) continue
if (state.terrains[h].produces != resource) continue
for (v in CatanBoard.HEX_CORNERS[h]) {
for (v in state.topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
val amount = if (building.type == BuildingType.CITY) 2 else 1
gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount
@@ -446,6 +575,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
}
// Seafarers: gold fields pay a card of the owner's choice, asked for after the rest is dealt.
val goldOwed = if (state.isSea) goldOwed(state, number) else emptyList()
var s = state.copy(phase = CatanPhase.PLAYING)
val whoGot = mutableListOf<String>()
for (seat in 0 until s.playerCount) {
@@ -454,10 +586,31 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(resources = withCounts(s.resources, seat, got, 1))
whoGot += "seat $seat takes ${describe(got)}"
}
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ")
return s.copy(
val goldLine = goldOwed.joinToString("") { ", seat ${it.seat} is owed ${it.count} gold" }
val said = if (whoGot.isEmpty() && goldOwed.isEmpty()) "nothing for anyone" else
(whoGot.joinToString(", ") + goldLine).trimStart(',', ' ')
s = s.copy(
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
)
if (goldOwed.isEmpty()) return s
return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed)))
}
/** What each seat is owed for gold fields showing [number], in seat order from the roller. */
private fun goldOwed(state: CatanState, number: Int): List<GoldOwed> {
val topo = state.topo
val owed = IntArray(state.playerCount)
for (h in 0 until topo.HEX_COUNT) {
if (state.terrains[h] != Terrain.GOLD || state.numbers[h] != number || h == state.robber) continue
for (v in topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
owed[building.owner] += if (building.type == BuildingType.CITY) 2 else 1
}
}
return (0 until state.playerCount)
.map { (state.turn + it) % state.playerCount }
.filter { owed[it] > 0 }
.map { GoldOwed(it, owed[it]) }
}
private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState {
@@ -475,7 +628,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
val hex = CatanBoard.hexIndex[move.hex]
if (state.isCk) return CatanCk.applyRobber(state, seat, move)
val hex = state.topo.hexIndex[move.hex]
?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
var stolen: String? = null
@@ -491,8 +645,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
return s.copy(
phase = CatanPhase.PLAYING,
log = s.log + "Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
" (${move.hex.q},${move.hex.r})$stolen.",
log = s.log + ("Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
" (${move.hex.q},${move.hex.r})$stolen."),
)
}
@@ -504,11 +658,73 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (free) {
val left = s.pendingFreeRoads - 1
s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = CatanPhase.PLAYING)
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
}
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
}
private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState {
val free = state.phase == CatanPhase.FREE_ROADS
val sea = state.sea!!
var s = state.copy(
sea = sea.copy(
ships = sea.ships.toMutableList().also { it[move.edge] = seat },
builtThisTurn = sea.builtThisTurn + move.edge,
),
)
if (!free) s = payCost(s, seat, SeaRules.SHIP_COST)
s = refreshLongestRoad(s)
if (free) {
val left = s.pendingFreeRoads - 1
s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
}
return checkWin(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."))
}
private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState {
val sea = state.sea!!
val ships = sea.ships.toMutableList().also {
it[move.from] = NO_PLAYER
it[move.to] = seat
}
// The ship is on a new edge, but it has sailed for the turn and may not sail again.
val s = state.copy(
sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to),
)
return checkWin(refreshLongestRoad(s).let {
it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.")
})
}
private fun applyMovePirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1)
var stolen = ""
if (move.victim != null) {
val rng = Random(state.seed * 1_000_003L + state.rngStep)
val victimHand = state.handOf(move.victim)
val card = victimHand[rng.nextInt(victimHand.size)]
val less = withCards(s.resources, move.victim, listOf(card), -1)
s = s.copy(resources = withCards(less, seat, listOf(card), 1))
stolen = " and takes a ${card.name.lowercase()} from seat ${move.victim}"
}
return s.copy(
phase = CatanPhase.PLAYING,
log = s.log + "Seat $seat sends the pirate to (${move.hex.q},${move.hex.r})$stolen.",
)
}
private fun applyPickGold(state: CatanState, seat: Int, move: PickGold): CatanState {
val sea = state.sea!!
val s = state.copy(
resources = withCards(state.resources, seat, move.cards, 1),
sea = sea.copy(gold = sea.gold.drop(1)),
log = state.log + "Seat $seat takes ${describe(countOf(move.cards))} from the gold.",
)
return SeaRules.settleGold(s)
}
private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState {
var s = state.copy(
buildings = state.buildings.toMutableList().also {
@@ -516,6 +732,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
},
)
s = payCost(s, seat, SETTLEMENT_COST)
s = SeaRules.claimIsland(s, seat, move.vertex)
// A settlement can break another player's road, so the award is
// recomputed for everyone.
s = refreshLongestRoad(s)
@@ -529,7 +746,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
},
)
s = payCost(s, seat, CITY_COST)
return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}."))
s = s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.")
// A city can be what a waiting metropolis needed.
if (s.isCk) s = CatanCk.afterCityBuilt(s, seat)
return checkWin(s)
}
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
@@ -602,33 +822,117 @@ object CatanRules : GameRules<CatanState, CatanMove> {
)
}
private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState =
state.copy(
phase = CatanPhase.TRADE_PENDING,
pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get),
private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState {
val audience = if (move.to == null) "the table" else "seat ${move.to}"
val offered = state.copy(
log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " +
"${describe(countOf(move.get))} to seat ${move.to}.",
"${describe(countOf(move.get))} to $audience.",
)
val offer = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get)
val first = nextAsker(offered, offer, seat)
?: return offered.copy(log = offered.log + "Nobody has any cards to trade.")
return offered.copy(
phase = CatanPhase.TRADE_PENDING,
pendingTrade = offer.copy(asking = first),
)
}
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
val offer = state.pendingTrade!!
var resources = withCards(state.resources, offer.to, offer.get, -1)
resources = withCards(resources, offer.by, offer.give, -1)
resources = withCards(resources, offer.by, offer.get, 1)
resources = withCards(resources, offer.to, offer.give, 1)
/**
* The next seat to ask after [after], or null when the round is over. An
* addressed offer asks only its addressee; an open one walks the table in
* seat order, skipping seats with no cards (they could neither pay nor counter).
*/
private fun nextAsker(state: CatanState, offer: TradeOffer, after: Int): Int? {
if (offer.to != null) return if (after == offer.by) offer.to else null
var s = (after + 1) % state.playerCount
while (s != offer.by) {
if (state.resourceCount(s) > 0) return s
s = (s + 1) % state.playerCount
}
return null
}
/** Moves the offer on to the next seat, or closes the round when everyone has answered. */
private fun advanceTrade(state: CatanState, offer: TradeOffer, after: Int, said: String): CatanState {
val next = nextAsker(state, offer, after)
val log = state.log + said
if (next != null) {
return state.copy(pendingTrade = offer.copy(asking = next), log = log)
}
if (offer.counters.isNotEmpty()) {
return state.copy(
phase = CatanPhase.TRADE_COUNTERS,
pendingTrade = offer.copy(asking = -1),
log = log,
)
}
return state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
resources = resources,
log = log + "Nobody takes the offer.",
)
}
/** Seat [a] hands [aGives] to seat [b] and gets [bGives] back. */
private fun swapCards(
resources: List<Map<Resource, Int>>,
a: Int,
aGives: List<Resource>,
b: Int,
bGives: List<Resource>,
): List<Map<Resource, Int>> {
var r = withCards(resources, a, aGives, -1)
r = withCards(r, b, bGives, -1)
r = withCards(r, b, aGives, 1)
return withCards(r, a, bGives, 1)
}
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true)
val offer = state.pendingTrade!!
return state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
resources = swapCards(state.resources, offer.by, offer.give, seat, offer.get),
log = state.log + "Seat $seat accepts — the cards cross the table.",
)
}
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
state.copy(
if (state.ck?.goodsOffer != null) {
CatanCk.answerOffer(state, seat, accept = false)
} else {
advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.")
}
private fun applyCounterTrade(state: CatanState, seat: Int, move: CounterTrade): CatanState {
val offer = state.pendingTrade!!
val counter = TradeCounter(by = seat, give = move.give, get = move.get)
return advanceTrade(
state,
offer.copy(counters = offer.counters + counter),
seat,
"Seat $seat counters: ${describe(countOf(move.give))} for ${describe(countOf(move.get))}.",
)
}
private fun applyAcceptCounter(state: CatanState, move: AcceptCounter): CatanState {
if (state.ck?.goodsOffer != null) return CatanCk.acceptCounter(state, move)
val offer = state.pendingTrade!!
val counter = offer.counters.first { it.by == move.seat }
return state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
log = state.log + "Seat $seat declines the offer.",
resources = swapCards(state.resources, offer.by, counter.get, counter.by, counter.give),
log = state.log + "Seat ${offer.by} takes seat ${counter.by}'s counter — the cards cross the table.",
)
}
private fun applyDismissCounters(state: CatanState): CatanState =
if (state.ck?.goodsOffer != null) CatanCk.dismissCounters(state) else state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
log = state.log + "Seat ${state.turn} turns the counter-offers down.",
)
private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState {
@@ -637,8 +941,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1))
val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour"
return s.copy(
log = s.log + "Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
"${move.get.name.lowercase()} via $via.",
log = s.log + ("Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
"${move.get.name.lowercase()} via $via."),
)
}
@@ -648,12 +952,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (settled.phase == CatanPhase.GAME_OVER) return settled
val next = (state.turn + 1) % state.playerCount
val s = state.copy(
val s = (if (state.isCk) CatanCk.endTurn(state) else state).copy(
turn = next,
turnNumber = state.turnNumber + 1,
phase = CatanPhase.AWAIT_ROLL,
devCardPlayedThisTurn = false,
pendingTrade = null,
sea = state.sea?.copy(builtThisTurn = emptyList(), shipMoved = false),
)
// Someone can cross the target on another player's turn (a broken
// Longest Road re-awarded to a third player); they win when their
@@ -661,9 +966,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return checkWin(s)
}
/** Where play goes after free roads: a card played before the roll returns to the roll. */
private fun phaseAfterRoads(state: CatanState): CatanPhase =
if (state.ck?.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
state.copy(
phase = CatanPhase.PLAYING,
phase = phaseAfterRoads(state),
pendingFreeRoads = 0,
log = state.log + "Seat $seat forfeits the remaining free road.",
)
@@ -679,6 +988,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* view or the host's copy.
*/
fun publicVictoryPoints(state: CatanState): List<Int> {
if (state.isCk) return CatanCk.publicVictoryPoints(state)
val points = IntArray(state.playerCount)
state.buildings.forEach { building ->
if (building != null) {
@@ -687,6 +997,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
state.longestRoadOwner?.let { points[it] += 5 }
state.largestArmyOwner?.let { points[it] += 5 }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList()
}
@@ -694,7 +1005,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
publicVictoryPoints(state)[seat] +
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
private fun checkWin(state: CatanState): CatanState {
internal fun checkWin(state: CatanState): CatanState {
if (state.phase == CatanPhase.GAME_OVER) return state
val seat = state.turn
val points = victoryPoints(state, seat)
@@ -715,7 +1026,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* road falls below five segments the award reopens to the longest road
* of five or more, lowest seat first on a tie.
*/
private fun refreshLongestRoad(state: CatanState): CatanState {
internal fun refreshLongestRoad(state: CatanState): CatanState {
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
val holder = state.longestRoadOwner
val best = lengths
@@ -745,15 +1056,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* one road, not two — the intersection is a branch).
*/
fun longestRoadFor(state: CatanState, seat: Int): Int {
if (state.isSea) return SeaRules.longestRouteFor(state, seat)
var best = 0
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT)
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val blocked = BooleanArray(state.topo.VERTEX_COUNT)
for (v in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[v]
if (building != null && building.owner != seat) blocked[v] = true
}
for (start in 0 until CatanBoard.VERTEX_COUNT) {
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
for (start in 0 until state.topo.VERTEX_COUNT) {
if (state.topo.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(state.topo.VERTEX_COUNT)
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
}
return best
@@ -768,9 +1081,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
): Int {
visited[v] = true
var best = 0
for (e in CatanBoard.VERTEX_EDGES[v]) {
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != seat) continue
val w = CatanBoard.otherEnd(e, v)
val w = state.topo.otherEnd(e, v)
if (visited[w]) continue
if (blocked[w]) {
// The segment may run up to an opponent's building, but not
@@ -812,7 +1125,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
fun bankSupply(state: CatanState, resource: Resource): Int =
BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 }
private fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
internal fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need }
private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState =
@@ -856,13 +1169,23 @@ object CatanRules : GameRules<CatanState, CatanMove> {
for (port in state.ports) {
if (port.resource != null && port.resource != give) continue
if (port.ratio >= best) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
if (mine) best = port.ratio
}
return best
}
/** 3 if [seat] has a settlement on a generic harbour, else the bank's 4. */
fun genericPortRatio(state: CatanState, seat: Int): Int {
for (port in state.ports) {
if (port.resource != null) continue
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
}
return 4
}
// -----------------------------------------------------------------------
// Results, redaction and wire format
// -----------------------------------------------------------------------
@@ -877,12 +1200,15 @@ object CatanRules : GameRules<CatanState, CatanMove> {
else -> "Seat $winner wins"
}
override fun viewFor(state: CatanState, seat: Int): CatanState = state.copy(
// Hidden information stays with the holder and the host.
devDeck = emptyList(),
devHands = state.devHands.mapIndexed { index, hand ->
if (index == seat) hand else emptyList()
},
override fun viewFor(state: CatanState, seat: Int): CatanState = CatanCk.viewFor(
state.copy(
// Hidden information stays with the holder and the host.
devDeck = emptyList(),
devHands = state.devHands.mapIndexed { index, hand ->
if (index == seat) hand else emptyList()
},
),
seat,
)
override fun encodeState(state: CatanState): String = json.encodeToString(state)
@@ -0,0 +1,144 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.Serializable
import kotlin.random.Random
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
@Serializable
enum class SeaScenario(val title: String, val blurb: String) {
NEW_SHORES(
"Heading for New Shores",
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
),
}
/**
* The Seafarers maps as geometry plus a generator for one game's tiles.
*
* A map is drawn below as text: `.` is sea, `#` a land hex that gets a random resource,
* `$` a gold field, `D` the desert. Odd rows are shifted half a hex to the right (the usual
* "odd-r" offset layout). The outermost ring is always sea, so every coast is closed.
*/
object SeaBoards {
private val NEW_SHORES_MAP = listOf(
"..........",
"...##.....",
"..####.$#.",
".#####..#.",
".##D###...",
"..####..#.",
".......##.",
".$#.......",
"..........",
)
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
}
private val topologies = HashMap<SeaScenario, Topology>()
/** The frame of [scenario]: every hex, with its land mask. Built once and shared. */
fun topology(scenario: SeaScenario): Topology = synchronized(topologies) {
topologies.getOrPut(scenario) { build(scenario) }
}
/** Hex (row, col) of the text map as an axial coordinate (odd-r offset). */
private fun coord(row: Int, col: Int): HexCoord = HexCoord(col - (row - (row and 1)) / 2, row)
private fun build(scenario: SeaScenario): Topology {
val map = rows(scenario)
val hexes = mutableListOf<HexCoord>()
val land = mutableListOf<Boolean>()
map.forEachIndexed { row, text ->
text.forEachIndexed { col, ch ->
hexes += coord(row, col)
land += ch != '.'
}
}
return Topology(hexes, land, standardFrame = false, harbours = 9)
}
/** Terrain kind the map asks of each hex: sea, gold, desert, or "pick a resource". */
private fun kinds(scenario: SeaScenario): List<Char> = rows(scenario).flatMap { it.toList() }
/** One game's tiles on a Seafarers map. */
@Serializable
data class Generated(
val terrains: List<Terrain>,
val numbers: List<Int>,
val robber: Int,
val pirate: Int,
val ports: List<Port>,
val home: Int,
)
private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
)
/** Number tokens: the standard 18 plus ten more, so every land hex but the desert has one. */
private val TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12) +
listOf(3, 4, 5, 6, 8, 9, 10, 11, 5, 9)
fun generate(scenario: SeaScenario, seed: Long): Generated {
val topo = topology(scenario)
val kinds = kinds(scenario)
val random = Random(seed)
// Resources fill the '#' hexes as evenly as the count allows, in shuffled order.
val plain = kinds.indices.filter { kinds[it] == '#' }
val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random)
val terrains = MutableList(kinds.size) { Terrain.SEA }
kinds.forEachIndexed { h, ch ->
terrains[h] = when (ch) {
'$' -> Terrain.GOLD
'D' -> Terrain.DESERT
'#' -> pool[plain.indexOf(h)]
else -> Terrain.SEA
}
}
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT }
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
val tokens = TOKENS.take(numbered.size).shuffled(random)
val numbers = IntArray(terrains.size)
var placed = false
var attempt = 0
while (!placed && attempt < 200) {
val order = numbered.shuffled(random)
val assigned = IntArray(terrains.size)
order.forEachIndexed { i, h -> assigned[h] = tokens[i] }
val reds = numbered.filter { assigned[it] == 6 || assigned[it] == 8 }
val clash = reds.any { a -> reds.any { b -> a < b && topo.hexesAdjacent(a, b) } }
if (!clash || attempt == 199) {
assigned.copyInto(numbers)
placed = true
}
attempt++
}
val portTypes = (
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
).shuffled(random)
val ports = topo.PORT_POSITIONS.mapIndexed { i, edge ->
val resource = portTypes[i % portTypes.size]
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
}
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
?: numbered.first()
val home = (0 until topo.islandCount).maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
// The pirate starts in the open sea: the sea hex farthest from every shore.
val sea = terrains.indices.filter { terrains[it] == Terrain.SEA }
val pirate = sea.maxByOrNull { h ->
val c = topo.HEXES[h]
terrains.indices.filter { terrains[it] != Terrain.SEA }.minOfOrNull {
Topology.hexDistance(c.q, c.r, topo.HEXES[it].q, topo.HEXES[it].r)
} ?: 0
} ?: sea.first()
return Generated(terrains, numbers.toList(), desert, pirate, ports, home)
}
}
@@ -0,0 +1,205 @@
package org.prolibertate.games.game.catan
/**
* The rules Seafarers adds to the base game, kept apart from [CatanRules] so the base
* game's code reads as it always did. [CatanRules] calls in here only when a game has
* [CatanState.sea]. See `docs/RULES-catan-seafarers.md`.
*/
internal object SeaRules {
const val MAX_SHIPS = 15
/** Points for being the first to settle an island other than the home island. */
const val ISLAND_BONUS = 2
val SHIP_COST: Map<Resource, Int> = mapOf(Resource.LUMBER to 1, Resource.WOOL to 1)
// -----------------------------------------------------------------------
// Ships
// -----------------------------------------------------------------------
private fun fleetOf(state: CatanState, seat: Int): Int = state.sea!!.ships.count { it == seat }
/**
* Whether [seat]'s ships and towns reach [vertex], so a ship may start there. A town
* of anyone else is a wall; [ignoring] is a ship that is being lifted off the board.
*/
private fun shipReaches(state: CatanState, seat: Int, vertex: Int, ignoring: Int): Boolean {
val building = state.buildings[vertex]
if (building != null) return building.owner == seat
val sea = state.sea!!
return state.topo.VERTEX_EDGES[vertex].any { it != ignoring && sea.ships[it] == seat }
}
/** A sea edge a ship could sit on, joined to [seat]'s fleet or a town of theirs. */
private fun shipSites(state: CatanState, seat: Int, ignoring: Int = -1): List<Int> {
val topo = state.topo
val sea = state.sea!!
return (0 until topo.EDGE_COUNT).filter { e ->
topo.edgeSea[e] &&
state.roads[e] == NO_PLAYER &&
(sea.ships[e] == NO_PLAYER || e == ignoring) &&
sea.pirate !in topo.EDGE_HEXES[e] &&
topo.EDGE_VERTICES[e].let { (a, b) ->
shipReaches(state, seat, a, ignoring) || shipReaches(state, seat, b, ignoring)
}
}
}
fun buildShipMoves(state: CatanState, seat: Int): List<BuildShip> =
if (fleetOf(state, seat) >= MAX_SHIPS) emptyList() else shipSites(state, seat).map { BuildShip(it) }
/** Whether the ship on [edge] sits at the open end of a route: one end touches nothing else of [seat]'s. */
private fun atOpenEnd(state: CatanState, seat: Int, edge: Int): Boolean {
val topo = state.topo
val sea = state.sea!!
return topo.EDGE_VERTICES[edge].toList().any { v ->
state.buildings[v] == null &&
topo.VERTEX_EDGES[v].none { it != edge && (state.roads[it] == seat || sea.ships[it] == seat) }
}
}
/** A ship may sail once a turn, never the turn it was built, only from the open end of a route. */
fun moveShipMoves(state: CatanState, seat: Int): List<MoveShip> {
val sea = state.sea!!
if (sea.shipMoved) return emptyList()
val moves = mutableListOf<MoveShip>()
for (e in sea.ships.indices) {
if (sea.ships[e] != seat || e in sea.builtThisTurn || !atOpenEnd(state, seat, e)) continue
shipSites(state, seat, ignoring = e).filter { it != e }.forEach { moves += MoveShip(e, it) }
}
return moves
}
// -----------------------------------------------------------------------
// Pirate
// -----------------------------------------------------------------------
/** Seats with a ship beside [hex] that hold at least one card: whom the pirate can rob there. */
private fun pirateVictims(state: CatanState, thief: Int, hex: Int): List<Int> {
val topo = state.topo
val sea = state.sea!!
return (0 until topo.EDGE_COUNT).filter { hex in topo.EDGE_HEXES[it] }
.map { sea.ships[it] }
.filter { it != NO_PLAYER && it != thief }
.distinct()
.filter { state.resourceCount(it) > 0 }
}
fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> {
val topo = state.topo
val sea = state.sea!!
val moves = mutableListOf<MovePirate>()
for (h in 0 until topo.HEX_COUNT) {
if (topo.land[h] || h == sea.pirate) continue
val victims = pirateVictims(state, seat, h)
if (victims.isEmpty()) {
moves += MovePirate(topo.HEXES[h])
} else {
victims.forEach { moves += MovePirate(topo.HEXES[h], it) }
}
}
return moves
}
// -----------------------------------------------------------------------
// Gold
// -----------------------------------------------------------------------
/** Every way the seat at the front of the gold queue can choose its cards, supply permitting. */
fun goldMoves(state: CatanState): List<PickGold> {
val owed = state.sea!!.gold.firstOrNull() ?: return emptyList()
val supply = Resource.entries.associateWith { CatanRules.bankSupply(state, it) }
val take = minOf(owed.count, supply.values.sum())
val picks = mutableListOf<PickGold>()
fun walk(index: Int, left: Int, acc: MutableList<Resource>) {
if (left == 0) {
picks += PickGold(acc.toList())
return
}
if (index == Resource.entries.size) return
val resource = Resource.entries[index]
for (n in 0..minOf(left, supply.getValue(resource))) {
repeat(n) { acc += resource }
walk(index + 1, left - n, acc)
repeat(n) { acc.removeAt(acc.size - 1) }
}
}
walk(0, take, mutableListOf())
return picks
}
/** Drops queue entries the bank can no longer pay, and leaves the gold phase once nobody is owed. */
fun settleGold(state: CatanState): CatanState {
val sea = state.sea!!
val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) }
val queue = if (supply > 0) sea.gold else emptyList()
return if (queue.isEmpty()) {
state.copy(sea = sea.copy(gold = emptyList()), phase = CatanPhase.PLAYING)
} else {
state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD)
}
}
// -----------------------------------------------------------------------
// Islands and scoring
// -----------------------------------------------------------------------
/** The bonus points [seat] holds for first landings. */
fun bonusPoints(state: CatanState, seat: Int): Int =
(state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS
/** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
val sea = state.sea ?: return state
val island = state.topo.vertexIsland[vertex]
if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
return state.copy(
sea = sea.copy(islandBonus = bonus),
log = state.log + "Seat $seat is the first to settle a new island: +$ISLAND_BONUS victory points.",
)
}
// -----------------------------------------------------------------------
// Longest route
// -----------------------------------------------------------------------
/**
* The seat's longest route over roads *and* ships. A route is a simple path of the seat's
* pieces that never runs through an opponent's town; it may change between road and ship
* only at a town of the seat's own.
*/
fun longestRouteFor(state: CatanState, seat: Int): Int {
val topo = state.topo
val sea = state.sea!!
val blocked = BooleanArray(topo.VERTEX_COUNT) { v ->
state.buildings[v]?.let { it.owner != seat } == true
}
fun owns(e: Int) = state.roads[e] == seat || sea.ships[e] == seat
fun kind(e: Int) = if (state.roads[e] == seat) 0 else 1
fun walk(v: Int, arrivedBy: Int, visited: BooleanArray): Int {
visited[v] = true
var best = 0
val ownTown = state.buildings[v]?.owner == seat
for (e in topo.VERTEX_EDGES[v]) {
if (!owns(e)) continue
val k = kind(e)
if (arrivedBy != -1 && k != arrivedBy && !ownTown) continue
val w = topo.otherEnd(e, v)
if (visited[w]) continue
best = maxOf(best, if (blocked[w]) 1 else 1 + walk(w, k, visited))
}
visited[v] = false
return best
}
var best = 0
for (start in 0 until topo.VERTEX_COUNT) {
if (topo.VERTEX_EDGES[start].none { owns(it) }) continue
best = maxOf(best, walk(start, -1, BooleanArray(topo.VERTEX_COUNT)))
}
return best
}
}
@@ -1,6 +1,9 @@
package org.prolibertate.games.game.checkers
import kotlin.random.Random
import kotlin.math.roundToInt
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -17,22 +20,49 @@ import org.prolibertate.games.game.engine.GameAi
* recapture as its only legal move, so the search plays it whether it wanted to
* or not.
*/
class CheckersAi(private val level: CheckersLevel? = null) :
GameAi<CheckersState, CheckersMove> {
class CheckersAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: CheckersLevel? = null,
) : GameAi<CheckersState, CheckersMove> {
private var nodeBudget = 0
private var nodesUsed = 0
// Evaluation weights and viewpoint for the move being chosen. At the default
// (balanced) style they equal the plain constants, so Medium is unchanged.
private var rootSeat = 0
private var wAdvance = ADVANCE
private var wEdge = EDGE
private var wBackRow = BACK_ROW
private var wTheirMobility = MOBILITY
override fun chooseMove(
state: CheckersState,
seat: Int,
legal: List<CheckersMove>,
): CheckersMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
nodeBudget = NODE_BUDGET
val bold = profile.style.boldness
val spite = profile.style.spite
rootSeat = seat
// Bold seats push men forward and care less about the edge and back row;
// cautious ones do the reverse. Spiteful seats weigh the opponent's
// freedom of movement more heavily.
wAdvance = ADVANCE + (bold * 2).roundToInt()
wEdge = EDGE - (bold * 2).roundToInt()
wBackRow = BACK_ROW - (bold * 3).roundToInt()
wTheirMobility = MOBILITY + (spite * 2).roundToInt()
val maxDepth = when (profile.level) {
AiLevel.EASY -> maxOf(2, setting.depth - 2)
AiLevel.MEDIUM -> setting.depth
AiLevel.HARD -> setting.depth + 2
}
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
nodesUsed = 0
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
@@ -40,7 +70,7 @@ class CheckersAi(private val level: CheckersLevel? = null) :
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
for (depth in 1..maxDepth) {
val results = mutableListOf<Pair<CheckersMove, Int>>()
val ordered = order(legal, preferred = best)
for (move in ordered) {
@@ -61,7 +91,16 @@ class CheckersAi(private val level: CheckersLevel? = null) :
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
val chosen = acceptable[random.nextInt(acceptable.size)].first
if (profile.level != AiLevel.EASY) return chosen
// Easy now and then settles for another sound-looking move: only ones
// within a man and a half of the best, never a real blunder.
val ranked = listOf(chosen) + scored
.filter { it.first != chosen && it.second >= bestScore - EASY_TOLERANCE }
.sortedByDescending { it.second }
.map { it.first }
return profile.pick(ranked)
}
private fun CheckersLevel.slack(): Int = when (this) {
@@ -126,29 +165,32 @@ class CheckersAi(private val level: CheckersLevel? = null) :
*/
private fun evaluate(state: CheckersState): Int {
val us = state.turn
val them = other(us)
var score = 0
val me = rootSeat
for (square in 0 until SQUARES) {
val piece = state.board[square] ?: continue
val sign = if (piece.seat == us) 1 else -1
val sign = if (piece.seat == me) 1 else -1
score += sign * if (piece.king) KING else MAN
if (!piece.king) {
// How many rows it has come, out of the seven it needs.
val advanced = if (piece.seat == BLACK_SEAT) rowOf(square) else 7 - rowOf(square)
score += sign * advanced * ADVANCE
score += sign * advanced * wAdvance
}
// Sitting on the edge cannot be jumped, which is worth a little in
// a game whose entire tactic is being jumped.
if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * EDGE
if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * wEdge
if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * BACK_ROW
if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * wBackRow
}
score += MOBILITY * (mobility(state, us) - mobility(state, them))
return score
val mine = mobility(state, me)
val theirs = mobility(state, other(me))
score += MOBILITY * mine - wTheirMobility * theirs
// Scored from the root seat's side; flip for the side to move.
return if (us == me) score else -score
}
/**
@@ -178,6 +220,8 @@ class CheckersAi(private val level: CheckersLevel? = null) :
const val INFINITY = 1_000_000
const val WIN = 100_000
const val NODE_BUDGET = 200_000
const val HARD_NODE_BUDGET = 450_000
const val EASY_TOLERANCE = 150
const val MAN = 100
const val KING = 175
@@ -2,6 +2,9 @@ package org.prolibertate.games.game.chess
import kotlin.math.abs
import kotlin.random.Random
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -16,7 +19,14 @@ import org.prolibertate.games.game.engine.GameAi
* so the setup screen sets the strength without the caller having to thread it
* through.
*/
class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessMove> {
class ChessAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<ChessState, ChessMove> {
/** Fixed strength that overrides the table's option (used by tests and tools). */
private var level: ChessLevel? = null
constructor(level: ChessLevel) : this(AiProfiles.DEFAULT) {
this.level = level
}
/**
* Ceiling on how much thinking any one move gets. A phone should not stall
@@ -28,10 +38,22 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
override fun chooseMove(state: ChessState, seat: Int, legal: List<ChessMove>): ChessMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
nodeBudget = NODE_BUDGET
// Easy looks one move less far ahead; Hard looks one further and gets
// roughly twice the node budget (still a couple of seconds on a phone).
val maxDepth = when (profile.level) {
AiLevel.EASY -> maxOf(1, setting.depth - 1)
AiLevel.MEDIUM -> setting.depth
AiLevel.HARD -> setting.depth + 1
}
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
aiWhite = state.whiteToMove
styleActive = profile.style.boldness != 0.0 || profile.style.spite != 0.0
styleBoldness = profile.style.boldness
styleSpite = profile.style.spite
nodesUsed = 0
// Deterministic per position, so a host and a client replaying the same
@@ -42,7 +64,7 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
for (depth in 1..maxDepth) {
val results = mutableListOf<Pair<ChessMove, Int>>()
// Search the previous iteration's best first, so the sub-searches
// below it have a decent bound to prune against.
@@ -74,9 +96,56 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
// makes them beatable without making them play nonsense.
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
val chosen = acceptable[random.nextInt(acceptable.size)].first
if (profile.level != AiLevel.EASY) return chosen
// Easy sometimes settles for a worse move, but only one within a modest
// margin of the best, so it never throws a piece away or walks into mate.
val ranked = scored.filter { it.second >= bestScore - setting.slack() - EASY_EXTRA_SLACK }
.sortedByDescending { it.second }.map { it.first }
val ordered = listOf(chosen) + ranked.filter { it != chosen }
return profile.pick(ordered)
}
// Per-move style state, set at the start of chooseMove.
private var aiWhite = true
private var styleActive = false
private var styleBoldness = 0.0
private var styleSpite = 0.0
/**
* A small temperament term (well under a pawn) from the mover's point of
* view. Aggressive seats like pieces near the enemy king, cautious seats
* like a sheltered own king, spiteful seats like the enemy being badly
* placed. Zero for the default style, so default play is unchanged.
*/
private fun styleTerm(state: ChessState): Int {
val enemyKing = state.kingSquare(white = !aiWhite)
val myKing = state.kingSquare(white = aiWhite)
var attackers = 0
var shield = 0
var enemyPlacement = 0
for (square in 0 until 64) {
val piece = state.board[square] ?: continue
if (piece.white == aiWhite) {
if (piece.kind == PieceKind.KING) continue
if (enemyKing != null && piece.kind != PieceKind.PAWN && distance(square, enemyKing) <= 3) attackers++
if (myKing != null && distance(square, myKing) <= 1) shield++
} else {
val table = tableFor(piece.kind)
enemyPlacement += table[if (piece.white) square else square xor 56]
}
}
var term = 0.0
if (styleBoldness > 0) term += styleBoldness * 4 * attackers
if (styleBoldness < 0) term += -styleBoldness * 5 * shield
term -= styleSpite * 0.1 * enemyPlacement
val own = term.toInt().coerceIn(-40, 40)
return if (state.whiteToMove == aiWhite) own else -own
}
private fun distance(a: Int, b: Int): Int = maxOf(abs(fileOf(a) - fileOf(b)), abs(rankOf(a) - rankOf(b)))
private fun ChessLevel.slack(): Int = when (this) {
ChessLevel.CASUAL -> 70
ChessLevel.CLUB -> 20
@@ -116,7 +185,7 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
*/
private fun quiescence(state: ChessState, alpha: Int, beta: Int, ply: Int): Int {
nodesUsed++
val standPat = evaluate(state)
val standPat = evaluate(state) + if (styleActive) styleTerm(state) else 0
if (standPat >= beta) return beta
var localAlpha = maxOf(alpha, standPat)
if (ply >= MAX_QUIESCENCE_PLY) return localAlpha
@@ -205,6 +274,8 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
const val INFINITY = 1_000_000
const val MATE = 100_000
const val NODE_BUDGET = 120_000
const val HARD_NODE_BUDGET = 240_000
const val EASY_EXTRA_SLACK = 60
const val MAX_QUIESCENCE_PLY = 24
/**
@@ -1,52 +1,135 @@
package org.prolibertate.games.game.crazyeights
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Crazy 8s opponent.
* A Crazy 8s opponent whose strength and temperament come from the seat's profile.
*
* It sheds expensive cards first, keeps its eights back for when it is stuck,
* and when it does play one it names the suit it holds most of.
* MEDIUM (the default) sheds expensive cards first, keeps its eights back for
* when it is stuck, and when it does play one it names the suit it holds most of.
*
* EASY plays the same way but sometimes sheds a cheaper card or wastes an eight.
* HARD counts the cards on the discard pile to know what is still out, plays to
* keep a long suit it can run, and when someone is close to going out it turns
* the game to a suit that is nearly exhausted so they cannot follow.
*/
class CrazyEightsAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class CrazyEightsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<CrazyEightsState, CrazyEightsMove> {
enum class Difficulty(
/** Play an eight anyway once the hand is down to this size. */
val eightEndgame: Int,
) {
EASY(eightEndgame = 99),
NORMAL(eightEndgame = 3),
CAUTIOUS(eightEndgame = 2),
}
override fun chooseMove(
state: CrazyEightsState,
seat: Int,
legal: List<CrazyEightsMove>,
): CrazyEightsMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val plays = legal.filterIsInstance<PlayCard>()
if (plays.isEmpty()) return legal.first()
val move: CrazyEightsMove = when (profile.level) {
AiLevel.MEDIUM -> standardRanking(state, seat, plays, EIGHT_ENDGAME).first()
AiLevel.EASY -> profile.pick(standardRanking(state, seat, plays, EASY_EIGHT_ENDGAME))
AiLevel.HARD -> hard(state, seat, plays, profile)
}
return if (move in legal) move else legal.first()
}
/**
* Ordinary cards first, most expensive leading, then eights with the suit
* this hand holds most of named first.
*/
private fun standardRanking(
state: CrazyEightsState,
seat: Int,
plays: List<PlayCard>,
eightEndgame: Int,
): List<PlayCard> {
val hand = state.hands[seat]
val ordinary = plays.filter { !isWild(it.card) }
// An eight is the only card that always plays, so it is worth more held
// than spent — unless the hand is nearly gone, or nothing else works.
val useEight = ordinary.isEmpty() || hand.size <= difficulty.eightEndgame
val useEight = ordinary.isEmpty() || hand.size <= eightEndgame
val pool = if (useEight) plays else ordinary
val best = pool
.filter { !isWild(it.card) }
.maxByOrNull { crazyEightsPenalty(it.card) }
if (best != null) return best
val ranked = pool.filter { !isWild(it.card) }.sortedByDescending { crazyEightsPenalty(it.card) }
if (ranked.isNotEmpty() && !useEight) return ranked
// Playing an eight: switch the game to whatever we hold most of.
val strongest = Suit.entries.maxByOrNull { suit -> hand.count { it.suit == suit } }
?: Suit.SPADES
return pool.firstOrNull { it.nominatedSuit == strongest } ?: pool.first()
val suits = Suit.entries.sortedByDescending { suit -> hand.count { it.suit == suit } }
val wilds = suits.flatMap { suit -> pool.filter { isWild(it.card) && it.nominatedSuit == suit } }
return ranked + wilds.ifEmpty { pool.filter { isWild(it.card) } }
}
// -----------------------------------------------------------------------
// Hard
// -----------------------------------------------------------------------
private fun hard(
state: CrazyEightsState,
seat: Int,
plays: List<PlayCard>,
profile: AiProfile,
): CrazyEightsMove {
val hand = state.hands[seat]
val options = state.options
// Cards this seat can account for: its own hand and everything on the pile.
val known = HashSet<Card>().also { it.addAll(hand); it.addAll(state.discard) }
val unseen = FULL_DECK.filter { it !in known }
fun unseenIn(suit: Suit): Int = unseen.count { it.suit == suit }
val next = (seat + 1) % options.playerCount
val nextCount = state.handCounts[next]
val smallestOpponent = (0 until options.playerCount).filter { it != seat }
.minOf { state.handCounts[it] }
// How hard to squeeze the suits: more when somebody is about to go out.
val urgency = 1.0 + profile.style.spite +
(if (nextCount <= 2) 1.0 else 0.0) + (if (smallestOpponent <= 2) 0.5 else 0.0)
/** Value of leaving the game showing [suit], with [remaining] the cards still held. */
fun suitValue(suit: Suit, remaining: List<Card>): Double =
3.0 * remaining.count { it.suit == suit } - 0.7 * urgency * unseenIn(suit)
val ordinary = plays.filter { !isWild(it.card) }
val wilds = plays.filter { isWild(it.card) }
// Aggressive seats spend eights earlier; cautious ones keep them as insurance.
val endgame = (EIGHT_ENDGAME + profile.style.boldness * 2.0)
val blocking = smallestOpponent <= 1 || (nextCount <= 2 && urgency >= 2.0)
val useEight = ordinary.isEmpty() || hand.size <= endgame || (blocking && wilds.isNotEmpty())
if (hand.size == 1) {
plays.firstOrNull()?.let { return it }
}
if (!useEight || wilds.isEmpty()) {
if (ordinary.isNotEmpty()) {
// Cautious seats shed liabilities harder, bold ones care more about position.
val shedWeight = 1.0 - 0.3 * profile.style.boldness
return ordinary.maxBy { play ->
val remaining = hand - play.card
crazyEightsPenalty(play.card) * shedWeight + suitValue(play.card.suit, remaining)
}
}
}
// An eight: name the suit that is best for this hand and worst for the others.
return wilds.maxBy { play ->
val remaining = hand - play.card
suitValue(play.nominatedSuit ?: play.card.suit, remaining)
}
}
private companion object {
const val EIGHT_ENDGAME = 3
const val EASY_EIGHT_ENDGAME = 99
val FULL_DECK: List<Card> = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } }
}
}
@@ -3,8 +3,12 @@ package org.prolibertate.games.game.cribbage
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
import kotlin.math.abs
import kotlin.random.Random
/**
* A cribbage opponent that counts rather than guesses.
@@ -18,8 +22,14 @@ import kotlin.math.abs
*
* The play is a heuristic: take the points on offer, and try not to leave the
* count where a ten-card collects fifteen or thirty-one.
*
* Per seat, from [profiles]: Easy settles for a worse lay-away or card now and
* then; Hard also samples the crib (what the unseen cards might add to it) and
* weighs what a reply could score off each card it plays. Boldness trades the
* two counts-to-avoid and crib ownership; spite weighs the other side's crib.
*/
class CribbageAi : GameAi<CribbageState, CribbageMove> {
class CribbageAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<CribbageState, CribbageMove> {
override fun chooseMove(
state: CribbageState,
@@ -27,30 +37,78 @@ class CribbageAi : GameAi<CribbageState, CribbageMove> {
legal: List<CribbageMove>,
): CribbageMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val layAways = legal.filterIsInstance<LayAway>()
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways)
return choosePeg(state, legal.filterIsInstance<PegCard>())
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways, profile)
val pegs = legal.filterIsInstance<PegCard>()
if (pegs.isNotEmpty()) return choosePeg(state, seat, pegs, profile)
return legal.first()
}
private fun chooseLayAway(
state: CribbageState,
seat: Int,
choices: List<LayAway>,
profile: AiProfile,
): LayAway {
val hand = state.hands[seat]
val held = hand.toSet()
val unseen = Decks.standard52().filter { it !in held }
// A partner's crib is your crib. That is the whole of four-handed play.
val ownCrib = state.options.teamOf(seat) == state.options.teamOf(state.dealer)
val boldness = profile.style.boldness
// Bold seats back their own crib; careful ones fear the other side's.
val cribSign = if (ownCrib) {
1.0 + 0.25 * boldness
} else {
-(1.0 - 0.25 * boldness + 0.3 * profile.style.spite)
}
val trials = if (profile.level == AiLevel.HARD) sampleCribs(hand, unseen, state.options) else null
return choices.maxByOrNull { choice ->
val scored = choices.map { choice ->
val given = choice.cards.toSet()
val keep = hand.filter { it !in given }
val expected = unseen.sumOf { starter ->
CribbageScoring.show(keep, starter, isCrib = false).sumOf { it.points }
}.toDouble() / unseen.size
expected + cribValue(choice.cards) * (if (ownCrib) 1.0 else -1.0)
} ?: choices.first()
val crib = if (trials != null) {
// Heuristic and sampled estimates agree on direction; averaging
// them smooths the sampling noise without losing the structure.
(sampledCribValue(choice.cards, trials) + cribValue(choice.cards)) / 2.0
} else {
cribValue(choice.cards)
}
choice to expected + crib * cribSign
}
// Stable sort: ties resolve to the first choice, as maxByOrNull did.
val ranked = scored.sortedByDescending { it.second }.map { it.first }
return profile.pick(ranked)
}
/** Sets of unseen cards a crib might receive, plus a cut, for Hard to average over. */
private fun sampleCribs(
hand: List<Card>,
unseen: List<Card>,
options: CribbageOptions,
): List<List<Card>> {
val random = Random(hand.sumOf { it.rank.ordinal * 4 + it.suit.ordinal }.toLong())
// Whatever the crib is short of after our own cards, plus the cut.
val others = (CRIB_SIZE - options.layAwaySize).coerceAtLeast(0)
return List(CRIB_SAMPLES) {
val picked = LinkedHashSet<Card>()
while (picked.size < others + 1) picked += unseen[random.nextInt(unseen.size)]
picked.toList()
}
}
private fun sampledCribValue(given: List<Card>, trials: List<List<Card>>): Double {
var total = 0
for (trial in trials) {
val starter = trial.last()
val crib = given + trial.dropLast(1)
total += CribbageScoring.show(crib, starter, isCrib = true).sumOf { it.points }
}
return total.toDouble() / trials.size
}
/**
@@ -83,22 +141,63 @@ class CribbageAi : GameAi<CribbageState, CribbageMove> {
return value
}
private fun choosePeg(state: CribbageState, choices: List<PegCard>): PegCard {
private fun choosePeg(
state: CribbageState,
seat: Int,
choices: List<PegCard>,
profile: AiProfile,
): PegCard {
val count = state.count
val onTheTable = state.series.map { it.card }
val boldness = profile.style.boldness
// Bold seats risk leaving five or twenty-one; careful ones really avoid it.
val countPenalty = 3.0 - boldness * 1.5
val leadFivePenalty = 4.0 - boldness * 2.0
val hard = profile.level == AiLevel.HARD
// Cards a reply could come from: nothing this seat holds, has seen played or was cut.
val unseen = if (hard) {
val known = (state.remaining(seat) + state.played.map { it.card } + listOfNotNull(state.starter)).toSet()
Decks.standard52().filter { it !in known && it !in state.hands[seat] }
} else {
emptyList()
}
return choices.maxByOrNull { choice ->
val scored = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
val scored = choices.map { choice ->
val points = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
val after = count + pipValue(choice.card)
var value = scored * 10.0
var value = points * 10.0
// Five and twenty-one are the two counts to avoid leaving: any of
// the sixteen ten-cards takes fifteen or thirty-one off them.
if (after == 5 || after == 21) value -= 3.0
if (after == 5 || after == 21) value -= countPenalty
// Leading a five is the same present, offered first.
if (count == 0 && choice.card.rank == Rank.FIVE) value -= 4.0
if (count == 0 && choice.card.rank == Rank.FIVE) value -= leadFivePenalty
// Big cards early. Low ones are what let you keep playing at the
// awkward end of a series, where the last card is worth a point.
value + pipValue(choice.card) * 0.1
} ?: choices.first()
value += pipValue(choice.card) * 0.1
if (hard && unseen.isNotEmpty() && after < CRIBBAGE_LIMIT) {
value -= replyThreat(onTheTable + choice.card, after, unseen) * 8.0
}
choice to value
}
val ranked = scored.sortedByDescending { it.second }.map { it.first }
return profile.pick(ranked)
}
/** Average points a reply scores off [series], over the cards still unseen. */
private fun replyThreat(series: List<Card>, after: Int, unseen: List<Card>): Double {
var total = 0
var playable = 0
for (card in unseen) {
if (after + pipValue(card) > CRIBBAGE_LIMIT) continue
playable++
total += CribbageScoring.peg(series + card).sumOf { it.points }
}
// Nothing playable is a go for them, worth a point to whoever last played.
return if (playable == 0) 0.0 else total.toDouble() / unseen.size
}
private companion object {
const val CRIB_SIZE = 4
const val CRIB_SAMPLES = 60
}
}
@@ -0,0 +1,123 @@
package org.prolibertate.games.game.engine
import kotlinx.serialization.Serializable
import kotlin.random.Random
/** How well one computer seat plays. */
enum class AiLevel {
/** Makes frequent, plausible mistakes and plays without much foresight. */
EASY,
/** The game's standard computer opponent. */
MEDIUM,
/** Plays carefully: more foresight, no deliberate mistakes. */
HARD,
}
/** What the person at setup chooses for the table's computer seats. */
@Serializable
enum class AiDifficulty(val label: String) {
EASY("Easy"),
MEDIUM("Medium"),
HARD("Hard"),
/** Each computer seat gets its own level, picked at random. */
MIXED("Mixed"),
}
/**
* A computer seat's temperament. Never shown to the human players: it only
* shapes how the AI trades off the options in front of it.
*/
enum class AiStyle(
/** -1 plays safe, +1 gambles; 0 is the game's standard balance. */
val boldness: Double,
/** How much the seat cares about stopping opponents rather than building its own position. */
val spite: Double,
) {
BALANCED(0.0, 0.0),
AGGRESSIVE(0.8, 0.3),
CAUTIOUS(-0.8, 0.0),
SPOILER(0.0, 0.8),
}
/**
* One computer seat's level and style, and a source of randomness of its own.
*
* [DEFAULT] is the game's standard opponent: [AiLevel.MEDIUM], [AiStyle.BALANCED],
* and it never slips, so an AI built without a profile plays exactly as it did
* before profiles existed.
*/
class AiProfile(
val level: AiLevel = AiLevel.MEDIUM,
val style: AiStyle = AiStyle.BALANCED,
val random: Random = Random(0),
) {
/**
* Whether this seat should throw away its best option this time. Only
* [AiLevel.EASY] does, and only some of the time, so it still plays like a
* person who is not concentrating rather than like a random-move generator.
*/
fun slips(): Boolean = level == AiLevel.EASY && random.nextDouble() < EASY_SLIP_CHANCE
/**
* Picks from [ranked] (best first). Easy sometimes settles for a worse choice,
* drawn from the better half so it stays plausible; other levels take the best.
*/
fun <T> pick(ranked: List<T>): T {
require(ranked.isNotEmpty()) { "Nothing to pick from" }
if (!slips() || ranked.size == 1) return ranked.first()
val pool = ranked.take(maxOf(2, (ranked.size + 1) / 2))
return pool[random.nextInt(pool.size)]
}
/** [base] nudged by this seat's boldness: a gambler raises it, a cautious seat lowers it. */
fun bolder(base: Double, strength: Double = 1.0): Double = base + style.boldness * strength
companion object {
const val EASY_SLIP_CHANCE = 0.3
val DEFAULT = AiProfile()
}
}
/**
* The profile for every computer seat at a table, worked out from the table's
* difficulty setting and its seed alone.
*
* Derived rather than stored, so a saved game, a rematch from the same config
* and a guest's copy of a hosted table all give the same seat the same
* personality without it travelling anywhere.
*/
class AiProfiles(
private val difficulty: AiDifficulty,
private val seed: Long,
) {
private val cache = HashMap<Int, AiProfile>()
constructor(config: TableConfig) : this(config.aiDifficulty, config.seed)
@Synchronized
fun of(seat: Int): AiProfile = cache.getOrPut(seat) {
val picker = Random(seed * 31 + seat * 7919L + 17)
val level = when (difficulty) {
AiDifficulty.EASY -> AiLevel.EASY
AiDifficulty.MEDIUM -> AiLevel.MEDIUM
AiDifficulty.HARD -> AiLevel.HARD
AiDifficulty.MIXED -> AiLevel.entries[picker.nextInt(AiLevel.entries.size)]
}
// The standard opponent is balanced by definition; the others each get a
// temperament, so no two computer seats feel alike.
val style = if (difficulty == AiDifficulty.MEDIUM) {
AiStyle.BALANCED
} else {
AiStyle.entries[picker.nextInt(AiStyle.entries.size)]
}
AiProfile(level, style, Random(picker.nextLong()))
}
companion object {
/** Standard opponents, for a caller that has no table to ask. */
val DEFAULT = AiProfiles(AiDifficulty.MEDIUM, 0L)
}
}
@@ -43,6 +43,8 @@ data class TableConfig(
val optionsJson: String,
/** Shared shuffle seed so a hand is reproducible from its state. */
val seed: Long,
/** How hard the computer seats play. Old saves and old hosts omit it and get the standard opponent. */
val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
)
/**
@@ -0,0 +1,101 @@
package org.prolibertate.games.game.engine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
/**
* A game in progress, kept so it can be picked up again after the app has been
* closed.
*
* The match is stored the way the host holds it: the [config] it was dealt from
* (seed, seats, options) and the whole authoritative [stateJson], hidden cards
* included, as the game's own rules encode it. Nothing here knows what game it
* is; [gameId] is only the name of the slot.
*/
@Serializable
data class SavedMatch(
val gameId: String,
val config: TableConfig,
/** The seat the person at this device plays. */
val localSeat: Int,
val stateJson: String,
/** Wall-clock milliseconds when the position was written. */
val savedAtMillis: Long,
/** A line saying how the game stands, from the rules' own summary. */
val summary: String = "",
)
/**
* One saved game per game, kept as a file each under [directory].
*
* One slot per game rather than a list: the player is mid-way through a game of
* Chess or they are not. Starting a new one replaces it, which is what the
* resume prompt on the setup screen says.
*
* Writes are atomic — the position goes to a temporary file that is then moved
* over the real one — so being killed half way through a save leaves the
* previous position, not half of the new one. Reads never throw: a file that
* will not parse is a game that cannot be resumed, so it is removed and reported
* as absent.
*/
class SavedGameStore(private val directory: File) {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private fun fileFor(gameId: String): File {
// The ids are catalogue constants, but they become file names, so
// nothing that could climb out of the directory is let through.
require(gameId.isNotBlank() && gameId.all { it.isLetterOrDigit() || it == '_' || it == '-' }) {
"Not a game id: $gameId"
}
return File(directory, "$gameId.json")
}
/** Writes [match] over whatever was saved for its game. */
@Synchronized
fun save(match: SavedMatch) {
directory.mkdirs()
val target = fileFor(match.gameId)
val temp = File(directory, "${match.gameId}.json.tmp")
temp.writeText(json.encodeToString(SavedMatch.serializer(), match))
if (!temp.renameTo(target)) {
// Some file systems will not rename over an existing file.
target.delete()
check(temp.renameTo(target)) { "Could not write the saved game" }
}
}
/** The saved game for [gameId], or null if there is none or it cannot be read. */
@Synchronized
fun load(gameId: String): SavedMatch? {
val file = fileFor(gameId)
if (!file.isFile) return null
val match = runCatching {
json.decodeFromString(SavedMatch.serializer(), file.readText())
}.getOrNull()
if (match == null || match.gameId != gameId) {
file.delete()
return null
}
return match
}
/** Forgets the saved game for [gameId]. A no-op if there is none. */
@Synchronized
fun clear(gameId: String) {
fileFor(gameId).delete()
}
/** Every readable saved game, the most recently played first. */
@Synchronized
fun all(): List<SavedMatch> {
val files = directory.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
return files
.mapNotNull { file -> runCatching { load(file.name.removeSuffix(".json")) }.getOrNull() }
.sortedByDescending { it.savedAtMillis }
}
}
@@ -3,6 +3,10 @@ package org.prolibertate.games.game.euchre
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -13,7 +17,7 @@ import org.prolibertate.games.game.engine.GameAi
* That is enough to be a reasonable table opponent without pretending to be a
* solver.
*/
class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class EuchreAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<EuchreState, EuchreMove> {
/**
@@ -22,25 +26,50 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
* means every seat bids and the card is never turned down — see
* EuchreBiddingRateTest, which pins the resulting order-up rate.
*/
enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) {
/** Bids loosely and plays straightforwardly. */
EASY(bidThreshold = 21, aloneThreshold = 32),
private companion object {
const val BID_THRESHOLD = 24.0
const val ALONE_THRESHOLD = 32.0
}
NORMAL(bidThreshold = 24, aloneThreshold = 32),
/** Cards this AI has watched played in the current hand, per seat it plays. */
private val memory = HashMap<Int, SeenCards>()
/** Bids tighter and is harder to sneak a trick past. */
CAUTIOUS(bidThreshold = 27, aloneThreshold = 31),
private class SeenCards {
var key: Long = Long.MIN_VALUE
val cards = HashSet<Card>()
}
/** Public information only: cards that have appeared on the table this hand. */
private fun observe(state: EuchreState, seat: Int): Set<Card> = synchronized(memory) {
val seen = memory.getOrPut(seat) { SeenCards() }
val key = state.seed * 1_000_003L + state.handNumber
if (key != seen.key || state.phase == EuchrePhase.BID_ROUND_1) {
seen.key = key
seen.cards.clear()
}
state.trick.forEach { seen.cards.add(it.card) }
state.completedTrick.forEach { seen.cards.add(it.card) }
state.turnedDown?.let { seen.cards.add(it) }
seen.cards.removeAll(state.hands[seat].toSet())
seen.cards.toSet()
}
override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal)
EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal)
EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal)
EuchrePhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
val profile = profiles.of(seat)
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
val move = try {
when (state.phase) {
EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal, profile)
EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal, profile)
EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal, profile)
EuchrePhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
else -> legal.first()
}
} catch (e: RuntimeException) {
legal.first()
}
return if (move in legal) move else legal.first()
}
// -----------------------------------------------------------------------
@@ -75,27 +104,61 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return value
}
/** The five-card hand left after the dealer picks up [upCard] and discards the least useful card. */
private fun bestAfterPickup(hand: List<Card>, upCard: Card, trump: Suit): Int {
val six = hand + upCard
return six.maxOf { dropped -> handValue(six - dropped, trump) }
}
/** A hand worth playing without a partner: real trump length with a bower to lead it. */
private fun soundAlone(hand: List<Card>, trump: Suit): Boolean {
val trumps = hand.count { isTrumpCard(it, trump) }
val bower = hand.any { isRightBower(it, trump) || isLeftBower(it, trump) }
return trumps >= 3 && bower
}
/** Easy misjudges how good its hand is. */
private fun misjudge(value: Int, profile: AiProfile): Int =
if (profile.level == AiLevel.EASY) value + profile.random.nextInt(-5, 6) else value
private fun chooseBidRound1(
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
profile: AiProfile,
): EuchreMove {
val upCard = state.upCard ?: return Pass
val trump = upCard.suit
var hand = state.hands[seat]
val isDealer = seat == state.dealer
val partnerDealer = partnerOf(seat) == state.dealer
val hard = profile.level == AiLevel.HARD
// If our partner is the dealer they gain the turn card, and if we are
// the dealer we gain it ourselves — both make the hand stronger than it
// looks. Ordering up into an opponent dealer instead hands them a card.
if (seat == state.dealer) hand = hand + upCard
var value = handValue(hand, trump)
if (partnerOf(seat) == state.dealer) value += 2
if (seat != state.dealer && partnerOf(seat) != state.dealer) value -= 2
val owned = if (isDealer) state.hands[seat] + upCard else state.hands[seat]
var value = when {
isDealer && hard -> bestAfterPickup(state.hands[seat], upCard, trump) + 3
else -> handValue(owned, trump)
}
if (partnerDealer) value += 2
if (!isDealer && !partnerDealer) {
value -= 2
// An opposing dealer who picks up a jack gets a bower out of our bid.
if (hard && upCard.rank == Rank.JACK) value -= 2
}
value = misjudge(value, profile)
val bidThreshold = profile.bolder(BID_THRESHOLD, -3.0)
var aloneThreshold = profile.bolder(ALONE_THRESHOLD, -3.0)
if (profile.level == AiLevel.EASY) aloneThreshold += 4.0
val alone = OrderUp(alone = true)
if (value >= difficulty.aloneThreshold && legal.contains(alone)) return alone
if (value >= aloneThreshold && legal.contains(alone) && (!hard || soundAlone(owned, trump))) {
return alone
}
val order = OrderUp(alone = false)
if (value >= difficulty.bidThreshold && legal.contains(order)) return order
if (value >= bidThreshold && legal.contains(order)) return order
return if (legal.contains(Pass)) Pass else legal.first()
}
@@ -103,37 +166,64 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
profile: AiProfile,
): EuchreMove {
val hand = state.hands[seat]
val callable = legal.filterIsInstance<CallTrump>()
if (callable.isEmpty()) return legal.first()
val plain = callable.filter { !it.alone }
val best = callable
.filter { !it.alone }
.maxByOrNull { handValue(hand, it.suit) }
?: callable.first()
val bestValue = handValue(hand, best.suit)
val best: CallTrump
val bestValue: Int
if (profile.level == AiLevel.EASY) {
val noisy = plain.associateWith { handValue(hand, it.suit) + profile.random.nextInt(-5, 6) }
val ranked = plain.sortedByDescending { noisy.getValue(it) }
best = if (ranked.isEmpty()) callable.first() else profile.pick(ranked)
bestValue = noisy[best] ?: handValue(hand, best.suit)
} else {
best = plain.maxByOrNull { handValue(hand, it.suit) } ?: callable.first()
bestValue = handValue(hand, best.suit)
}
val bidThreshold = profile.bolder(BID_THRESHOLD, -3.0)
var aloneThreshold = profile.bolder(ALONE_THRESHOLD, -3.0)
if (profile.level == AiLevel.EASY) aloneThreshold += 4.0
val alone = callable.firstOrNull { it.alone && it.suit == best.suit }
if (alone != null && bestValue >= difficulty.aloneThreshold) return alone
if (bestValue >= difficulty.bidThreshold) return best
val alonePlausible = profile.level != AiLevel.HARD || soundAlone(hand, best.suit)
if (alone != null && bestValue >= aloneThreshold && alonePlausible) return alone
if (bestValue >= bidThreshold) return best
// Stuck as dealer with a bad hand: name the least-bad suit anyway.
return if (legal.contains(Pass)) Pass else best
}
private fun chooseDiscard(state: EuchreState, legal: List<EuchreMove>): EuchreMove {
private fun chooseDiscard(
state: EuchreState,
legal: List<EuchreMove>,
profile: AiProfile,
): EuchreMove {
val trump = requireNotNull(state.trump)
val discards = legal.filterIsInstance<Discard>()
// Prefer to void a side suit entirely; otherwise throw the lowest card.
val hand = state.hands[state.dealer]
val suitCounts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount()
return discards.minByOrNull { discard ->
fun keepScore(discard: Discard): Int {
val card = discard.card
val suit = effectiveSuit(card, trump)
val trumpPenalty = if (suit == trump) 1000 else 0
val singletonBonus = if (suitCounts[suit] == 1 && card.rank != Rank.ACE) -20 else 0
trumpPenalty + singletonBonus + card.rank.order
} ?: discards.first()
return trumpPenalty + singletonBonus + card.rank.order
}
return when (profile.level) {
AiLevel.HARD -> discards
.sortedWith(
compareByDescending<Discard> { handValue(hand - it.card, trump) }
.thenBy { keepScore(it) },
)
.first()
AiLevel.EASY -> profile.pick(discards.sortedBy { keepScore(it) })
AiLevel.MEDIUM -> discards.minByOrNull { keepScore(it) } ?: discards.first()
}
}
// -----------------------------------------------------------------------
@@ -144,12 +234,40 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
profile: AiProfile,
seen: Set<Card>,
): EuchreMove {
val trump = requireNotNull(state.trump)
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, trump))
val maker = state.maker
val defending = maker != null && teamOf(maker) != teamOf(seat)
val makerSide = maker != null && teamOf(maker) == teamOf(seat)
// A spoiler on the defence leads trump to cut the maker's trump down.
val spiteful = defending && profile.style.spite >= 0.5
val best = if (profile.level == AiLevel.HARD) {
hardPlay(state, seat, cards, trump, seen, makerSide, spiteful)
} else {
standardPlay(state, seat, cards, trump, spiteful, profile)
}
if (profile.level != AiLevel.EASY) return PlayCard(best)
// Easy sometimes burns a high card it should have kept.
val ranked = listOf(best) + (cards - best).sortedByDescending { trumpStrength(it, trump) }
return PlayCard(profile.pick(ranked))
}
private fun standardPlay(
state: EuchreState,
seat: Int,
cards: List<Card>,
trump: Suit,
spiteful: Boolean,
profile: AiProfile,
): Card {
if (state.trick.isEmpty()) return chooseLead(cards, trump, spiteful)
val ledSuit = effectiveSuit(state.trick.first().card, trump)
val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
@@ -157,22 +275,120 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
val lastToPlay = state.trick.size == state.activeSeatCount - 1
// Partner already has it and we are last: never waste a good card.
if (partnerWinning && lastToPlay) return PlayCard(lowest(cards, trump, ledSuit))
// (Easy sometimes forgets and overtakes anyway.)
if (partnerWinning && lastToPlay && !profile.slips()) {
return lowest(cards, trump, ledSuit)
}
val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit)
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
return when {
// Take it as cheaply as we can.
winners.isNotEmpty() && (!partnerWinning || !lastToPlay) ->
PlayCard(winners.minBy { trickStrength(it, trump, ledSuit) })
winners.isNotEmpty() && (!partnerWinning || !lastToPlay || profile.level == AiLevel.EASY) ->
winners.minBy { trickStrength(it, trump, ledSuit) }
else -> PlayCard(lowest(cards, trump, ledSuit))
else -> lowest(cards, trump, ledSuit)
}
}
private fun chooseLead(cards: List<Card>, trump: Suit): Card {
/**
* The strong player's choice. It remembers which cards have gone, so it
* knows when a card is the best one left, and it lets a partner who is
* already safe take the trick.
*/
private fun hardPlay(
state: EuchreState,
seat: Int,
cards: List<Card>,
trump: Suit,
seen: Set<Card>,
makerSide: Boolean,
spiteful: Boolean,
): Card {
val hand = state.hands[seat]
// Cards that could still be in somebody else's hand.
val unseen = Decks.euchre(state.options.deckSize).filter { it !in seen && it !in hand }
val unseenTrump = unseen.count { isTrumpCard(it, trump) }
/** No unseen card of its suit beats it; trump is ignored for a side-suit card. */
fun isBoss(card: Card): Boolean {
val led = effectiveSuit(card, trump)
val mine = trickStrength(card, trump, led)
val cardIsTrump = isTrumpCard(card, trump)
return unseen.none { other ->
(cardIsTrump || !isTrumpCard(other, trump)) && trickStrength(other, trump, led) > mine
}
}
if (state.trick.isEmpty()) {
val myTrump = cards.filter { isTrumpCard(it, trump) }
val off = cards.filter { !isTrumpCard(it, trump) }
val bossTrump = myTrump.filter { isBoss(it) }.maxByOrNull { trumpStrength(it, trump) }
if (unseenTrump > 0 && bossTrump != null && (makerSide || myTrump.size >= 2)) return bossTrump
if (unseenTrump > 0 && makerSide && myTrump.size >= 3) {
return myTrump.maxBy { trumpStrength(it, trump) }
}
if (unseenTrump > 0 && spiteful && myTrump.size >= 2) {
return myTrump.maxBy { trumpStrength(it, trump) }
}
val bossOff = off.filter { isBoss(it) }.maxByOrNull { it.rank.order }
if (bossOff != null) return bossOff
if (bossTrump != null) return bossTrump
if (off.isEmpty()) return cards.maxBy { trumpStrength(it, trump) }
if (makerSide) return chooseLead(cards, trump, false)
// On the defence with nothing sure: lead low from the shortest suit,
// which keeps the aces and sets up a ruff.
val counts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount()
return off.minWith(
compareBy<Card>({ counts[effectiveSuit(it, trump)] ?: 0 }, { it.rank.order }),
)
}
val ledSuit = effectiveSuit(state.trick.first().card, trump)
val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
val partnerWinning = partnerOf(bestSoFar.seat) == seat
val remaining = state.activeSeatCount - state.trick.size - 1
val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit)
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
fun safe(card: Card): Boolean =
remaining == 0 ||
unseen.none { trickStrength(it, trump, ledSuit) > trickStrength(card, trump, ledSuit) }
fun throwAway(): Card {
// A card that neither follows nor trumps costs nothing; shed the
// lowest one that is not a sure winner later.
val junk = cards.filter { trickStrength(it, trump, ledSuit) == 0 }
if (junk.isNotEmpty()) {
return junk.minBy { it.rank.order + if (isBoss(it)) 100 else 0 }
}
return lowest(cards, trump, ledSuit)
}
if (partnerWinning) {
val partnerSecure = remaining == 0 ||
unseen.none { trickStrength(it, trump, ledSuit) > bestStrength }
if (partnerSecure) return throwAway()
// Partner may yet be beaten: back them with a side-suit card, but do
// not spend trump on top of their lead.
val backing = winners.filter { !isTrumpCard(it, trump) }
.minByOrNull { trickStrength(it, trump, ledSuit) }
return backing ?: throwAway()
}
if (winners.isNotEmpty()) {
val sure = winners.filter { safe(it) }
return (sure.ifEmpty { winners }).minBy { trickStrength(it, trump, ledSuit) }
}
return throwAway()
}
private fun chooseLead(cards: List<Card>, trump: Suit, spiteTrump: Boolean): Card {
// Right bower up front pulls trump and protects the left.
cards.firstOrNull { isRightBower(it, trump) }?.let { return it }
if (spiteTrump) {
val trumps = cards.filter { isTrumpCard(it, trump) }
if (trumps.size >= 2) return trumps.maxBy { trumpStrength(it, trump) }
}
// Off-suit aces are most likely to survive early.
cards.filter { !isTrumpCard(it, trump) && it.rank == Rank.ACE }
.maxByOrNull { it.rank.order }
@@ -1,49 +1,78 @@
package org.prolibertate.games.game.golf
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Golf opponent.
* A Golf opponent whose strength and temperament come from the seat's profile.
*
* It takes a card off the discard pile when it is cheap or completes a column,
* replaces its worst known card, and turns cards over when it has nothing
* better to do. It never peeks at a face-down card — including its own, which
* it genuinely does not know.
* MEDIUM (the default) takes a card off the discard pile when it is cheap or
* completes a column, replaces its worst known card, and turns cards over when
* it has nothing better to do. It never peeks at a face-down card — including
* its own, which it genuinely does not know.
*
* EASY is pickier about the discard pile, closes the hole from a poor position
* and sometimes makes a worse play than it saw. HARD keeps count of every card
* it has seen (turned-over grids and the discard pile), values its unknown
* cards at what is still unseen, and chooses each draw and placement by how
* much it lowers its expected score, including the chance of completing a
* column and whether closing the hole would leave it ahead.
*/
class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class GolfAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<GolfState, GolfMove> {
enum class Difficulty(
/** The tuning the standard plan runs on. */
private class Params(
/** Take a discard worth this or less rather than gambling on the stock. */
val discardAppetite: Int,
/** Only close the hole out when the visible score is at or under this. */
val closeOutBelow: Int,
) {
EASY(discardAppetite = 2, closeOutBelow = 30),
NORMAL(discardAppetite = 5, closeOutBelow = 16),
CAUTIOUS(discardAppetite = 7, closeOutBelow = 12),
}
)
override fun chooseMove(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
GolfPhase.SETUP -> chooseReveal(state, seat, legal)
GolfPhase.DRAW -> chooseDraw(state, seat, legal)
GolfPhase.PLACE -> choosePlace(state, seat, legal)
val profile = profiles.of(seat)
val move = when (state.phase) {
GolfPhase.SETUP -> chooseReveal(state, seat, legal, profile)
GolfPhase.DRAW -> chooseDraw(state, seat, legal, profile)
GolfPhase.PLACE -> choosePlace(state, seat, legal, profile)
else -> legal.first()
}
return if (move in legal) move else legal.first()
}
private fun paramsFor(level: AiLevel): Params = when (level) {
AiLevel.EASY -> EASY
else -> NORMAL
}
// -----------------------------------------------------------------------
// Setup
// -----------------------------------------------------------------------
/**
* Opening reveals are spread across columns. Two cards in the same column
* tell you whether that column can be cancelled but nothing about the rest
* of the board; one in each column leaves more of the grid legible.
*/
private fun chooseReveal(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
private fun chooseReveal(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
val reveals = legal.filterIsInstance<RevealCard>()
if (reveals.isEmpty()) return legal.first()
val options = state.options
// A distracted player turns over whatever is nearest.
if (profile.slips()) return reveals[profile.random.nextInt(reveals.size)]
val untouchedColumn = reveals.firstOrNull { move ->
val column = move.index % options.cols
(0 until options.rows).none { row ->
@@ -53,17 +82,61 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return untouchedColumn ?: reveals.first()
}
private fun chooseDraw(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
// -----------------------------------------------------------------------
// Drawing
// -----------------------------------------------------------------------
private fun chooseDraw(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
if (profile.level == AiLevel.HARD) return hardDraw(state, seat, legal, profile)
val params = paramsFor(profile.level)
val top = state.discard.lastOrNull()
var take = false
if (top != null && legal.contains(DrawFromDiscard)) {
val worthTaking = golfValue(top) <= difficulty.discardAppetite ||
take = golfValue(top) <= params.discardAppetite ||
completesColumn(state, seat, top) != null
if (worthTaking) return DrawFromDiscard
}
// Easy sometimes flips its own decision.
if (profile.slips() && legal.contains(DrawFromDiscard) && legal.contains(DrawFromStock)) take = !take
if (take && legal.contains(DrawFromDiscard)) return DrawFromDiscard
return if (legal.contains(DrawFromStock)) DrawFromStock else legal.first()
}
private fun choosePlace(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
// -----------------------------------------------------------------------
// Placing
// -----------------------------------------------------------------------
private fun choosePlace(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
if (profile.level == AiLevel.HARD) return hardPlace(state, seat, legal, profile)
val best = standardPlace(state, seat, legal, paramsFor(profile.level))
if (profile.level != AiLevel.EASY || !profile.slips()) return best
// A lapse: turn something over or cover a face-down card instead.
val alternatives = buildList<GolfMove> {
addAll(legal.filterIsInstance<DiscardAndFlip>())
addAll(legal.filterIsInstance<ReplaceCard>().filter { !state.revealed[seat][it.index] })
}.filter { it != best }
if (alternatives.isEmpty()) return best
return alternatives[profile.random.nextInt(alternatives.size)]
}
private fun standardPlace(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
params: Params,
): GolfMove {
val drawn = state.drawn ?: return legal.first()
val replacements = legal.filterIsInstance<ReplaceCard>()
val flips = legal.filterIsInstance<DiscardAndFlip>()
@@ -90,7 +163,7 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
val showing = state.grids[seat].indices
.filter { state.revealed[seat][it] }
.sumOf { golfValue(state.grids[seat][it]) }
if (showing > difficulty.closeOutBelow) return DiscardOnly
if (showing > params.closeOutBelow) return DiscardOnly
}
// Nothing worth swapping: throw it and turn something over instead,
@@ -106,7 +179,7 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
private fun completesColumn(
state: GolfState,
seat: Int,
card: org.prolibertate.games.game.cards.Card,
card: Card,
): Int? {
val options = state.options
for (col in 0 until options.cols) {
@@ -122,4 +195,229 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
}
return null
}
// -----------------------------------------------------------------------
// Hard
// -----------------------------------------------------------------------
/**
* What this seat knows about the hole: the cards in every turned-over cell,
* the discard pile and (on its own turn) the card in its hand. Everything
* else — face-down cells and the stock — is valued at the average of the
* cards not yet seen.
*/
private class Board(private val state: GolfState, seat: Int) {
val options = state.options
private val counts = HashMap<Rank, Int>()
private val unseenTotal: Int
val unknownValue: Double
private val lines: List<List<Int>>
init {
val copies = if (options.playerCount * options.gridSize > 24) 2 else 1
Rank.standard.forEach { counts[it] = 4 * copies }
fun see(card: Card) {
counts[card.rank] = maxOf(0, (counts[card.rank] ?: 0) - 1)
}
state.grids.forEachIndexed { player, grid ->
grid.forEachIndexed { index, card -> if (state.revealed[player][index]) see(card) }
}
state.discard.forEach { see(it) }
if (seat == state.turn) state.drawn?.let { see(it) }
unseenTotal = counts.values.sum()
unknownValue = if (unseenTotal == 0) {
5.0
} else {
counts.entries.sumOf { (rank, n) -> n * valueOf(rank).toDouble() } / unseenTotal
}
val columns = (0 until options.cols).map { col -> (0 until options.rows).map { it * options.cols + col } }
val rows = if (options.rows == 3) {
(0 until options.rows).map { row -> (0 until options.cols).map { row * options.cols + it } }
} else {
emptyList()
}
lines = columns + rows
}
fun valueOf(rank: Rank): Int = golfValue(Card(rank, Suit.SPADES))
private fun chanceOf(rank: Rank): Double =
if (unseenTotal == 0) 0.0 else (counts[rank] ?: 0).toDouble() / unseenTotal
/** A seat's cells as far as this seat can see them; null is a face-down card. */
fun cellsOf(player: Int): List<Card?> =
state.grids[player].mapIndexed { index, card -> if (state.revealed[player][index]) card else null }
/** Expected score of a grid, counting the chance that an unknown cell completes a line. */
fun expected(cells: List<Card?>): Double {
val cancelled = BooleanArray(cells.size)
for (line in lines) {
val line0 = line.map { cells[it] }
if (line0.all { it != null } && line0.map { it!!.rank }.distinct().size == 1) {
line.forEach { cancelled[it] = true }
}
}
var total = 0.0
for (index in cells.indices) {
if (cancelled[index]) continue
val card = cells[index]
total += if (card != null) golfValue(card).toDouble() else unknownValue
}
for (line in lines) {
if (line.any { cancelled[it] }) continue
val unknown = line.filter { cells[it] == null }
if (unknown.size != 1) continue
val known = line.mapNotNull { cells[it] }
if (known.isEmpty() || known.map { it.rank }.distinct().size != 1) continue
val rank = known.first().rank
total -= chanceOf(rank) * (known.sumOf { golfValue(it) } + valueOf(rank))
}
return total
}
/** Unseen ranks and how many of each remain, for averaging over a stock draw. */
fun unseenRanks(): List<Pair<Rank, Int>> = counts.entries.filter { it.value > 0 }.map { it.key to it.value }
/** How likely turning over [index] is to finish a line that already shows one rank. */
fun matchHope(cells: List<Card?>, index: Int): Double {
var best = 0.0
for (line in lines) {
if (index !in line) continue
val others = line.filter { it != index }
val known = others.mapNotNull { cells[it] }
if (known.size != others.size || known.isEmpty()) continue
if (known.map { it.rank }.distinct().size != 1) continue
val rank = known.first().rank
best = maxOf(best, chanceOf(rank) * (known.sumOf { golfValue(it) } + valueOf(rank)))
}
return best
}
}
private fun effectiveBoldness(state: GolfState, seat: Int, profile: AiProfile): Double {
// Well behind late in the match: take more chances.
val holesLeft = state.options.holes - state.hole - 1
val best = (0 until state.options.playerCount).filter { it != seat }.minOf { state.scores[it] }
val behind = state.scores[seat] - best
return profile.style.boldness + if (holesLeft <= 2 && behind > 8) 0.3 else 0.0
}
/** Scores one way of using the card in hand: lower grid score, more information, less risk. */
private class Planner(
val state: GolfState,
val seat: Int,
val board: Board,
val boldness: Double,
val spite: Double,
) {
private val cells = board.cellsOf(seat)
private val before = board.expected(cells)
private val hidden = cells.count { it == null }
private val finalTurn = state.closedBy != null
private val rivalBest: Double = (0 until state.options.playerCount)
.filter { it != seat }
.minOfOrNull { board.expected(board.cellsOf(it)) } ?: before
private fun closePenalty(after: Double): Double {
if (finalTurn) return 0.0
val margin = 2.0 - 2.0 * boldness
return maxOf(0.0, after - (rivalBest - margin)) * 0.8
}
private fun handoff(drawn: Card): Double =
maxOf(0.0, 3.0 - golfValue(drawn)) * 0.15 * (1.0 + spite)
private fun flipBonus(index: Int): Double =
if (finalTurn || hidden <= 1) 0.0 else 0.35 + 0.5 * board.matchHope(cells, index)
/** Utility of [move] with [drawn] in hand; higher is better. */
fun utility(move: GolfMove, drawn: Card): Double = when (move) {
is ReplaceCard -> {
val after = cells.toMutableList().also { it[move.index] = drawn }
val closes = hidden == 1 && cells[move.index] == null
val afterScore = board.expected(after)
before - afterScore - if (closes) closePenalty(afterScore) else 0.0
}
is DiscardAndFlip -> {
val closes = hidden == 1
flipBonus(move.index) - handoff(drawn) - if (closes) closePenalty(before) else 0.0
}
is DiscardOnly -> -handoff(drawn)
else -> Double.NEGATIVE_INFINITY
}
/** The moves open to a card in hand, standing in for the legal list during look-ahead. */
fun candidates(fromDiscard: Boolean, putt: Boolean): List<GolfMove> = buildList {
for (index in cells.indices) add(ReplaceCard(index))
if (!fromDiscard) {
for (index in cells.indices) if (cells[index] == null) add(DiscardAndFlip(index))
if (putt && hidden == 1) add(DiscardOnly)
}
}
}
private fun planner(state: GolfState, seat: Int, profile: AiProfile): Planner {
val board = Board(state, seat)
return Planner(state, seat, board, effectiveBoldness(state, seat, profile), profile.style.spite)
}
private fun hardDraw(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
val top = state.discard.lastOrNull()
val canStock = legal.contains(DrawFromStock)
val canDiscard = legal.contains(DrawFromDiscard) && top != null
if (!canDiscard) return if (canStock) DrawFromStock else legal.first()
if (!canStock) return DrawFromDiscard
val plan = planner(state, seat, profile)
val putt = state.options.lineUpFinalPutt
// The discard has to be used, so it is only as good as its best replacement.
val takeUtility = plan.candidates(fromDiscard = true, putt = putt)
.maxOf { plan.utility(it, top!!) } +
profile.style.spite * 0.3 * maxOf(0, 4 - golfValue(top!!))
// The stock is a gamble on the average of what is left, but can be thrown back.
val ranks = plan.board.unseenRanks()
val total = ranks.sumOf { it.second }
val stockUtility = if (total == 0) {
Double.NEGATIVE_INFINITY
} else {
val moves = plan.candidates(fromDiscard = false, putt = putt)
ranks.sumOf { (rank, n) ->
val sample = Card(rank, Suit.SPADES)
n * moves.maxOf { plan.utility(it, sample) }
} / total
}
// A bold seat wants a clear edge before it passes up the unknown card.
val take = takeUtility - plan.boldness * 0.4 > stockUtility
return if (take) DrawFromDiscard else DrawFromStock
}
private fun hardPlace(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
val drawn = state.drawn ?: return legal.first()
val plan = planner(state, seat, profile)
val options = legal.filter { it is ReplaceCard || it is DiscardAndFlip || it is DiscardOnly }
if (options.isEmpty()) return legal.first()
return options.maxBy { plan.utility(it, drawn) }
}
private companion object {
private val EASY = Params(discardAppetite = 2, closeOutBelow = 30)
private val NORMAL = Params(discardAppetite = 5, closeOutBelow = 16)
}
}
@@ -3,35 +3,98 @@ package org.prolibertate.games.game.hearts
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
* A cautious Hearts player.
* A Hearts player whose strength and temperament come from the seat's profile.
*
* It plays to avoid points rather than to shoot the moon: it sheds the queen of
* spades and the guards above her at the first opportunity, ducks under whoever
* is winning a trick when it can, and takes a trick as cheaply as possible when
* it cannot get out of the way. That is enough to make it an opponent worth
* beating without pretending to read the table.
* MEDIUM (the default) avoids points rather than shooting the moon: it sheds
* the queen of spades and the guards above her at the first opportunity, ducks
* under whoever is winning a trick when it can, and takes a trick as cheaply as
* possible when it cannot get out of the way.
*
* EASY plays the same plan but sometimes settles for a worse card in each
* decision. HARD counts the penalty cards and spades that have been taken,
* leads by risk, sheds by what is still dangerous, shoots the moon when its
* hand and the table allow it and tries to break a moon somebody else is on.
* It only uses its own hand and what the table has shown.
*/
object HeartsAi : GameAi<HeartsState, HeartsMove> {
class HeartsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<HeartsState, HeartsMove> {
override fun chooseMove(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
HeartsPhase.PASSING -> choosePass(state, seat, legal)
else -> choosePlay(state, seat, legal)
val profile = profiles.of(seat)
val move = when (state.phase) {
HeartsPhase.PASSING -> choosePass(state, seat, legal, profile)
else -> choosePlay(state, seat, legal, profile)
}
return if (move in legal) move else legal.first()
}
private fun choosePass(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
// -----------------------------------------------------------------------
// Passing
// -----------------------------------------------------------------------
private fun choosePass(
state: HeartsState,
seat: Int,
legal: List<HeartsMove>,
profile: AiProfile,
): HeartsMove {
val hand = state.hands[seat]
val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
val wanted: Set<Card> = when (profile.level) {
AiLevel.MEDIUM -> hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
AiLevel.EASY -> easyPass(hand, profile)
AiLevel.HARD -> hardPass(state, hand, profile)
}
// Prefer the exact bundle the heuristic asked for, but fall back rather
// than fail: the caller's list is what the host will validate against.
return legal.firstOrNull { it is PassCards && it.cards.toSet() == wanted } ?: legal.first()
}
private fun easyPass(hand: List<Card>, profile: AiProfile): Set<Card> {
val ranked = hand.sortedByDescending { passDanger(it, hand) }
val chosen = ranked.take(PASS_SIZE).toMutableList()
if (profile.slips()) {
// Keeps one of the cards it should have sent.
val spare = ranked.drop(PASS_SIZE).take(4)
if (spare.isNotEmpty()) {
chosen[profile.random.nextInt(chosen.size)] = spare[profile.random.nextInt(spare.size)]
}
}
return chosen.toSet()
}
private fun hardPass(state: HeartsState, hand: List<Card>, profile: AiProfile): Set<Card> {
if (state.options.allowShootTheMoon && moonHand(hand, profile)) {
// Keep every high card and heart, send the three smallest.
val junk = hand.filter { !(it.suit == Suit.HEARTS && it.rank.order >= 10) }
.sortedBy { it.rank.order }
if (junk.size >= PASS_SIZE) return junk.take(PASS_SIZE).toSet()
}
return hand.sortedByDescending { passDanger(it, hand) + shortSuitBonus(it, hand) }
.take(PASS_SIZE).toSet()
}
/** Sending away the last cards of a short suit makes a void, which is worth a lot when discarding. */
private fun shortSuitBonus(card: Card, hand: List<Card>): Int {
if (card.suit == Suit.HEARTS || card.suit == Suit.SPADES) return 0
val length = hand.count { it.suit == card.suit }
return if (length > 3) 0 else (4 - length) * 8
}
/** A hand worth trying to take all twenty-six with: lots of high cards and a heart suit to run. */
private fun moonHand(hand: List<Card>, profile: AiProfile): Boolean {
val hearts = hand.count { it.suit == Suit.HEARTS }
if (hearts < 4) return false
val strength = hand.count { it.rank.order >= Rank.QUEEN.order } +
hand.count { it.suit == Suit.HEARTS && it.rank.order >= 9 }
return strength >= profile.bolder(8.0, -2.0)
}
/**
* How much a card wants to be somebody else's problem.
*
@@ -52,27 +115,41 @@ object HeartsAi : GameAi<HeartsState, HeartsMove> {
}
}
private fun choosePlay(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
// -----------------------------------------------------------------------
// Playing
// -----------------------------------------------------------------------
private fun choosePlay(
state: HeartsState,
seat: Int,
legal: List<HeartsMove>,
profile: AiProfile,
): HeartsMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.isEmpty()) return legal.first()
val chosen = when {
state.trick.isEmpty() -> lead(cards)
else -> follow(state, cards)
val chosen = when (profile.level) {
AiLevel.MEDIUM -> standardRanking(state, cards).first()
AiLevel.EASY -> profile.pick(standardRanking(state, cards))
AiLevel.HARD -> hardPlay(state, seat, cards, profile)
}
return PlayCard(chosen)
return PlayCard(if (chosen in cards) chosen else cards.first())
}
/** The standard plan's preferences, best first. */
private fun standardRanking(state: HeartsState, cards: List<Card>): List<Card> =
if (state.trick.isEmpty()) rankedLead(cards) else rankedFollow(state, cards)
/**
* Leads low, and keeps off spades while the queen is still unaccounted for —
* leading a spade under her is how a seat ends up holding her.
*/
private fun lead(cards: List<Card>): Card {
val offSpades = cards.filter { it.suit != Suit.SPADES }
val pool = if (offSpades.isNotEmpty()) offSpades else cards
return pool.minBy { it.rank.order }
private fun rankedLead(cards: List<Card>): List<Card> {
val offSpades = cards.filter { it.suit != Suit.SPADES }.sortedBy { it.rank.order }
val spades = cards.filter { it.suit == Suit.SPADES }.sortedBy { it.rank.order }
return offSpades + spades
}
private fun follow(state: HeartsState, cards: List<Card>): Card {
private fun rankedFollow(state: HeartsState, cards: List<Card>): List<Card> {
val led = state.trick.first().card.suit
val best = state.trick.maxOf { trickStrength(it.card, led) }
val pot = pointsIn(state.trick.map { it.card })
@@ -82,27 +159,189 @@ object HeartsAi : GameAi<HeartsState, HeartsMove> {
if (ducking.isNotEmpty()) {
// Safe under the current winner, so shed the most expensive card
// that still loses.
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
val rest = (cards - ducking.toSet()).sortedBy { it.rank.order }
return ducking.sortedByDescending { it.rank.order + pointsOf(it) * 2 } + rest
}
val void = cards.none { it.suit == led }
if (void) return discard(cards)
// Every card left would take the trick. Playing last with nothing in the
// pot means it costs nothing, so there is no reason to waste a high card
// on it; otherwise take it as cheaply as possible.
return if (last && pot == 0) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
return if (last && pot == 0) {
cards.sortedByDescending { it.rank.order }
} else {
cards.sortedBy { it.rank.order }
}
}
/** Void in the led suit and free to throw anything, so throw the worst of it. */
private fun discard(cards: List<Card>): Card {
cards.firstOrNull { it == QUEEN_OF_SPADES }?.let { return it }
cards.filter { it.suit == Suit.SPADES && it.rank.order > Rank.QUEEN.order }
.maxByOrNull { it.rank.order }
?.let { return it }
cards.filter { it.suit == Suit.HEARTS }
.maxByOrNull { it.rank.order }
?.let { return it }
return cards.maxBy { it.rank.order }
// -----------------------------------------------------------------------
// Hard play
// -----------------------------------------------------------------------
/** What this seat can legitimately know: its hand, and every card taken or on the table. */
private class Memory(state: HeartsState, seat: Int) {
val hand: List<Card> = state.hands[seat]
val unseen: List<Card>
init {
val seen = HashSet<Card>()
state.taken.forEach { seen.addAll(it) }
state.trick.forEach { seen.add(it.card) }
seen.addAll(hand)
unseen = FULL_DECK.filter { it !in seen }
}
val queenUnseen: Boolean get() = QUEEN_OF_SPADES in unseen
fun unseenIn(suit: Suit): Int = unseen.count { it.suit == suit }
fun unseenHigher(card: Card): Int =
unseen.count { it.suit == card.suit && it.rank.order > card.rank.order }
}
private fun hardPlay(state: HeartsState, seat: Int, cards: List<Card>, profile: AiProfile): Card {
val mem = Memory(state, seat)
if (isShooting(state, seat, mem, profile)) return shootPlay(state, cards, mem)
return if (state.trick.isEmpty()) {
hardLead(cards, mem, profile)
} else {
hardFollow(state, seat, cards, mem, profile)
}
}
/** Holding every point so far, with a hand that can keep winning. */
private fun isShooting(state: HeartsState, seat: Int, mem: Memory, profile: AiProfile): Boolean {
if (!state.options.allowShootTheMoon) return false
val all = pointsIn(state.taken.flatten())
val mine = pointsIn(state.taken[seat])
if (mine != all) return false
if (mine == 0 && !moonHand(mem.hand, profile)) return false
if (mem.hand.isEmpty()) return false
val masters = mem.hand.count { mem.unseenHigher(it) == 0 }
return masters.toDouble() / mem.hand.size >= profile.bolder(0.6, -0.15)
}
private fun shootPlay(state: HeartsState, cards: List<Card>, mem: Memory): Card {
if (state.trick.isEmpty()) {
val masters = cards.filter { mem.unseenHigher(it) == 0 }
if (masters.isNotEmpty()) {
return masters.sortedWith(
compareBy<Card> { it.suit == Suit.HEARTS }.thenByDescending { it.rank.order },
).first()
}
return cards.maxBy { it.rank.order }
}
val led = state.trick.first().card.suit
val best = state.trick.maxOf { trickStrength(it.card, led) }
val remaining = state.options.playerCount - state.trick.size - 1
val winning = cards.filter { trickStrength(it, led) > best }
if (winning.isNotEmpty()) {
if (remaining == 0) return winning.minBy { it.rank.order }
return winning.filter { mem.unseenHigher(it) == 0 }.minByOrNull { it.rank.order }
?: winning.maxBy { it.rank.order }
}
// Cannot win this one: lose it with a card that is not a point.
return cards.minBy { pointsOf(it) * 100 + it.rank.order }
}
/** Another seat that has taken every penalty card so far and looks set to run the table. */
private fun moonThreat(state: HeartsState, seat: Int): Int? {
if (!state.options.allowShootTheMoon) return null
val all = pointsIn(state.taken.flatten())
if (all < 14 && !(state.trickNumber >= 6 && all >= 8)) return null
return (0 until state.options.playerCount).firstOrNull {
it != seat && pointsIn(state.taken[it]) == all
}
}
private fun scoreLeader(state: HeartsState, seat: Int): Int? =
(0 until state.options.playerCount).filter { it != seat }.minByOrNull { state.scores[it] }
private fun hardLead(cards: List<Card>, mem: Memory, profile: AiProfile): Card {
// A cautious seat weighs a trick it might win more heavily than a bold one.
val weight = 1.0 - 0.25 * profile.style.boldness
fun risk(card: Card): Double {
val others = mem.unseenIn(card.suit)
val higher = mem.unseenHigher(card)
val winChance = if (others == 0) 1.0 else Math.pow(1.0 - higher.toDouble() / others, 3.0)
var points = 0.5
if (card.suit == Suit.HEARTS) points += 2.0
if (card.suit == Suit.SPADES && mem.queenUnseen) points += 13 * 0.3
// A suit nobody else holds lets everyone dump on whoever wins it.
if (others == 0) points += 3.0
val shedding = if (winChance < 0.15) 0.05 * card.rank.order else 0.0
return winChance * points * weight - shedding
}
return cards.minBy { risk(it) }
}
private fun hardFollow(
state: HeartsState,
seat: Int,
cards: List<Card>,
mem: Memory,
profile: AiProfile,
): Card {
val trick = state.trick
val led = trick.first().card.suit
val winner = trick.maxBy { trickStrength(it.card, led) }
val best = trickStrength(winner.card, led)
val pot = pointsIn(trick.map { it.card })
val remaining = state.options.playerCount - trick.size - 1
val winning = cards.filter { trickStrength(it, led) > best }
val ducking = cards.filter { trickStrength(it, led) < best }
val threat = moonThreat(state, seat)
// Taking even a few points is far better than letting a moon through.
if (threat != null && pot > 0 && winning.isNotEmpty()) {
return winning.minBy { it.rank.order }
}
val void = cards.none { it.suit == led }
if (void) return hardDischarge(state, seat, cards, mem, profile, winner.seat, threat)
if (ducking.isNotEmpty()) {
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
}
// Forced to win. Last to play, the trick is taken whatever is played,
// so spend the highest card; otherwise win as cheaply as possible.
return if (remaining == 0) {
cards.maxBy { it.rank.order }
} else {
cards.minBy { it.rank.order }
}
}
/** Void in the led suit: throw away whatever is still dangerous to hold. */
private fun hardDischarge(
state: HeartsState,
seat: Int,
cards: List<Card>,
mem: Memory,
profile: AiProfile,
winnerSeat: Int,
threat: Int?,
): Card {
val spoil = profile.style.spite >= 0.5 && winnerSeat == scoreLeader(state, seat)
fun score(card: Card): Double {
var s: Double = when {
card == QUEEN_OF_SPADES -> 1000.0
else -> card.rank.order.toDouble()
}
// The ace and king of spades only matter while the queen is out there.
if (card.suit == Suit.SPADES && card.rank.order > Rank.QUEEN.order && mem.queenUnseen) s += 200
if (card.suit == Suit.HEARTS) s += 100
if (pointsOf(card) == 0 && mem.hand.count { it.suit == card.suit } <= 2) s += 10
if (spoil && pointsOf(card) > 0) s += 300
// Do not feed a seat that is running for the moon.
if (threat != null && winnerSeat == threat && pointsOf(card) > 0) s -= 2000
return s
}
return cards.maxBy { score(it) }
}
private companion object {
val FULL_DECK: List<Card> = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } }
}
}
@@ -3,6 +3,10 @@ package org.prolibertate.games.game.kaiser
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -12,25 +16,51 @@ import org.prolibertate.games.game.engine.GameAi
* in play chases the five of hearts while trying not to be the one holding the
* trick the three of spades lands in.
*/
class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class KaiserAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<KaiserState, KaiserMove> {
enum class Difficulty(
private companion object {
/** Hand strength needed before bidding above the floor. */
val bidThreshold: Int,
) {
EASY(bidThreshold = 12),
NORMAL(bidThreshold = 16),
CAUTIOUS(bidThreshold = 19),
const val BID_THRESHOLD = 16.0
const val EASY_BID_THRESHOLD = 15.0
}
/** Cards this AI has watched played in the current hand, per seat it plays. */
private val memory = HashMap<Int, SeenCards>()
private class SeenCards {
var key: Long = Long.MIN_VALUE
val cards = HashSet<Card>()
}
/** Public information only: cards that have appeared on the table this hand. */
private fun observe(state: KaiserState, seat: Int): Set<Card> = synchronized(memory) {
val seen = memory.getOrPut(seat) { SeenCards() }
val key = state.seed * 1_000_003L + state.handNumber
if (key != seen.key || state.phase == KaiserPhase.BIDDING) {
seen.key = key
seen.cards.clear()
}
state.trick.forEach { seen.cards.add(it.card) }
state.completedTrick.forEach { seen.cards.add(it.card) }
seen.cards.removeAll(state.hands[seat].toSet())
seen.cards.toSet()
}
override fun chooseMove(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
KaiserPhase.BIDDING -> chooseBid(state, seat, legal)
KaiserPhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
val profile = profiles.of(seat)
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
val move = try {
when (state.phase) {
KaiserPhase.BIDDING -> chooseBid(state, seat, legal, profile)
KaiserPhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
else -> legal.first()
}
} catch (e: RuntimeException) {
legal.first()
}
return if (move in legal) move else legal.first()
}
/** Trump length and top cards, plus a nod to holding the counters. */
@@ -50,41 +80,137 @@ class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return value
}
private fun chooseBid(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
/**
* A finer rating on the same scale as [suitValue]: it rewards a run of top
* trump, short side suits to ruff with, and only counts the five of hearts
* when the hand can protect it.
*/
private fun hardValue(hand: List<Card>, trump: Suit): Int {
val trumps = hand.filter { it.suit == trump }
var value = trumps.size * 3
for (card in trumps) {
value += when (card.rank) {
Rank.ACE -> 4
Rank.KING -> 3
Rank.QUEEN -> 1
else -> 0
}
}
// A king backed by the ace, or a queen backed by both, is a real trick.
val ranks = trumps.map { it.rank.order }.toSet()
if (Rank.ACE.order in ranks && Rank.KING.order in ranks) value += 2
if (Rank.ACE.order in ranks && Rank.KING.order in ranks && Rank.QUEEN.order in ranks) value += 1
for (card in hand) {
if (card.suit == trump) continue
if (card.rank == Rank.ACE) value += 2
else if (card.rank == Rank.KING && hand.any { it.suit == card.suit && it.rank == Rank.ACE }) value += 1
}
if (trumps.size >= 3) {
value += Suit.entries.filter { it != trump }.count { suit -> hand.none { it.suit == suit } } * 2
}
if (hand.contains(FIVE_OF_HEARTS)) {
val guarded = trump == Suit.HEARTS ||
hand.any { it.suit == Suit.HEARTS && it.rank == Rank.ACE }
value += if (guarded) 3 else -1
}
if (hand.contains(THREE_OF_SPADES)) value -= if (trump == Suit.SPADES) 1 else 2
return value
}
private fun chooseBid(
state: KaiserState,
seat: Int,
legal: List<KaiserMove>,
profile: AiProfile,
): KaiserMove {
val hand = state.hands[seat]
val hard = profile.level == AiLevel.HARD
val easy = profile.level == AiLevel.EASY
val naming = legal.filterIsInstance<NameTrump>()
if (naming.isNotEmpty()) {
val hand = state.hands[seat]
val best = naming
.filter { it.suit != null }
.maxByOrNull { suitValue(hand, it.suit!!) }
return best ?: naming.first()
val named = naming.filter { it.suit != null }
return when {
hard -> named.maxByOrNull { hardValue(hand, it.suit!!) }
easy -> {
val noisy = named.associateWith { suitValue(hand, it.suit!!) + profile.random.nextInt(-4, 5) }
named.sortedByDescending { noisy.getValue(it) }.let { if (it.isEmpty()) null else profile.pick(it) }
}
else -> named.maxByOrNull { suitValue(hand, it.suit!!) }
} ?: naming.first()
}
val hand = state.hands[seat]
val bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
val strength = suitValue(hand, bestSuit)
val bestSuit: Suit
val strength: Int
if (hard) {
bestSuit = Suit.entries.maxBy { hardValue(hand, it) }
strength = hardValue(hand, bestSuit)
} else {
bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
strength = suitValue(hand, bestSuit) + if (easy) profile.random.nextInt(-5, 6) else 0
}
val bids = legal.filterIsInstance<MakeBid>().filter { !it.bid.noTrump }
if (strength >= difficulty.bidThreshold && bids.isNotEmpty()) {
var needed = profile.bolder(if (easy) EASY_BID_THRESHOLD else BID_THRESHOLD, -4.0)
// A spoiler steals contracts from the other side.
val bidder = state.highBidder
if (bidder != null && teamOf(bidder) != teamOf(seat) && profile.style.spite >= 0.5) needed -= 2.0
if (hard && bids.isNotEmpty()) {
val cheapest = bids.minBy { it.bid.points }
// A bigger contract needs a bigger hand, and a partner who is
// already bidding is better supported than outbid.
needed += 2.0 * (cheapest.bid.points - state.options.minimumBid)
if (bidder != null && bidder == partnerOf(seat)) needed += 4.0
}
if (strength >= needed && bids.isNotEmpty()) {
// Bid the floor rather than reaching; the contract is what matters.
return bids.minBy { it.bid.points }
}
return legal.firstOrNull { it is PassBid } ?: bids.minByOrNull { it.bid.points } ?: legal.first()
}
private fun choosePlay(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
private fun choosePlay(
state: KaiserState,
seat: Int,
legal: List<KaiserMove>,
profile: AiProfile,
seen: Set<Card>,
): KaiserMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
val bidder = state.highBidder
val defending = bidder != null && teamOf(bidder) != teamOf(seat)
val spiteful = defending && profile.style.spite >= 0.5
val best = if (profile.level == AiLevel.HARD) {
hardPlay(state, seat, cards, seen, bidder != null && !defending)
} else {
standardPlay(state, seat, cards, spiteful, profile)
}
if (profile.level != AiLevel.EASY) return PlayCard(best)
// Easy sometimes throws a high card, or a counter, at the wrong time.
val ranked = listOf(best) + (cards - best).sortedByDescending { it.rank.order }
return PlayCard(profile.pick(ranked))
}
private fun standardPlay(
state: KaiserState,
seat: Int,
cards: List<Card>,
spiteful: Boolean,
profile: AiProfile,
): Card {
if (state.trick.isEmpty()) {
// Leading: pull with the top trump, else lead an ace.
val trump = state.trump
val topTrump = cards.filter { trump != null && it.suit == trump }
.maxByOrNull { it.rank.order }
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return PlayCard(topTrump)
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return topTrump
val ace = cards.filter { it.rank == Rank.ACE }.maxByOrNull { it.rank.order }
if (ace != null) return PlayCard(ace)
return PlayCard(cards.minBy { it.rank.order })
if (ace != null) return ace
return cards.minBy { it.rank.order }
}
val ledSuit = state.trick.first().card.suit
@@ -96,22 +222,129 @@ class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
// The five of hearts is worth more than the trick it rides in on.
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
// A spoiler on the defence will not leave a trick to a partner who may be beaten.
val notLast = state.trick.size < 3
val letPartner = partnerWinning && !threeOnTable &&
!(spiteful && notLast && winners.isNotEmpty()) && !profile.slips()
return when {
// Partner has it and the trick is worth taking: let them have it,
// unless the three of spades is in there.
partnerWinning && !threeOnTable -> PlayCard(cards.minBy { it.rank.order })
letPartner -> cards.minBy { it.rank.order }
fiveOnTable && winners.isNotEmpty() ->
PlayCard(winners.maxBy { trickStrength(it, state.trump, ledSuit) })
winners.maxBy { trickStrength(it, state.trump, ledSuit) }
// Nobody wants the three; duck if the trick is already lost.
threeOnTable && winners.isEmpty() -> PlayCard(cards.minBy { it.rank.order })
threeOnTable && winners.isEmpty() -> cards.minBy { it.rank.order }
winners.isNotEmpty() && !threeOnTable ->
PlayCard(winners.minBy { trickStrength(it, state.trump, ledSuit) })
winners.minBy { trickStrength(it, state.trump, ledSuit) }
else -> PlayCard(cards.minBy { it.rank.order })
else -> cards.minBy { it.rank.order }
}
}
/**
* The strong player's choice: it remembers what has been played, so it
* knows which of its cards are sure winners, feeds the five of hearts to a
* partner who is safe, and buries the three of spades under the other side.
*/
private fun hardPlay(
state: KaiserState,
seat: Int,
cards: List<Card>,
seen: Set<Card>,
bidding: Boolean,
): Card {
val trump = state.trump
val hand = state.hands[seat]
val unseen = Decks.kaiser().filter { it !in seen && it !in hand }
val counters = setOf(FIVE_OF_HEARTS, THREE_OF_SPADES)
fun isBoss(card: Card): Boolean {
val mine = trickStrength(card, trump, card.suit)
val cardIsTrump = trump != null && card.suit == trump
return unseen.none { other ->
(cardIsTrump || trump == null || other.suit != trump) &&
trickStrength(other, trump, card.suit) > mine
}
}
if (state.trick.isEmpty()) {
val plain = cards.filter { it !in counters }.ifEmpty { return cards.minBy { it.rank.order } }
val myTrump = plain.filter { trump != null && it.suit == trump }
val unseenTrump = unseen.count { trump != null && it.suit == trump }
val bossTrump = myTrump.filter { isBoss(it) }.maxByOrNull { it.rank.order }
if (bidding && unseenTrump > 0 && bossTrump != null) return bossTrump
plain.filter { it !in myTrump && isBoss(it) }.maxByOrNull { it.rank.order }?.let { return it }
if (bidding && unseenTrump > 0 && myTrump.size >= 2) return myTrump.maxBy { it.rank.order }
if (bossTrump != null) return bossTrump
val counts = hand.groupingBy { it.suit }.eachCount()
val offSuit = plain.filter { it !in myTrump }.ifEmpty { plain }
// The bidders draw a suit out; the defenders shorten one to ruff.
return if (bidding) {
offSuit.minWith(compareBy<Card>({ -(counts[it.suit] ?: 0) }, { it.rank.order }))
} else {
offSuit.minWith(compareBy<Card>({ counts[it.suit] ?: 0 }, { it.rank.order }))
}
}
val ledSuit = state.trick.first().card.suit
val best = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
val partnerWinning = partnerOf(best.seat) == seat
val remaining = 4 - state.trick.size - 1
val bestStrength = trickStrength(best.card, trump, ledSuit)
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
fun sure(card: Card): Boolean =
remaining == 0 ||
unseen.none { trickStrength(it, trump, ledSuit) > trickStrength(card, trump, ledSuit) }
fun throwAway(): Card {
val plain = cards.filter { it !in counters }
if (plain.isNotEmpty()) return plain.minBy { it.rank.order }
// Only counters left: the five belongs with partner, the three does not.
return if (partnerWinning) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
}
if (partnerWinning) {
if (sure(best.card)) {
// Partner has it: feed them the five of hearts if we can.
if (FIVE_OF_HEARTS in cards && trickStrength(FIVE_OF_HEARTS, trump, ledSuit) <= bestStrength) {
return FIVE_OF_HEARTS
}
return throwAway()
}
if (!threeOnTable) {
winners.filter { sure(it) }.minByOrNull { trickStrength(it, trump, ledSuit) }?.let { return it }
}
return throwAway()
}
// The other side is winning.
if (threeOnTable) return throwAway()
if (fiveOnTable && winners.isNotEmpty()) {
val safe = winners.filter { sure(it) }
return if (safe.isNotEmpty()) {
safe.minBy { trickStrength(it, trump, ledSuit) }
} else {
winners.maxBy { trickStrength(it, trump, ledSuit) }
}
}
// Bury the three under a trick the other side is certain to take.
if (THREE_OF_SPADES in cards &&
trickStrength(THREE_OF_SPADES, trump, ledSuit) <= bestStrength &&
sure(best.card)
) {
return THREE_OF_SPADES
}
if (winners.isNotEmpty()) {
val safe = winners.filter { sure(it) }
return (safe.ifEmpty { winners }).minBy { trickStrength(it, trump, ledSuit) }
}
return throwAway()
}
}
@@ -1,6 +1,8 @@
package org.prolibertate.games.game.mastermind
import kotlin.random.Random
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -16,9 +18,18 @@ import org.prolibertate.games.game.engine.GameAi
* - **Strong** is Knuth's idea one ply deep: among the codes still standing, it
* plays the one whose *worst* possible answer leaves the smallest number of
* candidates behind, so it cannot be given a reply that tells it little.
*
* The computer difficulty chosen at setup picks the level: Easy plays Casual,
* Medium plays whatever the table's saved [MastermindOptions.level] says (Club
* for a new game) and Hard plays Strong. [level] overrides all of that, which is
* what the tests use.
*/
class MastermindAi(private val level: MastermindLevel? = null) :
GameAi<MastermindState, MastermindMove> {
class MastermindAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: MastermindLevel? = null,
) : GameAi<MastermindState, MastermindMove> {
constructor(level: MastermindLevel?) : this(AiProfiles.DEFAULT, level)
override fun chooseMove(
state: MastermindState,
@@ -26,7 +37,12 @@ class MastermindAi(private val level: MastermindLevel? = null) :
legal: List<MastermindMove>,
): MastermindMove {
require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
val setting = level ?: state.options.level
val profile = profiles.of(seat)
val setting = level ?: when (profile.level) {
AiLevel.EASY -> MastermindLevel.CASUAL
AiLevel.MEDIUM -> state.options.level
AiLevel.HARD -> MastermindLevel.STRONG
}
// Deterministic for a given position, so a host and a client watching
// the same game agree on what the computer did.
@@ -0,0 +1,39 @@
package org.prolibertate.games.game.meowdoku
import org.prolibertate.games.game.engine.GameAi
/**
* Plays the board for the one seat.
*
* Like peg solitaire's AI, nothing normally asks it to: the single seat belongs
* to the person holding the phone. It exists because an empty seat has to be
* playable by something, and because "the computer can finish this board" is a
* property worth testing — a generated puzzle that only a human could unpack is
* a broken promise about uniqueness.
*
* It reads the answer from the state. The rules hold it, the seat is alone with
* it, and a solver that deduced its way there would be the same length as the
* hint engine in the original game without making this seat any more honest.
*/
object MeowdokuAi : GameAi<MeowdokuState, MeowdokuMove> {
override fun chooseMove(
state: MeowdokuState,
seat: Int,
legal: List<MeowdokuMove>,
): MeowdokuMove {
require(legal.isNotEmpty()) { "No move available" }
val places = legal.filterIsInstance<PlaceCat>().associateBy { it.square }
// The first cat still missing. A cross on its square is no obstacle:
// crossed squares take attempts like anything else, and a correct one
// replaces the cross.
for (row in 0 until state.side) {
if (state.cats.none { it.row == row }) {
places[Square(row, state.catCols[row])]?.let { return it }
}
}
return legal.first()
}
}
@@ -0,0 +1,371 @@
package org.prolibertate.games.game.meowdoku
import kotlin.math.abs
/**
* Builds the puzzles.
*
* A Meowdoku puzzle is an N by N board, a colouring of it into N regions, and a
* hidden answer of N cats. The three are not chosen separately: the cats come
* first, then the regions grow out from them so every region owns exactly one
* cat, then the finished board is checked to make sure it has only that one
* answer. A puzzle with two answers is a puzzle with no answer, since the second
* one cannot be deduced from what is printed.
*
* The construction is seeded and reproducible: the same seed gives the same
* board, which is what lets a saved game come back unchanged and lets a test
* speak about "the" puzzle for a seed.
*/
object MeowdokuGenerator {
/** Smallest and largest boards worth dealing; anything bigger is a wall. */
val MIN_SIDE = 4
val MAX_SIDE = 10
/**
* The answer, as the column of the cat in each row.
*
* One cat per row and one per column is already built in, since the rows are
* enumerated once each and no column is taken twice.
*/
fun placeCats(side: Int, rng: SeededRng): IntArray {
val cols = IntArray(side) { -1 }
val placed = placeCatsFrom(0, side, cols, rng)
// Every board from four up has a placement, and the search is complete,
// so failing here would mean the generator itself is broken.
check(placed) { "No way to settle $side cats on a $side board" }
return cols
}
private fun placeCatsFrom(row: Int, side: Int, cols: IntArray, rng: SeededRng): Boolean {
if (row == side) return true
for (col in rng.shuffled(cols.indices.toList())) {
if (catFitsHere(row, col, cols)) {
cols[row] = col
if (placeCatsFrom(row + 1, side, cols, rng)) return true
cols[row] = -1
}
}
return false
}
/**
* Whether a cat at [row],[col] is at peace with the cats in the rows above.
*
* No shared column, and no shared corner: a cat grooms the squares touching
* its own on all eight sides, so a neighbour one step away in any direction
* is too close.
*/
fun catFitsHere(row: Int, col: Int, cols: IntArray): Boolean {
for (r in 0 until row) {
val c = cols[r]
if (c == col) return false
if (abs(r - row) <= 1 && abs(c - col) <= 1) return false
}
return true
}
/**
* The colours of the board, as a region id per square.
*
* Each region starts as the cat it contains and grows outwards one square at
* a time. The first [strips] regions are grown as straight bars along their
* cat's row or column, alternating which way they run: a board of nothing but
* blobs reads as texture, and the bars are what give the regions a shape a
* player can reason about. The rest take the shortest border square, which
* keeps them clumped rather than trailing off into the far corner.
*/
fun growRegions(side: Int, catCols: IntArray, rng: SeededRng, strips: Int): Array<IntArray> {
val regions = Array(side) { IntArray(side) { -1 } }
val dirs = listOf(0 to 1, 0 to -1, 1 to 0, -1 to 0)
// Region i owns the cat in row i, which is where its colour starts.
for (r in 0 until side) regions[r][catCols[r]] = r
val sizes = IntArray(side) { 1 }
var unassigned = side * side - side
// The straight bars are laid first, and the rest is grown around them.
// The straight bars are laid first, and the rest is grown around them.
// Bar i runs along its cat's row when i is even, down its column when odd.
val stripRegions = rng.shuffled((0 until side).toList()).take(strips.coerceIn(0, side - 1)).toSet()
for ((n, region) in stripRegions.withIndex()) {
val r0 = region
val c0 = catCols[r0]
val extra = 1 + rng.nextInt(3)
var added = 0
if (n % 2 == 0) {
var left = c0 - 1
var right = c0 + 1
while (added < extra) {
val goLeft = rng.nextBoolean()
when {
goLeft && left >= 0 && regions[r0][left] == -1 ->
regions[r0][left--] = region
right < side && regions[r0][right] == -1 ->
regions[r0][right++] = region
left >= 0 && regions[r0][left] == -1 ->
regions[r0][left--] = region
else -> break
}
sizes[region]++
unassigned--
added++
}
} else {
var up = r0 - 1
var down = r0 + 1
while (added < extra) {
val goUp = rng.nextBoolean()
when {
goUp && up >= 0 && regions[up][c0] == -1 ->
regions[up--][c0] = region
down < side && regions[down][c0] == -1 ->
regions[down++][c0] = region
up >= 0 && regions[up][c0] == -1 ->
regions[up--][c0] = region
else -> break
}
sizes[region]++
unassigned--
added++
}
}
}
// What is left goes to whichever neighbouring region is smallest, so no
// region dies young while another sprawls.
var guard = side * side * 20
while (unassigned > 0 && guard > 0) {
guard--
for (r in 0 until side) {
for (c in 0 until side) {
if (regions[r][c] != -1) continue
var best = -1
for ((dr, dc) in dirs) {
val nr = r + dr
val nc = c + dc
if (nr !in 0 until side || nc !in 0 until side) continue
val neighbour = regions[nr][nc]
if (neighbour == -1 || neighbour in stripRegions) continue
if (best == -1 || sizes[neighbour] < sizes[best]) best = neighbour
}
if (best == -1) {
for ((dr, dc) in dirs) {
val nr = r + dr
val nc = c + dc
if (nr !in 0 until side || nc !in 0 until side) continue
val neighbour = regions[nr][nc]
if (neighbour != -1 && (best == -1 || sizes[neighbour] < sizes[best])) {
best = neighbour
}
}
}
if (best != -1) {
regions[r][c] = best
sizes[best]++
unassigned--
}
}
}
}
for (r in 0 until side) {
for (c in 0 until side) {
if (regions[r][c] == -1) regions[r][c] = 0
}
}
return regions
}
/**
* How many answers this coloured board has, up to [limit].
*
* A depth-first search with the obvious propagation: once a row is given a
* cat, the squares that cat excludes — its column, the rows beside it, and
* everything in its colour — lose their candidates. A row whose candidate
* list runs out fails immediately and the search backtracks, which is what
* makes the count finish quickly on boards of this size.
*
* Nothing about the true cats is handed to the search; it sees the colours
* and the rules and no more.
*/
fun countSolutions(side: Int, regions: Array<IntArray>, limit: Int = 2): Int {
val candidates = Array(side) { r -> (0 until side).toMutableSet() }
val colsTaken = BooleanArray(side)
val coloursTaken = BooleanArray(side)
val count = IntArray(1)
solve(0, side, regions, candidates, colsTaken, coloursTaken, count, limit)
return count[0]
}
private fun solve(
row: Int,
side: Int,
regions: Array<IntArray>,
candidates: Array<MutableSet<Int>>,
colsTaken: BooleanArray,
coloursTaken: BooleanArray,
count: IntArray,
limit: Int,
) {
if (count[0] >= limit) return
if (row == side) {
count[0]++
return
}
for (col in candidates[row].toList()) {
val colour = regions[row][col]
if (colsTaken[col] || coloursTaken[colour]) continue
val undo = exclude(row, col, side, regions, candidates)
if (undo != null) {
colsTaken[col] = true
coloursTaken[colour] = true
solve(row + 1, side, regions, candidates, colsTaken, coloursTaken, count, limit)
colsTaken[col] = false
coloursTaken[colour] = false
undo()
}
if (count[0] >= limit) return
}
}
/**
* Crosses out what the cat at [row],[col] forbids, and returns the function
* that puts it back. Returns null if some row loses its last candidate,
* which means this placement is a dead end.
*/
private fun exclude(
row: Int,
col: Int,
side: Int,
regions: Array<IntArray>,
candidates: Array<MutableSet<Int>>,
): (() -> Unit)? {
val colour = regions[row][col]
val saved = Array(side) { r -> candidates[r].toMutableSet() }
var dead = false
for (r in 0 until side) {
if (r == row) continue
candidates[r].remove(col)
if (abs(r - row) == 1) {
candidates[r].remove(col - 1)
candidates[r].remove(col + 1)
}
candidates[r].removeAll { c -> regions[r][c] == colour }
if (candidates[r].isEmpty()) dead = true
}
if (dead) {
for (r in 0 until side) candidates[r] = saved[r]
return null
}
return {
for (r in 0 until side) candidates[r] = saved[r]
}
}
/**
* A complete puzzle: the colours, the answer, and a proof it is unique.
*
* Generation is rejection rather than construction — build a board, check it,
* throw it away if the check fails — and the attempts walk forward from
* [seed] so a seed that has a puzzle within a few tries gives the same one
* every time.
*/
fun generate(side: Int, seed: Long): MeowdokuPuzzle? {
val base = (seed xor (seed ushr 32)).toInt()
for (attempt in 0 until MAX_ATTEMPTS) {
val rng = SeededRng(base + attempt * 7919)
val catCols = placeCats(side, rng)
val regions = growRegions(side, catCols, SeededRng(base * 31 + attempt * 104729), strips = side / 2)
if (distinctColours(side, regions) != side) continue
if (countSolutions(side, regions, limit = 2) != 1) continue
return MeowdokuPuzzle(side, regions, catCols)
}
return null
}
private const val MAX_ATTEMPTS = 4000
/** How many different colours a board actually shows. */
private fun distinctColours(side: Int, regions: Array<IntArray>): Int =
regions.flatMap { it.toList() }.distinct().size
}
/**
* A board and its answer.
*
* The answer is stored rather than re-derived because the rules need it to
* referee: a tap either lands a cat or loses a heart, and that is judged against
* the solution, not against whether the player could have deduced it.
*/
data class MeowdokuPuzzle(
val side: Int,
/** Region id per square; ids run 0 until side. */
val regions: Array<IntArray>,
/** The column of the cat in each row. */
val catCols: IntArray,
) {
fun regionOf(row: Int, col: Int): Int = regions[row][col]
/** Every colour id, in board order, for a legend or a palette. */
val colours: List<Int> get() = (0 until side).toList()
fun solutionSquares(): List<Pair<Int, Int>> =
catCols.indices.map { it to catCols[it] }
override fun equals(other: Any?): Boolean = other is MeowdokuPuzzle &&
side == other.side &&
regions.contentDeepEquals(other.regions) &&
catCols.contentEquals(other.catCols)
override fun hashCode(): Int = 31 * side + regions.contentDeepHashCode()
}
/**
* A 32-bit linear congruential generator.
*
* Deliberately not [kotlin.random.Random]: that one is seeded per-JVM and can
* change between releases, and a puzzle generator that drifts silently corrupts
* saved games. This one is small, explicit, and the same everywhere.
*/
class SeededRng(seed: Int) {
private var state = if (seed == 0) DEFAULT_SEED else seed
/** The next 31 bits, as a non-negative int. */
fun nextInt(): Int {
state = state * MULTIPLIER + 1
return (state ushr 1) and 0x7FFF_FFFF
}
/** A value in 0 until [bound], taken by rejection so it stays uniform. */
fun nextInt(bound: Int): Int {
require(bound > 0)
while (true) {
val n = nextInt()
// 2^31 is a multiple of no useful number of squares, so a bare
// modulus biases the low values; the slack is thrown and redrawn.
if (n < Int.MAX_VALUE - (Int.MAX_VALUE % bound)) return n % bound
}
}
fun nextBoolean(): Boolean = nextInt(2) == 0
/** The same list every time for the same seed, in a different order. */
fun <T> shuffled(items: List<T>): List<T> {
val out = items.toMutableList()
for (i in out.lastIndex downTo 1) {
val j = nextInt(i + 1)
val tmp = out[i]
out[i] = out[j]
out[j] = tmp
}
return out
}
private companion object {
// The constants of the old VAX C library's `rand`, which are well tested
// and give a period of the full 31 bits.
const val MULTIPLIER = 1103515245
const val DEFAULT_SEED = 0x5DEECE66D.toInt()
}
}
@@ -0,0 +1,119 @@
package org.prolibertate.games.game.meowdoku
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Meowdoku hides N cats in an N by N board: exactly one cat in every row, every
// column and every colour, and no two cats sharing even a corner. A tap crosses
// a square over; a second tap on that square asks the board whether a cat
// lives there. A right answer lands the cat, a wrong one costs a heart and
// keeps the cross — so the crossing over is both the player's note and the
// first half of every guess, and the hearts are what stop it being a lottery.
/** One square of the board. */
@Serializable
data class Square(val row: Int, val col: Int)
@Serializable
data class MeowdokuOptions(
/** The board is [side] by [side], and the answer is [side] cats. */
val side: Int = 6,
/**
* How many wrong taps the board forgives. The original game deals three,
* and the hearts on a level are part of its difficulty rather than a
* courtesy.
*/
val hearts: Int = 3,
/** How many cats the board will show on demand. */
val hints: Int = 3,
) {
init {
require(side in MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE) {
"No board of $side squares a side"
}
require(hearts in 1..9) { "A board with no hearts is lost before it is dealt" }
require(hints in 0..9) { "Hints must read on the board, not scroll" }
}
}
/**
* The dealt board plus the marks on it.
*
* [cats] and [marks] are sets rather than a state per square because a square's
* story is exactly those two memberships: nothing is a cat and crossed, and the
* empty squares carry no information worth storing.
*/
@Serializable
data class MeowdokuState(
val options: MeowdokuOptions,
/** The region id of every square, row by row. Ids run 0 until [side]. */
val regions: List<List<Int>>,
/** The column of the cat in each row — the answer, kept so taps can be judged. */
val catCols: List<Int>,
/** Cats the player has found (or a hint has shown). */
val cats: Set<Square>,
/** Squares crossed over: wrong taps and pencil marks alike. */
val marks: Set<Square>,
val heartsLeft: Int,
val hintsLeft: Int,
/** Wrong taps so far, which is how a solved board says how it was solved. */
val wrongCats: Int,
/** The last square touched, and whether touching it cost a heart. */
val lastSquare: Square? = null,
val lastWrong: Boolean = false,
val log: List<String>,
) {
val side: Int get() = options.side
/** Cats found, out of the [side] the board hides. */
val catsFound: Int get() = cats.size
val solved: Boolean get() = catsFound == side
/** Out of hearts, with cats still missing. */
val lost: Boolean get() = heartsLeft == 0 && !solved
fun regionOf(square: Square): Int = regions[square.row][square.col]
fun isCatSquare(square: Square): Boolean = catCols[square.row] == square.col
}
/** What the player may do to a board. */
@Serializable
sealed interface MeowdokuMove
/**
* The second tap of a double tap: the cat attempt.
*
* One move rather than two, because that is what the player is doing: the board
* decides whether the tap found a cat or cost a heart. The square may carry the
* cross its own first tap just drew — that cross is part of the double tap,
* not a decision against it — and a correct attempt replaces it.
*/
@Serializable
@SerialName("cat")
data class PlaceCat(val square: Square) : MeowdokuMove
/**
* Draws or erases a cross on a square with no cat on it.
*
* This is the first tap of a tap pair and every cell of a drag stroke, so it is
* the note-taking half of the scheme: free, unrewarded, and the board cannot
* lose a heart over it. A cross drawn here can still be tapped through with a
* cat attempt — the note is the player's, and only they get to change their
* mind about it.
*/
@Serializable
@SerialName("pencil")
data class TogglePencil(val square: Square) : MeowdokuMove
/**
* Spends a hint to show the cat in the first row that lacks one.
*
* The original game sells hints; here they are simply counted, and the one
* choice that has to be made about them — a hint is placed by the board, not
* guessed at by the player — is the same either way.
*/
@Serializable
@SerialName("hint")
data object TakeHint : MeowdokuMove
@@ -0,0 +1,197 @@
package org.prolibertate.games.game.meowdoku
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
/**
* Meowdoku as a pure state machine. See RULES-meowdoku.md.
*
* The engine is a second single-seat game, alongside peg solitaire, and it
* inherits the same freedom: there is nobody to hide anything from, so
* [viewFor] hands out the whole board including the answer. The answer lives in
* the state rather than being re-derived because judging a tap against a
* deduction the player could have made is a different thing from judging it
* against what the board was dealt — and the deduction engine (crossing out
* rows, columns and colours) belongs to the player, not the referee.
*/
object MeowdokuRules : GameRules<MeowdokuState, MeowdokuMove> {
override val gameId: String = GameCatalog.MEOWDOKU
/** The one seat. */
const val SOLO_SEAT: Int = 0
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): MeowdokuState {
val options = json.decodeFromString<MeowdokuOptions>(config.optionsJson)
require(config.seats.size == 1) {
"Meowdoku seats one, not ${config.seats.size}"
}
val puzzle = deal(options.side, config.seed)
return MeowdokuState(
options = options,
regions = puzzle.regions.map { it.toList() },
catCols = puzzle.catCols.toList(),
cats = emptySet(),
marks = emptySet(),
heartsLeft = options.hearts,
hintsLeft = options.hints,
wrongCats = 0,
log = listOf("A ${options.side} by ${options.side} board, ${options.side} cats to find."),
)
}
/**
* Deals until the board is good.
*
* A seed whose first attempts all fail to produce a unique board moves the
* seed along rather than giving up: the same table asked twice gets the
* same board both times, which is what a saved game and a unit test need.
*/
private fun deal(side: Int, seed: Long): MeowdokuPuzzle {
var trySeed = seed
repeat(DEAL_TRIES) {
MeowdokuGenerator.generate(side, trySeed)?.let { return it }
trySeed = trySeed * 6364136223846793005L + 1442695040888963407L
}
error("No unique ${side} x $side board within $DEAL_TRIES deals of seed $seed")
}
private const val DEAL_TRIES = 8
/**
* Every tap the board would take.
*
* The scheme is the original's: the first tap crosses a square over and
* opens a short window, and a second tap in that window is the cat attempt
* — so an attempt is offered on every square without a cat, crossed or
* not. The cross the attempt replaces is exactly the cross the first tap
* drew. Cats, once placed, are never tapped again: they are not a guess
* you take back.
*
* The list is written row by row, which is also the order the hint uses.
*/
override fun legalMoves(state: MeowdokuState, seat: Int): List<MeowdokuMove> {
if (seat != SOLO_SEAT || isFinished(state)) return emptyList()
val cats = ArrayList<MeowdokuMove>()
val pencils = ArrayList<MeowdokuMove>()
for (row in 0 until state.side) {
for (col in 0 until state.side) {
val square = Square(row, col)
if (square in state.cats) continue
cats += PlaceCat(square)
pencils += TogglePencil(square)
}
}
val hint = if (state.hintsLeft > 0) listOf(TakeHint) else emptyList()
return cats + pencils + hint
}
override fun applyMove(
state: MeowdokuState,
seat: Int,
move: MeowdokuMove,
): MeowdokuState {
require(seat == SOLO_SEAT) { "Meowdoku has one seat" }
require(move in legalMoves(state, seat)) { "$move is not a legal move" }
return when (move) {
is PlaceCat -> tap(state, move.square)
is TogglePencil -> pencil(state, move.square)
TakeHint -> hint(state)
}
}
/**
* The second tap of a double tap: found a cat, or lost a heart.
*
* A correct tap clears the cross the first tap left behind — the cat
* replaces it. A wrong one leaves the cross where it is and spends a
* heart, because a wrong square crossed twice is the same mistake twice,
* and the board remembers it either way.
*/
private fun tap(state: MeowdokuState, square: Square): MeowdokuState {
if (state.isCatSquare(square)) {
val next = state.copy(
cats = state.cats + square,
marks = state.marks - square,
lastSquare = square,
lastWrong = false,
)
val note = if (next.solved) {
"All ${next.side} cats, ${next.wrongCats} wrong taps."
} else {
"Found a cat at row ${square.row + 1}, column ${square.col + 1}."
}
return next.copy(log = next.log + note)
}
val hearts = state.heartsLeft - 1
val next = state.copy(
marks = state.marks + square,
heartsLeft = hearts,
wrongCats = state.wrongCats + 1,
lastSquare = square,
lastWrong = true,
)
return if (hearts == 0) {
next.copy(log = next.log + "Out of hearts, with ${next.side - next.catsFound} cats still hiding.")
} else {
next
}
}
private fun pencil(state: MeowdokuState, square: Square): MeowdokuState {
val marks = if (square in state.marks) state.marks - square else state.marks + square
return state.copy(marks = marks, lastSquare = square, lastWrong = false)
}
/**
* Shows the cat in the first row without one.
*
* A revealed cat lands like a found one — and erases a pencil cross on its
* square, because the point of a hint is that the player stops having to
* work out whether the board contradicts their own notes.
*/
private fun hint(state: MeowdokuState): MeowdokuState {
val row = (0 until state.side).first { r -> state.cats.none { it.row == r } }
val square = Square(row, state.catCols[row])
return state.copy(
cats = state.cats + square,
marks = state.marks - square,
hintsLeft = state.hintsLeft - 1,
lastSquare = square,
lastWrong = false,
log = state.log + "A hint shows the cat in row ${row + 1}.",
)
}
/** A board is finished when it is solved or when the hearts run out. */
override fun isFinished(state: MeowdokuState): Boolean = state.solved || state.lost
override fun currentSeat(state: MeowdokuState): Int? =
if (isFinished(state)) null else SOLO_SEAT
override fun summary(state: MeowdokuState): String = when {
state.solved -> "Solved with ${state.heartsLeft} hearts to spare"
state.lost -> "Lost with ${state.catsFound} of ${state.side} cats found"
else -> "${state.catsFound} of ${state.side} cats, ${state.heartsLeft} hearts left"
}
/**
* Nothing to strip. One person plays, and the answer in the state is there
* for the referee, not as a secret. Should a second seat ever race through
* the same board, this is where [MeowdokuState.catCols] gets hidden.
*/
override fun viewFor(state: MeowdokuState, seat: Int): MeowdokuState = state
override fun encodeState(state: MeowdokuState): String = json.encodeToString(state)
override fun decodeState(json: String): MeowdokuState = this.json.decodeFromString(json)
override fun encodeMove(move: MeowdokuMove): String = json.encodeToString(move)
override fun decodeMove(json: String): MeowdokuMove = this.json.decodeFromString(json)
}
@@ -1,6 +1,9 @@
package org.prolibertate.games.game.morris
import kotlin.random.Random
import kotlin.math.roundToInt
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -19,7 +22,20 @@ import org.prolibertate.games.game.engine.GameAi
*
* Strength comes from the table's own options unless a level is passed in.
*/
class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, MorrisMove> {
class MorrisAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: MorrisLevel? = null,
) : GameAi<MorrisState, MorrisMove> {
// Viewpoint and weights for the move being chosen. At the default (balanced)
// style they equal the plain constants, so Medium is unchanged.
private var rootSeat = 0
private var wMill = MILL
private var wAlmost = ALMOST
private var wTheirAlmost = ALMOST
private var wMobility = MOBILITY
private var wTheirMobility = MOBILITY
private var wDanger = DANGER
/**
* Ceiling on how much thinking any one move gets, so a phone does not stall
@@ -32,10 +48,29 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
override fun chooseMove(state: MorrisState, seat: Int, legal: List<MorrisMove>): MorrisMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
nodeBudget = NODE_BUDGET
val bold = profile.style.boldness
val spite = profile.style.spite
rootSeat = seat
// Bold seats value mills and half-built mills more; cautious ones value
// mobility and avoiding the three-piece trap. Spiteful seats weigh the
// opponent's mill threats and freedom of movement more.
wMill = MILL + (bold * 6).roundToInt()
wAlmost = ALMOST + (bold * 3).roundToInt()
wTheirAlmost = ALMOST + (spite * 4).roundToInt() + (-bold * 2).roundToInt()
wMobility = MOBILITY + (-bold * 1).roundToInt()
wTheirMobility = MOBILITY + (spite * 2).roundToInt() + (-bold * 1).roundToInt()
wDanger = DANGER + (-bold * 10).roundToInt()
val maxDepth = when (profile.level) {
AiLevel.EASY -> maxOf(2, setting.depth - 2)
AiLevel.MEDIUM -> setting.depth
AiLevel.HARD -> setting.depth + 1
}
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
nodesUsed = 0
// Deterministic for a given position, so a host and a client replaying
@@ -46,7 +81,7 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
for (depth in 1..maxDepth) {
val results = mutableListOf<Pair<MorrisMove, Int>>()
val ordered = order(legal, preferred = best)
for (move in ordered) {
@@ -73,7 +108,16 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
val chosen = acceptable[random.nextInt(acceptable.size)].first
if (profile.level != AiLevel.EASY) return chosen
// Easy now and then settles for another sound-looking move: only ones
// within about a piece of the best, never a real blunder.
val ranked = listOf(chosen) + scored
.filter { it.first != chosen && it.second >= bestScore - EASY_TOLERANCE }
.sortedByDescending { it.second }
.map { it.first }
return profile.pick(ranked)
}
/** How far below best a move may score and still be considered. */
@@ -155,16 +199,17 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
/** The position from the point of view of the side to move. */
private fun evaluate(state: MorrisState): Int {
val us = state.turn
val them = other(us)
val me = rootSeat
val them = other(me)
var score = PIECE * (state.remaining(us) - state.remaining(them))
score += MILL * (state.millCount(us) - state.millCount(them))
score += ALMOST * (almostMills(state, us) - almostMills(state, them))
score += MOBILITY * (mobility(state, us) - mobility(state, them))
score += LADDER * (midEdgeCount(state, us) - midEdgeCount(state, them))
score -= DANGER * (onThree(state, us) - onThree(state, them))
return score
var score = PIECE * (state.remaining(me) - state.remaining(them))
score += wMill * (state.millCount(me) - state.millCount(them))
score += wAlmost * almostMills(state, me) - wTheirAlmost * almostMills(state, them)
score += wMobility * mobility(state, me) - wTheirMobility * mobility(state, them)
score += LADDER * (midEdgeCount(state, me) - midEdgeCount(state, them))
score -= wDanger * (onThree(state, me) - onThree(state, them))
// Scored from the root seat's side; flip for the side to move.
return if (state.turn == me) score else -score
}
/**
@@ -215,6 +260,8 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
const val INFINITY = 1_000_000
const val WIN = 100_000
const val NODE_BUDGET = 120_000
const val HARD_NODE_BUDGET = 280_000
const val EASY_TOLERANCE = 120
const val PIECE = 120
const val MILL = 26
@@ -1,6 +1,10 @@
package org.prolibertate.games.game.pirates
import kotlin.math.roundToInt
import kotlin.random.Random
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -14,17 +18,73 @@ import org.prolibertate.games.game.engine.GameAi
* from the Bulgars' side and negated for the pirates, so the two cannot drift
* apart and start valuing the same position differently.
*/
class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState, PiratesMove> {
class PiratesAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: PiratesLevel? = null,
) : GameAi<PiratesState, PiratesMove> {
private var nodeBudget = 0
private var nodesUsed = 0
// Evaluation weights for the seat now choosing; the standard values unless
// a style or level bends them. Set at the top of each chooseMove.
private var wBulgar = BULGAR
private var wStronghold = IN_STRONGHOLD
private var wAdvance = ADVANCE
private var wExposed = EXPOSED
private var wRoom = PIRATE_ROOM
private fun tune(profile: AiProfile, seat: Int) {
val bold = profile.style.boldness
val spite = profile.style.spite
var bulgar = 1.0
var stronghold = 1.0
var advance = 1.0
var exposed = 1.0
var room = 1.0
if (seat == BULGAR_SEAT) {
// A bold crowd pushes forward and risks being jumped; a cautious one stays tight.
exposed -= 0.3 * bold
advance += 0.3 * bold
// A spoiler squeezes the pirates' room.
room += 0.4 * spite
} else {
// A bold pirate hunts; a cautious one keeps its room to move.
bulgar += 0.15 * bold
room -= 0.3 * bold
// A spoiler pays more attention to stopping the climb.
stronghold += 0.4 * spite
advance += 0.4 * spite
}
if (profile.level == AiLevel.EASY) exposed *= 0.5 // overlooks some threats
wBulgar = (BULGAR * bulgar).roundToInt()
wStronghold = (IN_STRONGHOLD * stronghold).roundToInt()
wAdvance = (ADVANCE * advance).roundToInt()
wExposed = (EXPOSED * exposed).roundToInt()
wRoom = (PIRATE_ROOM * room).roundToInt()
}
override fun chooseMove(state: PiratesState, seat: Int, legal: List<PiratesMove>): PiratesMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
val profile = profiles.of(seat)
val base = level ?: state.options.level
tune(profile, seat)
nodeBudget = NODE_BUDGET
val hard = profile.level == AiLevel.HARD
val easy = profile.level == AiLevel.EASY
val maxDepth = when (profile.level) {
AiLevel.MEDIUM -> base.depth
AiLevel.EASY -> minOf(base.depth, 2)
AiLevel.HARD -> base.depth + 2
}
val slack = when (profile.level) {
AiLevel.MEDIUM -> base.slack()
AiLevel.EASY -> maxOf(base.slack(), 40)
AiLevel.HARD -> 0
}
nodeBudget = if (hard) HARD_NODE_BUDGET else NODE_BUDGET
nodesUsed = 0
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
@@ -32,18 +92,23 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
for (depth in 1..maxDepth) {
val results = mutableListOf<Pair<PiratesMove, Int>>()
val ordered = order(legal, preferred = best)
var bestSoFar = -INFINITY
for (move in ordered) {
// A hard seat only needs the exact value of moves that tie or beat
// the best so far, so everything else is searched against a window.
val floor = if (hard && results.isNotEmpty()) bestSoFar - 1 else -INFINITY
val score = -negamax(
state = PiratesRules.advanced(state, move),
depth = depth - 1,
alpha = -INFINITY,
beta = INFINITY,
beta = -floor,
ply = 1,
)
results += move to score
if (score > bestSoFar) bestSoFar = score
if (nodesUsed >= nodeBudget) break
}
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
@@ -51,9 +116,16 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
best = results.maxBy { it.second }.first
}
if (easy) {
val ranked = scored.sortedByDescending { it.second }.map { it.first }
val picked = profile.pick(ranked)
if (picked != ranked.first() && picked in legal) return picked
}
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
val acceptable = scored.filter { it.second >= bestScore - slack }
val chosen = acceptable[random.nextInt(acceptable.size)].first
return if (chosen in legal) chosen else legal.first()
}
private fun PiratesLevel.slack(): Int = when (this) {
@@ -115,22 +187,22 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
private fun evaluate(state: PiratesState): Int {
var bulgarView = 0
bulgarView += state.count(BULGAR_SEAT) * BULGAR
bulgarView += state.stronghold() * IN_STRONGHOLD
bulgarView += state.count(BULGAR_SEAT) * wBulgar
bulgarView += state.stronghold() * wStronghold
// How far up the board the crowd has come. The stronghold is at the
// top, so a low row number is ground gained.
for (point in 0 until POINTS) {
if (state.board[point] != BULGAR_SEAT) continue
bulgarView += (GRID - 1 - rowOf(point)) * ADVANCE
bulgarView += (GRID - 1 - rowOf(point)) * wAdvance
// A Bulgar with an empty point directly behind it on a line is one
// a pirate can jump. Being unjumpable is most of staying alive.
if (isExposed(state, point)) bulgarView -= EXPOSED
if (isExposed(state, point)) bulgarView -= wExposed
}
// Room for the pirates to work in, which is what the Bulgars are
// slowly taking away.
bulgarView -= pirateMobility(state) * PIRATE_ROOM
bulgarView -= pirateMobility(state) * wRoom
return if (state.turn == BULGAR_SEAT) bulgarView else -bulgarView
}
@@ -160,6 +232,7 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
const val INFINITY = 1_000_000
const val WIN = 100_000
const val NODE_BUDGET = 150_000
const val HARD_NODE_BUDGET = 400_000
const val BULGAR = 100
const val IN_STRONGHOLD = 55
@@ -1,63 +1,85 @@
package org.prolibertate.games.game.president
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic President opponent.
* A President opponent whose strength and temperament come from the seat's profile.
*
* It sheds low cards early, spends the cheapest set that takes the pile, and
* holds twos back for when they are worth something. It does not count cards.
* MEDIUM (the default) sheds low cards early, spends the cheapest set that takes
* the pile, and holds twos back for when they are worth something. It does not
* count cards.
*
* EASY spends high cards freely when leading and sometimes plays a worse set or
* passes when it should have played. HARD reads the round's log to count which
* ranks are still out, leads so that its remaining hand is unbeatable, blocks an
* opponent who is about to go out, and keeps its high cards back when a cheap
* pile is not worth them.
*/
class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class PresidentAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<PresidentState, PresidentMove> {
enum class Difficulty(
/** Rank above which a card is hoarded rather than spent to lead. */
val hoardAbove: Int,
) {
/** Plays whatever is cheapest, whenever it can. */
EASY(hoardAbove = 99),
NORMAL(hoardAbove = 13),
/** Sits on high cards until late, then dumps them. */
CAUTIOUS(hoardAbove = 11),
}
override fun chooseMove(
state: PresidentState,
seat: Int,
legal: List<PresidentMove>,
): PresidentMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val plays = legal.filterIsInstance<PlayCards>()
if (plays.isEmpty()) return legal.first()
return if (state.setSize == 0) chooseLead(state, seat, plays) else chooseFollow(state, plays, legal)
val move: PresidentMove = when (profile.level) {
AiLevel.MEDIUM -> standard(state, seat, plays, legal, hoardAbove = HOARD_ABOVE)
AiLevel.EASY -> easy(state, seat, plays, legal, profile)
AiLevel.HARD -> hard(state, seat, plays, legal, profile)
}
return if (move in legal) move else legal.first()
}
private fun standard(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
legal: List<PresidentMove>,
hoardAbove: Int,
): PresidentMove =
if (state.setSize == 0) {
leadRanking(state, seat, plays, hoardAbove).first()
} else {
chooseFollow(state, plays, legal)
}
// -----------------------------------------------------------------------
// Standard plan
// -----------------------------------------------------------------------
/**
* Leading is a chance to shed. Prefer the biggest set of the lowest rank,
* which clears junk fastest without spending anything valuable.
*/
private fun chooseLead(
private fun leadRanking(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
): PresidentMove {
hoardAbove: Int,
): List<PlayCards> {
val endgame = state.hands[seat].size <= 4
val candidates = plays.filter { play ->
endgame || presidentRank(play.cards.first()) <= difficulty.hoardAbove
endgame || presidentRank(play.cards.first()) <= hoardAbove
}.ifEmpty { plays }
return candidates
.sortedWith(
compareBy(
{ presidentRank(it.cards.first()) },
{ -it.cards.size },
)
)
.first()
return candidates.sortedWith(
compareBy<PlayCards>(
{ presidentRank(it.cards.first()) },
{ -it.cards.size },
),
)
}
/**
@@ -82,4 +104,188 @@ class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return cheapest
}
// -----------------------------------------------------------------------
// Easy
// -----------------------------------------------------------------------
private fun easy(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
legal: List<PresidentMove>,
profile: AiProfile,
): PresidentMove {
if (state.setSize == 0) {
// Never hoards: leads aces and twos as readily as anything else.
return profile.pick(leadRanking(state, seat, plays, hoardAbove = 99))
}
val best = chooseFollow(state, plays, legal)
val alternatives = plays.sortedBy { presidentRank(it.cards.first()) }
val ranked = buildList<PresidentMove> {
add(best)
addAll(alternatives.filter { it != best })
if (PassTurn in legal && best != PassTurn) add(PassTurn)
}
return profile.pick(ranked)
}
// -----------------------------------------------------------------------
// Hard
// -----------------------------------------------------------------------
/** Ranks still unaccounted for: not in this seat's hand and not played yet this round. */
private class Counts(state: PresidentState, seat: Int) {
private val unseen = HashMap<Int, Int>()
init {
for (rank in 3..15) unseen[rank] = 4
state.hands[seat].forEach { dec(presidentRank(it)) }
val log = state.log
val start = log.indexOfLast { ROUND_MARK.matches(it) } + 1
for (i in start until log.size) {
val line = log[i]
val at = line.indexOf(" plays ")
if (!line.startsWith("Seat ") || at < 0) continue
line.substring(at + " plays ".length).removeSuffix(".").split(' ').forEach { token ->
LABELS[token]?.let { dec(presidentRank(it)) }
}
}
}
private fun dec(rank: Int) {
unseen[rank] = maxOf(0, (unseen[rank] ?: 0) - 1)
}
fun of(rank: Int): Int = unseen[rank] ?: 0
/** Could an opponent still hold a set of [size] that beats a set of [size] at [rank]? */
fun beatable(rank: Int, size: Int, options: PresidentOptions): Boolean {
if (options.twosClear && rank != 15 && of(15) >= size) return true
if (options.fourOfAKindBomb && size == 4 && (3..15).any { it != rank && of(it) == 4 }) return true
return (rank + 1..15).any { of(it) >= size }
}
}
private fun hard(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
legal: List<PresidentMove>,
profile: AiProfile,
): PresidentMove {
val hand = state.hands[seat]
// Going out beats any plan.
plays.firstOrNull { it.cards.size == hand.size }?.let { return it }
val counts = Counts(state, seat)
val others = (0 until state.options.playerCount).filter { it != seat && !state.isOut(it) }
return if (state.setSize == 0) {
hardLead(state, seat, plays, counts, others, profile)
} else {
hardFollow(state, seat, plays, legal, counts, others, profile)
}
}
/** An opponent who could empty their hand with a set the size of the one on top, or is the score leader close to out. */
private fun outThreat(
state: PresidentState,
seat: Int,
others: List<Int>,
profile: AiProfile,
): Boolean {
if (others.any { state.handCounts[it] <= maxOf(1, state.setSize) }) return true
if (profile.style.spite >= 0.5) {
val leader = others.maxByOrNull { state.scores[it] }
if (leader != null && state.handCounts[leader] <= 2) return true
}
return false
}
private fun hardLead(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
counts: Counts,
others: List<Int>,
profile: AiProfile,
): PresidentMove {
val hand = state.hands[seat]
val options = state.options
val oneCardLeft = others.any { state.handCounts[it] == 1 }
fun unbeatableAfter(play: PlayCards): Boolean {
val remaining = hand.toMutableList().also { left -> play.cards.forEach { left.remove(it) } }
return remaining.groupBy { presidentRank(it) }
.all { (rank, cards) -> !counts.beatable(rank, cards.size, options) }
}
fun key(play: PlayCards): Double {
val rank = presidentRank(play.cards.first())
val size = play.cards.size
var k = rank - 0.9 * size
if (unbeatableAfter(play)) return k - 50.0
// Leading aces and twos spends control that is worth more later.
if (rank >= 14 && hand.size > 4) k += 20.0
// Somebody is down to one card: a single would let them out if they can beat it.
if (oneCardLeft && size == 1) k += 10.0 + (15 - rank) * 2.0
// A bold seat is happier to lead its strong sets early.
if (rank >= 11) k -= profile.style.boldness * 2.0
return k
}
return plays.minBy { key(it) }
}
private fun hardFollow(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
legal: List<PresidentMove>,
counts: Counts,
others: List<Int>,
profile: AiProfile,
): PresidentMove {
val hand = state.hands[seat]
val options = state.options
val canPass = PassTurn in legal
val threat = outThreat(state, seat, others, profile)
val withoutTwos = plays.filter { play -> play.cards.none { isTwo(it) } }
val pool = withoutTwos.ifEmpty { plays }
val cheapest = pool.minBy { presidentRank(it.cards.first()) }
if (threat) {
// Block them: take the pile with the cheapest set nobody can beat,
// or failing that the cheapest set there is, twos included.
val sure = plays.filter { !counts.beatable(presidentRank(it.cards.first()), it.cards.size, options) }
.minByOrNull { presidentRank(it.cards.first()) }
return sure ?: plays.minBy { presidentRank(it.cards.first()) }
}
val spendingTwo = cheapest.cards.any { isTwo(it) }
if (spendingTwo) {
val remaining = hand.toMutableList().also { left -> cheapest.cards.forEach { left.remove(it) } }
val runOut = remaining.groupBy { presidentRank(it) }
.all { (rank, cards) -> !counts.beatable(rank, cards.size, options) }
val worthIt = state.setRank >= 13 || state.pile.size >= 4 || hand.size <= 4 || runOut
if (!worthIt && canPass) return PassTurn
return cheapest
}
// A cheap pile is not worth a high card while the hand is still big.
val gap = profile.bolder(6.0, 2.0)
val cost = presidentRank(cheapest.cards.first()) - state.setRank
if (canPass && hand.size > 5 && cost > gap && presidentRank(cheapest.cards.first()) >= 12) {
return PassTurn
}
return cheapest
}
private companion object {
const val HOARD_ABOVE = 13
val ROUND_MARK = Regex("^Round \\d+\\.$")
val LABELS: Map<String, Card> = Suit.entries.flatMap { suit ->
Rank.standard.map { Card(it, suit) }
}.associateBy { it.label }
}
}
@@ -1,5 +1,8 @@
package org.prolibertate.games.game.sequence
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -8,18 +11,39 @@ import org.prolibertate.games.game.engine.GameAi
* It scores every legal placement by how much it advances one of its own runs
* and how badly it spoils an opponent's, holds its jacks back until they are
* worth spending, and never looks at anyone's hand.
*
* Per seat, from [profiles]: Easy mostly ignores your runs, burns its one-eyed
* jacks on anything and sometimes settles for a lesser square; Hard blocks hard,
* saves jacks for real threats, always takes a completed run and always stops
* one that is one square from done. Boldness leans towards building over
* blocking and spite the other way round.
*/
class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class SequenceAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<SequenceState, SequenceMove> {
enum class Difficulty(val blockWeight: Int, val jackPatience: Int) {
/** Builds its own runs and mostly ignores yours. */
EASY(blockWeight = 1, jackPatience = 0),
/** The numbers one seat plays with, worked out from its level and style. */
private class Tuning(
val ownWeight: Double,
val blockWeight: Double,
val jackPatience: Double,
val finishing: Boolean,
)
NORMAL(blockWeight = 3, jackPatience = 3),
/** Blocks aggressively and saves jacks for real threats. */
CAUTIOUS(blockWeight = 5, jackPatience = 4),
private fun tuningOf(profile: AiProfile): Tuning {
val (block, patience) = when (profile.level) {
AiLevel.EASY -> 1.0 to 0.0
AiLevel.MEDIUM -> 3.0 to 3.0
AiLevel.HARD -> 5.0 to 4.0
}
val style = profile.style
val easy = profile.level == AiLevel.EASY
return Tuning(
ownWeight = 4.0 + style.boldness,
blockWeight = block - style.boldness * 0.8 + style.spite * 2.0,
// Only a seat that is paying attention changes its mind about jacks.
jackPatience = if (easy) patience else patience + style.boldness - style.spite,
finishing = profile.level == AiLevel.HARD,
)
}
override fun chooseMove(
@@ -28,15 +52,30 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
legal: List<SequenceMove>,
): SequenceMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val tuning = tuningOf(profile)
val team = state.teamOf(seat)
// Clearing a dead card is free value when nothing better is on offer.
val exchange = legal.filterIsInstance<ExchangeDeadCard>().firstOrNull()
if (exchange != null && legal.none { it is PlaceChip }) return exchange
val best = legal
val placements = legal
.filterIsInstance<PlaceChip>()
.maxByOrNull { scorePlacement(state, it, team) }
.map { it to scorePlacement(state, it, team, tuning) }
.sortedByDescending { it.second }
.map { it.first }
val best = if (placements.isEmpty()) {
null
} else if (tuning.finishing) {
// A run that completes, or a run of the opponent's that would, is
// never something to be talked out of.
placements.first()
} else {
// Easy only: the others never slip. Only the better few are in play,
// so a slip is a weaker square rather than a wasted card.
profile.pick(placements.take(4))
}
val removal = legal
.filterIsInstance<RemoveChip>()
@@ -45,7 +84,7 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
// Spend a one-eyed jack only on a genuinely dangerous chip.
if (removal != null) {
val threat = threatAt(state, removal.cell, state.chips[removal.cell])
if (threat >= difficulty.jackPatience) return removal
if (threat >= tuning.jackPatience && !profile.slips()) return removal
}
return best ?: removal ?: exchange ?: legal.first()
@@ -56,14 +95,25 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
* opponent's run it interrupts. Wild jacks are discounted so they are not
* burned on an ordinary square.
*/
private fun scorePlacement(state: SequenceState, move: PlaceChip, team: Int): Int {
private fun scorePlacement(
state: SequenceState,
move: PlaceChip,
team: Int,
tuning: Tuning,
): Double {
val ours = runPotential(state, move.cell, team)
val theirs = (0 until state.options.teamCount)
.filter { it != team }
.maxOfOrNull { runPotential(state, move.cell, it) }
?: 0
var score = ours * 4 + theirs * difficulty.blockWeight
var score = ours * tuning.ownWeight + theirs * tuning.blockWeight
if (tuning.finishing) {
// A potential of five through this square means placing here
// completes the run (for whoever places it).
if (ours >= RUN_LENGTH) score += 100.0
if (theirs >= RUN_LENGTH) score += 60.0 else if (theirs == RUN_LENGTH - 1) score += 8.0
}
// Central squares sit on more possible lines than edge squares.
val row = SequenceBoard.rowOf(move.cell)
val col = SequenceBoard.colOf(move.cell)
@@ -1,5 +1,8 @@
package org.prolibertate.games.game.tayu
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -18,17 +21,53 @@ import org.prolibertate.games.game.engine.GameAi
*
* [level] overrides the table's own setting, which is what the tests use;
* ordinarily it is left null and read from [TayuOptions.level].
*
* Per seat, from [profiles]: the table's setting says which terms count at all
* (Gentle ignores the opponent entirely and that is respected at every level).
* Within Full strength, Easy blocks and steers less and sometimes takes one of
* its better few placements rather than the best; Hard blocks and steers harder.
* Boldness weights its own axis against the opponent's, spite weights blocking.
*/
class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove> {
class TayuAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: TayuLevel? = null,
) : GameAi<TayuState, TayuMove> {
constructor(level: TayuLevel) : this(AiProfiles.DEFAULT, level)
private class Weights(val own: Double, val block: Double, val reach: Double)
private fun weightsOf(setting: TayuLevel, profile: AiProfile): Weights {
var own = setting.ownWeight.toDouble()
var block = setting.blockWeight.toDouble()
var reach = setting.reachWeight.toDouble()
// Gentle is a table rule, not a skill level: it stays as it is.
if (setting.blockWeight > 0) {
when (profile.level) {
AiLevel.EASY -> { block *= 0.4; reach *= 0.5 }
AiLevel.MEDIUM -> Unit
AiLevel.HARD -> { block *= 1.35; reach *= 1.5 }
}
own *= 1.0 + 0.15 * profile.style.boldness
block *= 1.0 - 0.15 * profile.style.boldness + 0.4 * profile.style.spite
}
return Weights(own, block, reach)
}
override fun chooseMove(state: TayuState, seat: Int, legal: List<TayuMove>): TayuMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
return legal.maxByOrNull { rate(state, seat, it, setting) } ?: legal.first()
val weights = weightsOf(setting, profile)
val rated = legal.map { it to rate(state, seat, it, weights) }
if (profile.level != AiLevel.EASY) return rated.maxByOrNull { it.second }?.first ?: legal.first()
// Easy: one of its better few placements, not always the best.
val ranked = rated.sortedByDescending { it.second }.take(EASY_POOL).map { it.first }
return profile.pick(ranked)
}
private fun rate(state: TayuState, seat: Int, move: TayuMove, setting: TayuLevel): Int {
private fun rate(state: TayuState, seat: Int, move: TayuMove, weights: Weights): Double {
val after = TayuRules.previewPlacement(state, seat, move)
val mine = state.axisOf(seat)
val theirs = if (mine == Axis.NORTH_SOUTH) Axis.EAST_WEST else Axis.NORTH_SOUTH
@@ -43,15 +82,15 @@ class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove>
// Skipped rather than multiplied by zero: the gentle setting is the whole
// reason to avoid walking the open mouths at all, and this runs for every
// one of a thousand-odd candidate placements.
val reach = if (setting.reachWeight == 0) {
val reach = if (weights.reach == 0.0) {
0
} else {
(pull(after, mine) - pull(after, theirs)) / REACH_SCALE
}
return setting.ownWeight * ours -
setting.blockWeight * opposing +
setting.reachWeight * reach
return weights.own * ours -
weights.block * opposing +
weights.reach * reach
}
/**
@@ -82,5 +121,8 @@ class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove>
private companion object {
/** Brings the open-mouth term onto the same scale as the score term. */
const val REACH_SCALE = 8
/** How many top-rated placements Easy chooses among. */
const val EASY_POOL = 6
}
}
@@ -3,8 +3,13 @@ package org.prolibertate.games.game.wizard
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
import kotlin.math.abs
import kotlin.math.pow
import kotlin.math.roundToInt
/**
@@ -15,52 +20,99 @@ import kotlin.math.roundToInt
* high it is and whether it is trump. In play it chases tricks while it is
* short of its bid and ducks once it has them, which is the whole game.
*/
class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class WizardAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<WizardState, WizardMove> {
enum class Difficulty(
/**
* Added to the expected-trick count before rounding. Optimists overbid
* and go down; pessimists underbid and get caught taking tricks they
* did not want.
*/
val bidBias: Double,
) {
EASY(bidBias = 0.35),
NORMAL(bidBias = 0.0),
CAUTIOUS(bidBias = -0.25),
private companion object {
/** Optimists overbid and go down; Easy leans this way. */
const val EASY_BID_BIAS = 0.35
val DECK: List<Card> = Decks.wizard()
}
/** Cards this AI has watched played in the current round, per seat it plays. */
private val memory = HashMap<Int, SeenCards>()
private class SeenCards {
var key: Long = Long.MIN_VALUE
val cards = HashSet<Card>()
}
/** Public information only: cards that have appeared on the table this round. */
private fun observe(state: WizardState, seat: Int): Set<Card> = synchronized(memory) {
val seen = memory.getOrPut(seat) { SeenCards() }
val key = state.seed * 1_000_003L + state.round
if (key != seen.key || state.phase == WizardPhase.BIDDING) {
seen.key = key
seen.cards.clear()
}
state.trick.forEach { seen.cards.add(it.card) }
state.completedTrick.forEach { seen.cards.add(it.card) }
seen.cards.removeAll(state.hands[seat].toSet())
seen.cards.toSet()
}
override fun chooseMove(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
WizardPhase.BIDDING -> chooseBid(state, seat, legal)
WizardPhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
val profile = profiles.of(seat)
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
val move = try {
when (state.phase) {
WizardPhase.BIDDING -> chooseBid(state, seat, legal, profile)
WizardPhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
else -> legal.first()
}
} catch (e: RuntimeException) {
legal.first()
}
return if (move in legal) move else legal.first()
}
// -----------------------------------------------------------------------
// Bidding
// -----------------------------------------------------------------------
private fun chooseBid(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
private fun chooseBid(
state: WizardState,
seat: Int,
legal: List<WizardMove>,
profile: AiProfile,
): WizardMove {
val hand = state.hands[seat]
val hard = profile.level == AiLevel.HARD
val easy = profile.level == AiLevel.EASY
val naming = legal.filterIsInstance<ChooseTrump>()
if (naming.isNotEmpty()) {
val hand = state.hands[seat]
val best = naming.maxByOrNull { suitStrength(hand, it.suit) }
return best ?: naming.first()
return when {
hard -> naming.maxByOrNull { hardExpected(state, hand, it.suit) }
easy -> {
val noisy = naming.associateWith { suitStrength(hand, it.suit) + profile.random.nextDouble(-0.8, 0.8) }
profile.pick(naming.sortedByDescending { noisy.getValue(it) })
}
else -> naming.maxByOrNull { suitStrength(hand, it.suit) }
} ?: naming.first()
}
val bids = legal.filterIsInstance<MakeBid>()
if (bids.isEmpty()) return legal.first()
val wanted = (expectedTricks(state.hands[seat], state.trump) + difficulty.bidBias)
.roundToInt()
.coerceIn(0, state.cardsThisRound)
var expected = if (hard) hardExpected(state, hand, state.trump) else expectedTricks(hand, state.trump)
if (hard) {
// The last to bid knows how many tricks the others have left on the table.
val others = state.bids.filterNotNull()
if (others.size == state.options.playerCount - 1) {
expected = expected * 0.75 + (state.cardsThisRound - others.sum()) * 0.25
}
}
if (easy) expected += EASY_BID_BIAS + profile.random.nextDouble(-0.8, 0.8)
// A gambler bids up, a cautious seat bids down.
expected += profile.bolder(0.0, 0.3)
val wanted = expected.roundToInt().coerceIn(0, state.cardsThisRound)
// Screw-the-dealer may have taken the number we wanted away, so settle
// for the nearest one still on offer.
return bids.minBy { abs(it.tricks - wanted) }
val ranked = bids.sortedBy { abs(it.tricks - wanted) }
return if (easy) profile.pick(ranked) else ranked.first()
}
/** How much of a hand a given trump suit would be worth. */
@@ -92,22 +144,88 @@ class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return expected
}
/**
* The chance [card] takes a trick, from the cards that could still beat it.
*
* [pool] is every card that might be in somebody else's hand and [q] the
* chance any one of them is, and each higher card only threatens the trick
* if it lands in the same one, which is less likely the longer the round.
*/
private fun winProb(card: Card, pool: List<Card>, trump: Suit?, q: Double, tricks: Int): Double {
if (isWizardCard(card)) return 1.0
if (isJester(card)) return 0.0
val wizards = pool.count { isWizardCard(it) && it != card }
val ordinary = { c: Card -> !isWizardCard(c) && !isJester(c) }
val trumpCard = trump != null && card.suit == trump
val higher = pool.count { ordinary(it) && it.suit == card.suit && it.rank.order > card.rank.order }
var danger = (higher + wizards).toDouble()
if (!trumpCard && trump != null) {
val voidFactor = 1.0 / (1.0 + 0.5 * (tricks - 1))
danger += voidFactor * pool.count { ordinary(it) && it.suit == trump }
}
val sequencing = 1.0 / (1.0 + 0.35 * (tricks - 1))
return (1.0 - (q * sequencing).coerceIn(0.0, 1.0)).pow(danger)
}
/** Expected tricks from the cards that could still be out, calibrated so the average hand scores its fair share. */
private fun hardExpected(state: WizardState, hand: List<Card>, trump: Suit?): Double {
val n = state.cardsThisRound
val players = state.options.playerCount
val inPlay = DECK.filter { it != state.trumpCard }
val mine = inPlay.filter { it !in hand }
val q = ((players - 1) * n).toDouble() / mine.size.coerceAtLeast(1)
val own = hand.sumOf { winProb(it, mine, trump, q, n) }
val average = inPlay.sumOf { winProb(it, inPlay, trump, q, n) } / inPlay.size
val scale = if (average > 0.0) (1.0 / (players * average)).coerceIn(0.5, 2.0) else 1.0
return own * scale
}
// -----------------------------------------------------------------------
// Play
// -----------------------------------------------------------------------
private fun choosePlay(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
private fun choosePlay(
state: WizardState,
seat: Int,
legal: List<WizardMove>,
profile: AiProfile,
seen: Set<Card>,
): WizardMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
val hard = profile.level == AiLevel.HARD
val bid = state.bids[seat] ?: 0
val stillNeeded = bid - state.tricksWon[seat]
val tricksLeft = state.hands[seat].size
// Chase while we are short, and only while there are enough tricks left
// to be worth chasing.
val wantsTrick = stillNeeded > 0 && stillNeeded <= tricksLeft
// to be worth chasing. (The careful player keeps chasing even when the
// bid is out of reach: every trick still cuts the penalty.)
val wantsTrick = stillNeeded > 0 && (hard || stillNeeded <= tricksLeft)
// A spoiler who has already blown their own bid takes tricks off anyone
// who needs exactly one more.
val spoiling = profile.style.spite >= 0.5 && stillNeeded < 0
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, state.trump, wantsTrick))
val best = if (hard) {
hardPlay(state, seat, cards, seen, wantsTrick, spoiling, stillNeeded, tricksLeft)
} else {
standardPlay(state, cards, wantsTrick, spoiling)
}
if (profile.level != AiLevel.EASY) return PlayCard(best)
// Easy sometimes burns a strong card at the wrong moment.
val ranked = listOf(best) + (cards - best).sortedByDescending { it.rank.order }
return PlayCard(profile.pick(ranked))
}
private fun standardPlay(
state: WizardState,
cards: List<Card>,
wantsTrick: Boolean,
spoiling: Boolean,
): Card {
if (state.trick.isEmpty()) return chooseLead(cards, state.trump, wantsTrick)
val ledSuit = ledSuitOf(state.trick)
val order = state.trick.size
@@ -122,15 +240,99 @@ class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return when {
// Take it as cheaply as we can.
wantsTrick && winners.isNotEmpty() -> PlayCard(winners.minBy { strengthOf(it) })
wantsTrick && winners.isNotEmpty() -> winners.minBy { strengthOf(it) }
// Spite: stop somebody from landing the bid they need.
spoiling && winners.isNotEmpty() && victimNeedsOne(state) -> winners.minBy { strengthOf(it) }
// Cannot take it, or do not want it: throw the least useful card.
losers.isNotEmpty() -> PlayCard(losers.minBy { strengthOf(it) })
losers.isNotEmpty() -> losers.minBy { strengthOf(it) }
// Everything we hold wins, and we did not want the trick. Take it
// with the weakest winner so the good cards stay for later.
else -> PlayCard(cards.minBy { strengthOf(it) })
else -> cards.minBy { strengthOf(it) }
}
}
/** Whether whoever is winning the trick right now needs exactly this trick to make their bid. */
private fun victimNeedsOne(state: WizardState): Boolean {
val ledSuit = ledSuitOf(state.trick)
val leading = state.trick
.withIndex()
.maxByOrNull { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
?: return false
val victim = leading.value.seat
return (state.bids[victim] ?: return false) - state.tricksWon[victim] == 1
}
/**
* The strong player's choice. It keeps track of which cards are gone to
* judge how likely each of its own is to win, spends sure winners when it
* needs tricks, and sheds its most dangerous cards onto tricks it will
* lose anyway when it does not.
*/
private fun hardPlay(
state: WizardState,
seat: Int,
cards: List<Card>,
seen: Set<Card>,
wantsTrick: Boolean,
spoiling: Boolean,
stillNeeded: Int,
tricksLeft: Int,
): Card {
val hand = state.hands[seat]
val unseen = DECK.filter { it != state.trumpCard && it !in seen && it !in hand }
val oppCards = state.handCounts.sum() - hand.size
val q = if (unseen.isEmpty()) 0.0 else (oppCards.toDouble() / unseen.size).coerceIn(0.0, 1.0)
val trump = state.trump
fun pWin(card: Card): Double = winProb(card, unseen, trump, q, tricksLeft)
if (state.trick.isEmpty()) {
if (wantsTrick) {
val scored = cards.map { it to pWin(it) }
val bestOverall = scored.maxBy { it.second }
// With tricks to spare, save the wizard for when it is needed.
if (tricksLeft > stillNeeded) {
val bestPlain = scored.filter { !isWizardCard(it.first) }.maxByOrNull { it.second }
if (bestPlain != null && bestPlain.second >= 0.6) return bestPlain.first
}
return bestOverall.first
}
// Ducking: lead whatever is least likely to win, a jester best of all.
return cards.minWith(compareBy<Card>({ pWin(it) }, { it.rank.order }))
}
val ledSuit = ledSuitOf(state.trick)
val order = state.trick.size
val remaining = state.options.playerCount - order - 1
val bestOnTable = state.trick
.withIndex()
.maxOf { (index, played) -> trickStrength(played.card, index, trump, ledSuit) }
val strengthOf = { card: Card -> trickStrength(card, order, trump, ledSuit) }
val winners = cards.filter { strengthOf(it) > bestOnTable }
val losers = cards.filter { strengthOf(it) <= bestOnTable }
fun sure(card: Card): Boolean =
remaining == 0 || unseen.none { trickStrength(it, order + 1, trump, ledSuit) > strengthOf(card) }
if (wantsTrick) {
if (winners.isNotEmpty()) {
val certain = winners.filter { sure(it) }
return (certain.ifEmpty { winners }).minBy { strengthOf(it) }
}
// Cannot take this one: keep the strong cards for a trick that can be won.
return losers.minBy { strengthOf(it) }
}
if (spoiling && winners.isNotEmpty() && victimNeedsOne(state)) {
return winners.minBy { strengthOf(it) }
}
if (losers.isNotEmpty()) {
// Unload the card that would be hardest to lose later, but keep the
// jester: it is the one card that can never win.
val nonJester = losers.filter { !isJester(it) }
return (nonJester.ifEmpty { losers }).maxBy { strengthOf(it) }
}
return cards.minBy { strengthOf(it) }
}
private fun chooseLead(cards: List<Card>, trump: Suit?, wantsTrick: Boolean): Card {
if (wantsTrick) {
// A led wizard is an unloseable trick.
@@ -1,6 +1,11 @@
package org.prolibertate.games.game.yahtzee
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
import kotlin.math.exp
import kotlin.math.sqrt
/**
* Plays the dice by eye rather than by arithmetic.
@@ -10,33 +15,53 @@ import org.prolibertate.games.game.engine.GameAi
* enumerated the thirty-two subsets and applied them would be reading the roll
* before making it — beating the player by seeing the future rather than by
* playing well. Everything below decides from the dice already on the table.
*
* Per seat, from [profiles]: Easy sometimes holds a poor set of dice and
* sometimes writes a lesser box than the best on offer. Hard works out, for
* every way of holding dice, the *expected* value of what could be thrown
* (averaging over all outcomes, never over the roll that will actually come),
* valuing each box by what it scores less what that box is usually worth, with
* the upper-section bonus tracked. Boldness chases the bonus and big combos
* and rolls on with slimmer odds; caution takes a sure score sooner.
*/
object YahtzeeAi : GameAi<YahtzeeState, YahtzeeMove> {
class YahtzeeAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<YahtzeeState, YahtzeeMove> {
override fun chooseMove(state: YahtzeeState, seat: Int, legal: List<YahtzeeMove>): YahtzeeMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val rolls = legal.filterIsInstance<RollDice>()
val writes = legal.filterIsInstance<ScoreIn>()
// Nothing on the table yet, so there is only one thing to do.
if (!state.hasRolled) return rolls.firstOrNull() ?: writes.first()
if (profile.level == AiLevel.HARD && writes.isNotEmpty()) {
return chooseExpected(state, seat, rolls, writes, profile)
}
val best = writes.maxByOrNull { scoreOf(it.category, state.dice) }
val ranked = writes.sortedByDescending { scoreOf(it.category, state.dice) }
// Easy sometimes writes the second or third best box rather than the best.
val best = if (ranked.isEmpty()) null else profile.pick(ranked.take(EASY_BOXES))
val bestScore = best?.let { scoreOf(it.category, state.dice) } ?: 0
// A hand already worth taking, or no rolls left, ends the turn.
if (rolls.isEmpty()) return best ?: writes.first()
if (bestScore >= WORTH_KEEPING) return best!!
if (bestScore >= profile.bolder(WORTH_KEEPING.toDouble(), 4.0)) return best!!
val keep = whatToKeep(state.dice).toSet()
val keep = if (profile.slips()) poorKeep(state.dice) else whatToKeep(state.dice).toSet()
return rolls.firstOrNull { it.keep == keep } ?: rolls.first()
}
/**
* Good enough to stop throwing. Set at a full house, which is the point
* where a hand is worth more than an average reroll of it.
*/
private const val WORTH_KEEPING = 25
/** What a distracted player holds: the high dice, or the pair they notice second. */
private fun poorKeep(dice: List<Int>): Set<Int> {
val counts = countsOf(dice)
val second = counts.entries
.sortedWith(compareByDescending<Map.Entry<Int, Int>> { it.value }.thenBy { it.key })
.drop(1)
.firstOrNull { it.value >= 2 }
if (second != null) return dice.indices.filter { dice[it] == second.key }.toSet()
return dice.indices.filter { dice[it] >= 4 }.toSet()
}
/**
* What to hold on to: a run if one is nearly there, otherwise the face there
@@ -82,4 +107,213 @@ object YahtzeeAi : GameAi<YahtzeeState, YahtzeeMove> {
face in wanted && taken.add(face)
}
}
// -- Hard: expected values ------------------------------------------------
/**
* Values every box as what it scores now less what it is normally worth, so
* that writing a zero in Ones costs little and writing one in Yahtzee costs a
* lot, then looks two throws ahead by averaging over every possible outcome
* of every way of holding dice.
*/
private fun chooseExpected(
state: YahtzeeState,
seat: Int,
rolls: List<RollDice>,
writes: List<ScoreIn>,
profile: AiProfile,
): YahtzeeMove {
val valuation = Valuation(state, seat, profile.style.boldness)
val dice = state.dice
val stop = writes.maxByOrNull { valuation.gain(it.category, dice) } ?: writes.first()
if (rolls.isEmpty()) return stop
val stopValue = valuation.gain(stop.category, dice)
// terminal[i]: best write for the i-th multiset of dice with no throws left.
val terminal = DoubleArray(DiceTables.multisets.size) { i ->
val faces = DiceTables.facesOf(i)
writes.maxOf { valuation.gain(it.category, faces) }
}
// Each throw still to come after the next one, worked backwards.
var next = terminal
repeat(state.rollsLeft - 1) {
val previous = next
next = DoubleArray(terminal.size) { i ->
maxOf(terminal[i], bestKeep(i, previous).second)
}
}
val here = DiceTables.indexOf(countVector(dice))
val (keepCounts, expected) = bestKeep(here, next)
val margin = -profile.style.boldness
if (expected <= stopValue + margin) return stop
val remaining = keepCounts.copyOf()
val keep = dice.indices.filter { position ->
val face = dice[position] - 1
if (remaining[face] > 0) {
remaining[face]--
true
} else {
false
}
}.toSet()
return rolls.firstOrNull { it.keep == keep } ?: stop
}
/** The best set of dice to hold from multiset [index], and what throwing the rest is worth. */
private fun bestKeep(index: Int, after: DoubleArray): Pair<IntArray, Double> {
var bestKeep = DiceTables.keepsOf(index).first()
var best = Double.NEGATIVE_INFINITY
for (keep in DiceTables.keepsOf(index)) {
val thrown = DICE_COUNT - keep.sum()
var key = 0
for (face in 0 until 6) key += keep[face] * DiceTables.POWERS[face]
var total = 0.0
for (outcome in DiceTables.rerolls[thrown]) {
total += outcome.probability * after[DiceTables.indexOfKey[key + outcome.key]]
}
if (total > best + 1e-9) {
best = total
bestKeep = keep
}
}
return bestKeep to best
}
private fun countVector(dice: List<Int>): IntArray {
val counts = IntArray(6)
dice.forEach { counts[it - 1]++ }
return counts
}
/** What a box is worth to this card right now, in points relative to its usual yield. */
private class Valuation(state: YahtzeeState, seat: Int, private val boldness: Double) {
private val card = state.cards[seat]
private val yahtzeeBonus = state.options.yahtzeeBonus && card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE
private val openUpper = YahtzeeCategory.entries.filter {
it.section == YahtzeeSection.UPPER && !card.isFilled(it)
}
private val secured = card.upperSubtotal >= UPPER_BONUS_AT
private val diff = YahtzeeCategory.entries
.filter { it.section == YahtzeeSection.UPPER && card.isFilled(it) }
.sumOf { (card[it] ?: 0) - 3 * it.face!! }
private val bonusValue = UPPER_BONUS * (1.0 + 0.4 * boldness)
fun gain(category: YahtzeeCategory, dice: List<Int>): Double {
val score = scoreOf(category, dice)
var value = score - TYPICAL[category.ordinal]
if (category.section == YahtzeeSection.UPPER && !secured) {
val face = category.face!!
val after = if (card.upperSubtotal + score >= UPPER_BONUS_AT) {
1.0
} else {
chance(diff + score - 3 * face, openUpper - category)
}
value += bonusValue * (after - chance(diff, openUpper))
}
// The big combinations: a bold seat values them a little more.
if (score > 0 && (category == YahtzeeCategory.LARGE_STRAIGHT ||
category == YahtzeeCategory.YAHTZEE || category == YahtzeeCategory.FOUR_OF_A_KIND)
) {
value += boldness * 2.0
}
if (yahtzeeBonus && isYahtzee(dice)) value += YAHTZEE_BONUS
return value
}
/** Likelihood the upper section still reaches the bonus from [difference] with [open] to fill. */
private fun chance(difference: Int, open: List<YahtzeeCategory>): Double {
if (open.isEmpty()) return if (difference >= 0) 1.0 else 0.0
val expected = open.sumOf { TYPICAL[it.ordinal] - 3 * it.face!! }
val spread = 2.2 * sqrt(open.size.toDouble()) + 0.3
return 1.0 / (1.0 + exp(-(difference + expected + 1.5) / spread))
}
}
/** Every multiset of five dice and every throw of n dice, worked out once. */
private object DiceTables {
class Outcome(val key: Int, val probability: Double)
val POWERS = intArrayOf(1, 6, 36, 216, 1296, 7776)
val indexOfKey = IntArray(46656) { -1 }
val multisets: List<IntArray> = vectors(DICE_COUNT)
val rerolls: Array<List<Outcome>> = Array(DICE_COUNT + 1) { n ->
val factorial = IntArray(6) { f -> (1..f).fold(1) { a, b -> a * b } }
val total = Math.pow(6.0, n.toDouble())
vectors(n).map { counts ->
val ways = (1..n).fold(1) { a, b -> a * b } / counts.fold(1) { a, c -> a * factorial[c] }
Outcome(keyOf(counts), ways / total)
}
}
private val keeps: Array<List<IntArray>?> = arrayOfNulls(multisets.size)
init {
multisets.forEachIndexed { i, counts -> indexOfKey[keyOf(counts)] = i }
}
fun indexOf(counts: IntArray): Int = indexOfKey[keyOf(counts)]
fun facesOf(index: Int): List<Int> {
val faces = ArrayList<Int>(DICE_COUNT)
multisets[index].forEachIndexed { f, c -> repeat(c) { faces += f + 1 } }
return faces
}
/** Every distinct set of dice that can be held back from multiset [index]. */
fun keepsOf(index: Int): List<IntArray> {
keeps[index]?.let { return it }
val counts = multisets[index]
var all = listOf(IntArray(6))
for (face in 0 until 6) {
all = all.flatMap { partial ->
(0..counts[face]).map { n -> partial.copyOf().also { it[face] = n } }
}
}
keeps[index] = all
return all
}
private fun keyOf(counts: IntArray): Int {
var key = 0
for (face in 0 until 6) key += counts[face] * POWERS[face]
return key
}
/** Every way to split [size] dice over six faces. */
private fun vectors(size: Int): List<IntArray> {
val out = ArrayList<IntArray>()
fun build(face: Int, left: Int, current: IntArray) {
if (face == 5) {
current[5] = left
out += current.copyOf()
return
}
for (n in 0..left) {
current[face] = n
build(face + 1, left - n, current)
}
}
build(0, size, IntArray(6))
return out
}
}
private companion object {
/**
* Good enough to stop throwing. Set at a full house, which is the point
* where a hand is worth more than an average reroll of it.
*/
const val WORTH_KEEPING = 25
/** How many of the best boxes Easy chooses among when it writes a score. */
const val EASY_BOXES = 3
/**
* What each box yields on average under strong play, in the order of
* [YahtzeeCategory]: the cost of spending it.
*/
val TYPICAL = doubleArrayOf(
2.11, 5.28, 8.57, 12.16, 15.69, 19.19, 21.66, 13.10, 22.59, 29.46, 32.71, 16.87, 22.01,
)
}
}
@@ -0,0 +1,58 @@
package org.prolibertate.games.game.yahtzee
import kotlinx.serialization.Serializable
/** What one finished game says about the local player's card. */
@Serializable
data class YahtzeeRecord(
val total: Int,
val gotBonus: Boolean,
/** Yahtzees scored, counting the extra ones that earned the hundred. */
val yahtzees: Int,
/** Boxes written in as nothing. */
val zeros: Int,
val players: Int,
val won: Boolean,
)
/** Builds the record of [seat]'s card in a finished game. Ties for first count as a win. */
fun recordOf(state: YahtzeeState, seat: Int): YahtzeeRecord {
val card = state.cards[seat]
val mine = state.totalFor(seat)
val best = state.cards.indices.maxOf { state.totalFor(it) }
return YahtzeeRecord(
total = mine,
gotBonus = card.upperBonus > 0,
yahtzees = (if (card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE) 1 else 0) +
state.yahtzeeBonuses[seat] / YAHTZEE_BONUS,
zeros = card.boxes.count { it == 0 },
players = state.cards.size,
won = mine >= best,
)
}
/** A game set against the ones before it. [rank] is 1 for a new best. */
data class YahtzeeComparison(
val current: YahtzeeRecord,
val previousGames: Int,
val bestBefore: Int?,
val averageBefore: Double?,
val rank: Int,
val gamesRanked: Int,
val bonusRateBefore: Double?,
val yahtzeesBefore: Int,
val winRateBefore: Double?,
)
fun compareToHistory(previous: List<YahtzeeRecord>, current: YahtzeeRecord): YahtzeeComparison =
YahtzeeComparison(
current = current,
previousGames = previous.size,
bestBefore = previous.maxOfOrNull { it.total },
averageBefore = previous.takeIf { it.isNotEmpty() }?.map { it.total }?.average(),
rank = 1 + previous.count { it.total > current.total },
gamesRanked = previous.size + 1,
bonusRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.gotBonus }.toDouble() / p.size },
yahtzeesBefore = previous.sumOf { it.yahtzees },
winRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.won }.toDouble() / p.size },
)
@@ -308,6 +308,6 @@ class LanTransport(private val context: Context) : Transport {
}
const val CONNECT_TIMEOUT_MS = 8_000
const val WIFI_LOCK_TAG = "PilgrimsOfKatan:lan"
const val WIFI_LOCK_TAG = "ProLibertateGames:lan"
}
}
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.prolibertate.games.game.engine.AiDifficulty
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
@@ -33,6 +34,7 @@ class LobbyController(
val connected: Boolean = false,
val gameId: String? = null,
val optionsJson: String = "{}",
val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
val hostName: String = "",
val seats: List<PlayerSlot> = emptyList(),
val discovered: List<DiscoveredHost> = emptyList(),
@@ -88,6 +90,7 @@ class LobbyController(
hostName: String,
seatCount: Int,
teamOf: (Int) -> Int,
aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
) {
stop()
val seats = (0 until seatCount).map { seat ->
@@ -102,6 +105,7 @@ class LobbyController(
hosting = true,
gameId = gameId,
optionsJson = optionsJson,
aiDifficulty = aiDifficulty,
hostName = hostName,
seats = seats,
message = "Waiting for players…",
@@ -237,6 +241,7 @@ class LobbyController(
seats = current.seats,
optionsJson = current.optionsJson,
seed = seed,
aiDifficulty = current.aiDifficulty,
)
scope.launch { connections.values.forEach { it.send(StartGame(config)) } }
_state.value = current.copy(started = config)
@@ -96,6 +96,27 @@ class MatchController<S : Any, M : Any>(
/** Host only: the true state. Clients never see this. */
private var authoritative: S? = null
/** A position to carry on from instead of dealing, set by [restore] before [startAsHost]. */
private var restored: S? = null
/**
* Host only: told about every position the table reaches, so it can be kept
* for later. Called under the table's lock, so it must return quickly — the
* snapshot encodes its state only when asked, and the work of writing it
* belongs to whoever is listening.
*/
var onHostState: ((HostSnapshot) -> Unit)? = null
/** One position of the table, as handed to [onHostState]. */
class HostSnapshot(
val finished: Boolean,
val summary: String,
private val encode: () -> String,
) {
/** The whole true state, encoded by the game's own rules. */
fun stateJson(): String = encode()
}
private val lock = Mutex()
private var sequence = 0
@@ -160,7 +181,7 @@ class MatchController<S : Any, M : Any>(
check(role == Role.HOST) { "Not hosting" }
clients.forEach { connection -> listenToPeer(connection) }
scope.launch {
val initial = runCatching { rules.initialState(config) }.getOrElse { error ->
val initial = restored ?: runCatching { rules.initialState(config) }.getOrElse { error ->
// A table the rules will not accept is a dead end, not a reason
// to take the app down with it: this runs in the screen's own
// scope, where nothing would catch it.
@@ -175,6 +196,20 @@ class MatchController<S : Any, M : Any>(
}
}
/**
* Host: carry on from a saved position instead of dealing a new game.
*
* Call before [startAsHost]. Returns false, and changes nothing, if
* [stateJson] cannot be read by this version of the game — a save made by an
* older build, say — and the caller should deal a fresh table instead.
*/
fun restore(stateJson: String): Boolean {
check(role == Role.HOST) { "Only a host can restore a game" }
val state = runCatching { rules.decodeState(stateJson) }.getOrNull() ?: return false
restored = state
return true
}
private fun listenToPeer(connection: Connection) {
peers[connection.peerId] = connection
peerJobs[connection.peerId] = scope.launch {
@@ -449,6 +484,16 @@ class MatchController<S : Any, M : Any>(
}
_finished.value = rules.isFinished(current)
if (role == Role.HOST) {
onHostState?.invoke(
HostSnapshot(
finished = rules.isFinished(current),
summary = rules.summary(current),
encode = { rules.encodeState(current) },
),
)
}
for (slot in config.seats) {
val peerId = slot.peerId ?: continue
val connection = peers[peerId] ?: continue
@@ -0,0 +1,95 @@
package org.prolibertate.games.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import org.prolibertate.games.game.engine.SavedGameStore
import org.prolibertate.games.game.engine.SavedMatch
import org.prolibertate.games.game.engine.TableConfig
/**
* Keeps the position of one table on disk as it is played.
*
* It is fed every position the host reaches ([offer]) and writes the newest one
* off the main thread. Positions that arrive while a write is under way are
* dropped in favour of the latest — a computer's turn can be several moves in a
* second, and only where the game stands matters. A finished game is not kept:
* its slot is cleared, so a won game is not offered back as one to resume.
*
* [flush] writes whatever has not been written yet, on the caller's thread. It
* is what makes leaving the table, or the app going to the background, safe: the
* background writer may not have caught up, and the process may not be given the
* chance to let it.
*/
class MatchSaver(
private val store: SavedGameStore,
private val gameId: String,
private val config: TableConfig,
private val localSeat: Int,
private val scope: CoroutineScope,
private val clock: () -> Long = System::currentTimeMillis,
) {
private val latest = MutableStateFlow<MatchController.HostSnapshot?>(null)
private var worker: Job? = null
private var written: MatchController.HostSnapshot? = null
private var discarded = false
/** Starts the background writer. */
fun start() {
worker = scope.launch(Dispatchers.IO) {
latest.filterNotNull().collectLatest { write(it) }
}
}
/** Hands over the newest position. Cheap: the encoding happens on the writer. */
fun offer(snapshot: MatchController.HostSnapshot) {
latest.value = snapshot
}
/** Writes the newest position now if the writer has not already. */
fun flush() {
latest.value?.let { write(it) }
}
/** The game was ended on purpose: forget the saved position and write no more. */
@Synchronized
fun discard() {
discarded = true
worker?.cancel()
worker = null
runCatching { store.clear(gameId) }
}
fun stop() {
worker?.cancel()
worker = null
}
@Synchronized
private fun write(snapshot: MatchController.HostSnapshot) {
if (discarded || snapshot === written) return
written = snapshot
// Saving is a courtesy to the player: a full disk must not take the game
// down with it.
runCatching {
if (snapshot.finished) {
store.clear(gameId)
} else {
store.save(
SavedMatch(
gameId = gameId,
config = config,
localSeat = localSeat,
stateJson = snapshot.stateJson(),
savedAtMillis = clock(),
summary = snapshot.summary,
),
)
}
}
}
}
@@ -33,7 +33,7 @@ const val PURPOSE_SCOREKEEPER = "scorekeeper"
/** Service type and name advertised over mDNS. */
const val SERVICE_TYPE = "_plgames._tcp"
const val SERVICE_NAME = "PilgrimsOfKatan"
const val SERVICE_NAME = "ProLibertateGames"
val protocolJson: Json = Json {
ignoreUnknownKeys = true
@@ -0,0 +1,17 @@
package org.prolibertate.games.score
/** The short list of names offered when a column is being named. */
object RecentNames {
const val LIMIT = 10
/**
* [name] moved to the front of [names], trimmed, with any earlier copy
* (compared ignoring case) dropped and the list cut to [LIMIT]. A blank name
* leaves the list as it was.
*/
fun with(names: List<String>, name: String): List<String> {
val typed = name.trim()
if (typed.isEmpty()) return names
return (listOf(typed) + names.filterNot { it.equals(typed, ignoreCase = true) }).take(LIMIT)
}
}
@@ -29,6 +29,7 @@ class ScorekeeperRepository(private val context: Context) {
private object Keys {
val SHEET = stringPreferencesKey("sheet")
val RECENT_NAMES = stringPreferencesKey("recent_names")
}
// Unknown keys are ignored so a sheet written by a newer build can still be
@@ -45,6 +46,24 @@ class ScorekeeperRepository(private val context: Context) {
?: ScoreSheet()
}
/** The names last used on a sheet, most recent first, for the rename dialog. */
val recentNames: Flow<List<String>> = context.scorekeeperDataStore.data.map { prefs ->
prefs[Keys.RECENT_NAMES]
?.let { stored ->
runCatching { json.decodeFromString<List<String>>(stored) }.getOrNull()
}
?: emptyList()
}
suspend fun rememberName(name: String) {
context.scorekeeperDataStore.edit { prefs ->
val current = prefs[Keys.RECENT_NAMES]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
.orEmpty()
prefs[Keys.RECENT_NAMES] = json.encodeToString(RecentNames.with(current, name))
}
}
suspend fun save(sheet: ScoreSheet) {
context.scorekeeperDataStore.edit { it[Keys.SHEET] = json.encodeToString(sheet) }
}
@@ -31,8 +31,18 @@ sealed interface SheetOp {
/** Sets a fresh sheet up for [count] players, scored by round or by tally. */
@Serializable
@SerialName("start")
data class StartSheet(val count: Int, val mode: ScoringMode = ScoringMode.ROUNDS) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count, mode)
data class StartSheet(
val count: Int,
val mode: ScoringMode = ScoringMode.ROUNDS,
/** Names for the first seats, in order; a seat past the end stays unnamed. */
val names: List<String> = emptyList(),
) : SheetOp {
override fun applyTo(sheet: ScoreSheet): ScoreSheet {
val seated = ScoreSheet.of(count, mode)
return seated.players.foldIndexed(seated) { index, acc, player ->
names.getOrNull(index)?.let { acc.renamed(player.id, it) } ?: acc
}
}
}
/** Rubs everything out. */
@@ -24,6 +24,8 @@ data class Settings(
val soundEnabled: Boolean = true,
val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true,
/** Multiplies the system font size across the whole app. */
val textScale: Float = 1.0f,
/**
* Exactly what the user has typed, which may be blank while they are
* mid-edit. Nothing substitutes a default into this value: doing so meant
@@ -46,6 +48,8 @@ data class Settings(
companion object {
const val MIN_SPEED = 0.5f
const val MAX_SPEED = 2.0f
const val MIN_TEXT_SCALE = 0.8f
const val MAX_TEXT_SCALE = 1.6f
const val DEFAULT_PLAYER_NAME = "Player"
}
}
@@ -56,6 +60,7 @@ class SettingsRepository(private val context: Context) {
val SOUND = booleanPreferencesKey("sound_enabled")
val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val TEXT_SCALE = floatPreferencesKey("text_scale")
val NAME = stringPreferencesKey("player_name")
// There is deliberately no update-channel key. The channel is a
// property of the installed APK, not a preference — an install that
@@ -69,6 +74,8 @@ class SettingsRepository(private val context: Context) {
soundEnabled = prefs[Keys.SOUND] ?: true,
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
textScale = (prefs[Keys.TEXT_SCALE] ?: 1.0f)
.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE),
playerName = prefs[Keys.NAME] ?: "",
)
}
@@ -83,6 +90,12 @@ class SettingsRepository(private val context: Context) {
}
}
suspend fun setTextScale(scale: Float) {
context.dataStore.edit {
it[Keys.TEXT_SCALE] = scale.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE)
}
}
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
}
@@ -0,0 +1,42 @@
package org.prolibertate.games.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
import org.prolibertate.games.game.yahtzee.YahtzeeRecord
import org.prolibertate.games.game.yahtzee.compareToHistory
private val Context.yahtzeeStatsStore: DataStore<Preferences> by preferencesDataStore(
name = "yahtzee_stats",
)
/** The local player's finished Yahtzee games, newest last, kept to the last [KEEP]. */
class YahtzeeStatsRepository(private val context: Context) {
private val key = stringPreferencesKey("games")
private val json = Json { ignoreUnknownKeys = true }
private fun decode(stored: String?): List<YahtzeeRecord> =
stored?.let { runCatching { json.decodeFromString<List<YahtzeeRecord>>(it) }.getOrNull() }
.orEmpty()
/** Compares [record] with every earlier game, then adds it to the history. */
suspend fun finish(record: YahtzeeRecord): YahtzeeComparison {
val previous = decode(context.yahtzeeStatsStore.data.first()[key])
context.yahtzeeStatsStore.edit {
it[key] = json.encodeToString((previous + record).takeLast(KEEP))
}
return compareToHistory(previous, record)
}
private companion object {
const val KEEP = 500
}
}
@@ -20,6 +20,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -29,6 +31,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -47,9 +51,15 @@ import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.settings.YahtzeeStatsRepository
import org.prolibertate.games.ui.game.LocalCardSpeed
import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.prolibertate.games.game.engine.AiDifficulty
import org.prolibertate.games.game.engine.SavedGameStore
import org.prolibertate.games.game.engine.SavedMatch
/** Everything the screens need, assembled once by the activity. */
class AppEnv(
@@ -60,6 +70,9 @@ class AppEnv(
/** The shared score sheet, for the Scorekeeper to host or join. */
val scoreSession: ScorekeeperSession,
val peerId: String,
/** Games in progress, kept between sessions. */
val saves: SavedGameStore,
val yahtzeeStats: YahtzeeStatsRepository,
)
/**
@@ -76,13 +89,23 @@ sealed interface Route {
data class Section(val menu: GameMenu) : Route
data object Settings : Route
data object Scorekeeper : Route
/** Every saved game, to carry on from or delete. */
data object Resume : Route
data class Setup(val gameId: String) : Route
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
data class Lobby(
val gameId: String,
val optionsJson: String,
val hosting: Boolean,
val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
) : Route
data class Play(
val gameId: String,
val config: TableConfig,
val hosting: Boolean,
val localSeat: Int,
/** A saved position to carry on from, as the game's rules encoded it; null deals a new game. */
val resumeState: String? = null,
) : Route
}
@@ -129,7 +152,14 @@ fun AppRoot(env: AppEnv) {
// Card motion reads its speed from here rather than from a parameter.
// Every card screen animates, most of them several layers deep, and the
// alternative was passing one unchanging number down through all of it.
CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
val density = LocalDensity.current
val scaledDensity = remember(density, settings.textScale) {
Density(density.density, density.fontScale * settings.textScale)
}
CompositionLocalProvider(
LocalCardSpeed provides settings.animationSpeed,
LocalDensity provides scaledDensity,
) {
AppContent(
env = env,
settings = settings,
@@ -167,6 +197,8 @@ private fun AppContent(
// Dev builds show the games that are not finished yet; production
// releases list only what can actually be played.
showComingSoon = showComingSoon,
hasSaved = savedGames(env, stack).isNotEmpty(),
onResume = { push(Route.Resume) },
onPickSection = { push(Route.Section(it)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onScorekeeper = { push(Route.Scorekeeper) },
@@ -181,6 +213,25 @@ private fun AppContent(
onBack = pop,
)
is Route.Resume -> {
// Bumped when a game is deleted, so the list is read again.
var deletions by remember(route) { mutableIntStateOf(0) }
val saved by produceState(emptyList<SavedMatch>(), route, deletions) {
value = withContext(Dispatchers.IO) { env.saves.all() }
}
ResumeScreen(
saved = saved,
onResume = { match ->
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
},
onDelete = { match ->
env.saves.clear(match.gameId)
deletions++
},
onBack = pop,
)
}
is Route.Settings -> SettingsScreen(
env = env,
settings = settings,
@@ -194,15 +245,30 @@ private fun AppContent(
onBack = { pop() },
)
is Route.Setup -> GameSetupScreen(
descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.displayName,
onBack = { pop() },
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
onHostOnline = { optionsJson ->
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
},
)
is Route.Setup -> {
// Bumped when the saved game is thrown away, so the banner goes with it.
var discards by remember(route) { mutableIntStateOf(0) }
val saved by produceState<SavedMatch?>(null, route, discards) {
value = withContext(Dispatchers.IO) { env.saves.load(route.gameId) }
}
GameSetupScreen(
descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.displayName,
onBack = { pop() },
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
onHostOnline = { optionsJson, aiDifficulty ->
push(Route.Lobby(route.gameId, optionsJson, hosting = true, aiDifficulty = aiDifficulty))
},
saved = saved,
onResume = { match ->
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
},
onDiscardSaved = {
env.saves.clear(route.gameId)
discards++
},
)
}
is Route.Lobby -> LobbyScreen(
env = env,
@@ -231,6 +297,8 @@ private fun AppContent(
@Composable
fun MainMenuScreen(
showComingSoon: Boolean,
hasSaved: Boolean,
onResume: () -> Unit,
onPickSection: (GameMenu) -> Unit,
onJoinGame: () -> Unit,
onScorekeeper: () -> Unit,
@@ -280,6 +348,14 @@ fun MainMenuScreen(
)
}
// Games left half-played, above everything else: coming back to
// the app is mostly coming back to one.
if (hasSaved) {
Button(onClick = onResume, modifier = Modifier.fillMaxWidth()) {
Text("Resume game")
}
}
// Below the games, and full width like them, so the column
// stays one column all the way down.
OutlinedButton(
@@ -364,7 +440,11 @@ fun SectionScreen(
}
}
items(games) { game ->
GameTile(game = game, onClick = { onPickGame(game) })
GameTile(
game = game,
playable = game.playableIn(devBuild = showComingSoon),
onClick = { onPickGame(game) },
)
}
}
}
@@ -403,12 +483,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) {
Card(
onClick = onClick,
enabled = game.available,
enabled = playable,
colors = CardDefaults.cardColors(
containerColor = if (game.available) {
containerColor = if (playable) {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.surface
@@ -427,13 +507,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
val players = when {
// A puzzle for one, rather than "1 players".
game.maxPlayers == 1 -> "On your own"
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
else -> "${game.minPlayers}–${game.maxPlayers} players"
}
Text(
text = when {
!game.available -> "Coming soon"
// A puzzle for one, rather than "1 players".
game.maxPlayers == 1 -> "On your own"
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
else -> "${game.minPlayers}–${game.maxPlayers} players"
!playable -> "Coming soon"
// Playable on this dev build only; production hides it.
!game.available -> "Preview · $players"
else -> players
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 8.dp),
@@ -481,3 +566,17 @@ fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) {
Text(text)
}
}
/**
* The saved games, read off the main thread each time the menu is the screen
* showing — which is when a game has just been left and its last position
* written, so the list is not read until that has happened.
*/
@Composable
private fun savedGames(env: AppEnv, stack: List<Route>): List<SavedMatch> {
val onMenu = stack.last() is Route.Menu
val saved by produceState(emptyList<SavedMatch>(), onMenu, stack.size) {
value = if (onMenu) withContext(Dispatchers.IO) { env.saves.all() } else emptyList()
}
return saved
}
@@ -0,0 +1,21 @@
package org.prolibertate.games.ui
import androidx.compose.runtime.staticCompositionLocalOf
/**
* How a screen's "End game" button tells the table to forget the saved game.
*
* Leaving a table keeps its position so it can be resumed; ending one on purpose
* does not. The two arrive at the screen as the same "go back", so the
* difference is carried here instead: [PlayScreen] provides one handle, the match
* it builds says what ending means for it by setting [onEnd], and each End-game
* dialog calls [end] before it leaves.
*/
class EndGameHandle {
/** Set by the table once it knows how to forget its own saved position. */
var onEnd: () -> Unit = {}
fun end() = onEnd()
}
val LocalEndGameHandle = staticCompositionLocalOf { EndGameHandle() }
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Divider
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -27,14 +30,13 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.AiDifficulty
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.SavedMatch
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.backgammon.BackgammonLevel
import org.prolibertate.games.game.backgammon.BackgammonOptions
import org.prolibertate.games.game.checkers.CheckersLevel
import org.prolibertate.games.game.checkers.CheckersOptions
import org.prolibertate.games.game.chess.ChessLevel
import org.prolibertate.games.game.chess.ChessOptions
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
import org.prolibertate.games.game.cribbage.CribbageOptions
@@ -46,17 +48,19 @@ import org.prolibertate.games.game.freecell.FreeCellOptions
import org.prolibertate.games.game.pyramid.PyramidOptions
import org.prolibertate.games.game.spider.SpiderOptions
import org.prolibertate.games.game.yahtzee.YahtzeeOptions
import org.prolibertate.games.game.meowdoku.MeowdokuGenerator
import org.prolibertate.games.game.meowdoku.MeowdokuOptions
import org.prolibertate.games.game.pegsolitaire.PegBoard
import org.prolibertate.games.game.pegsolitaire.PegGoal
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
import org.prolibertate.games.game.kaiser.KaiserOptions
import org.prolibertate.games.game.mastermind.MastermindLevel
import org.prolibertate.games.game.mastermind.MastermindOptions
import org.prolibertate.games.game.morris.MorrisLevel
import org.prolibertate.games.game.morris.MorrisOptions
import org.prolibertate.games.game.pirates.PiratesLevel
import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.SeaScenario
import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions
@@ -80,7 +84,11 @@ fun GameSetupScreen(
playerName: String,
onBack: () -> Unit,
onPlayOffline: (TableConfig, Int) -> Unit,
onHostOnline: (String) -> Unit,
onHostOnline: (String, AiDifficulty) -> Unit,
/** A game of this kind left half-played, if there is one. */
saved: SavedMatch? = null,
onResume: (SavedMatch) -> Unit = {},
onDiscardSaved: () -> Unit = {},
) {
var euchre by remember { mutableStateOf(EuchreOptions()) }
var sequence by remember { mutableStateOf(SequenceOptions()) }
@@ -99,17 +107,20 @@ fun GameSetupScreen(
var pirates by remember { mutableStateOf(PiratesOptions()) }
var hearts by remember { mutableStateOf(HeartsOptions()) }
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
var meowdoku by remember { mutableStateOf(MeowdokuOptions()) }
var yahtzee by remember { mutableStateOf(YahtzeeOptions()) }
var klondike by remember { mutableStateOf(KlondikeOptions()) }
var freecell by remember { mutableStateOf(FreeCellOptions()) }
var spider by remember { mutableStateOf(SpiderOptions()) }
var pyramid by remember { mutableStateOf(PyramidOptions()) }
var catan by remember { mutableStateOf(CatanOptions()) }
// Not an engine option: which seat the local player takes. Seat 0 is
// always White, so choosing Black means sitting in seat 1.
var playWhite by remember { mutableStateOf(true) }
// Pirates and Bulgars is not symmetrical, so which side you take is the
// first thing about the game rather than a detail of it.
var playPirates by remember { mutableStateOf(false) }
var aiDifficulty by remember { mutableStateOf(AiDifficulty.MEDIUM) }
val optionsJson = when (descriptor.id) {
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
@@ -129,11 +140,13 @@ fun GameSetupScreen(
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
GameCatalog.MEOWDOKU -> setupJson.encodeToString(meowdoku)
GameCatalog.YAHTZEE -> setupJson.encodeToString(yahtzee)
GameCatalog.KLONDIKE -> setupJson.encodeToString(klondike)
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
GameCatalog.SPIDER -> setupJson.encodeToString(spider)
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
GameCatalog.CATAN -> setupJson.encodeToString(catan)
else -> "{}"
}
val seatCount = seatCountFor(descriptor.id, optionsJson)
@@ -145,6 +158,10 @@ fun GameSetupScreen(
) {
Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium)
if (saved != null) {
SavedGameBanner(saved, onResume = { onResume(saved) }, onDiscard = onDiscardSaved)
}
Divider()
Text("House rules", fontWeight = FontWeight.Bold)
@@ -189,6 +206,8 @@ fun GameSetupScreen(
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
GameCatalog.MEOWDOKU -> MeowdokuOptionsEditor(meowdoku) { meowdoku = it }
GameCatalog.YAHTZEE -> YahtzeeOptionsEditor(yahtzee) { yahtzee = it }
GameCatalog.KLONDIKE -> KlondikeOptionsEditor(klondike) { klondike = it }
@@ -199,6 +218,8 @@ fun GameSetupScreen(
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
else -> Text("No options yet for this game.")
}
@@ -219,6 +240,22 @@ fun GameSetupScreen(
style = MaterialTheme.typography.bodySmall,
)
if (!solo) {
ChipRow(
label = "Computer difficulty",
values = AiDifficulty.entries.toList(),
selected = aiDifficulty,
display = { it.label },
onSelect = { aiDifficulty = it },
)
if (aiDifficulty == AiDifficulty.MIXED) {
Text(
"Each computer player gets its own level.",
style = MaterialTheme.typography.bodySmall,
)
}
}
// Two games let the local player pick a seat rather than always
// taking the first one: chess by colour, and Pirates and Bulgars by
// which side of a very lopsided fight they fancy.
@@ -227,15 +264,21 @@ fun GameSetupScreen(
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
else -> 0
}
if (saved != null) {
Text(
"Starting a new game replaces the one in progress.",
style = MaterialTheme.typography.bodySmall,
)
}
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
onPlayOffline(
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat, aiDifficulty),
localSeat,
)
}
if (!solo) {
PrimaryAction(text = "Host a game for others to join") {
onHostOnline(optionsJson)
onHostOnline(optionsJson, aiDifficulty)
}
}
}
@@ -249,6 +292,7 @@ private fun offlineConfig(
seatCount: Int,
playerName: String,
localSeat: Int = 0,
aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
): TableConfig = TableConfig(
gameId = gameId,
seats = (0 until seatCount).map { seat ->
@@ -262,6 +306,7 @@ private fun offlineConfig(
optionsJson = optionsJson,
// Seeded from the clock so consecutive games are not identical.
seed = System.currentTimeMillis(),
aiDifficulty = aiDifficulty,
)
/**
@@ -303,6 +348,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
GameCatalog.TAYU ->
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
GameCatalog.CATAN ->
setupJson.decodeFromString<CatanOptions>(optionsJson).playerCount
else -> fallback
}
}.getOrDefault(fallback)
@@ -326,6 +374,90 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
else -> seat
}
/** The targets the setup screen offers for a Katan game with [expansions] on. */
internal fun catanPointsChoices(expansions: Set<CatanExpansion>): List<Int> = when {
// Seafarers adds two points for each new island, so the target is higher.
CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> listOf(8, 10, 13, 15)
// Five is a short game for trying things out; ten is the published target.
else -> listOf(5, 8, 10, 12)
}
/** The published target for a game with [expansions] on. */
internal fun catanDefaultPoints(expansions: Set<CatanExpansion>): Int = when {
CatanExpansion.SEAFARERS in expansions -> 12
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> 13
else -> 10
}
/**
* [options] with [expansion] switched on or off. The target moves to the new combination's own
* default unless the one already chosen is on its menu too. Expansions cannot be combined yet,
* so switching one on switches the other off.
*/
internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions {
val expansions = if (on) setOf(expansion) else options.expansions - expansion
return options.copy(
expansions = expansions,
pointsToWin = options.pointsToWin.takeIf {
it in catanPointsChoices(expansions) && it != catanDefaultPoints(options.expansions)
} ?: catanDefaultPoints(expansions),
)
}
private fun catanExpansionName(expansion: CatanExpansion): String = when (expansion) {
CatanExpansion.CITIES_AND_KNIGHTS -> "Cities & Knights"
CatanExpansion.SEAFARERS -> "Seafarers"
}
private fun catanExpansionBlurb(expansion: CatanExpansion): String = when (expansion) {
CatanExpansion.CITIES_AND_KNIGHTS ->
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
"cards and metropolises. No development cards; the robber sleeps until the first attack."
CatanExpansion.SEAFARERS ->
"Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " +
"and the first settlement on each new island scores two extra points."
}
@Composable
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
CatanExpansion.entries.forEach { expansion ->
ToggleRow(
title = catanExpansionName(expansion),
subtitle = catanExpansionBlurb(expansion),
checked = expansion in options.expansions,
onChange = { onChange(catanToggleExpansion(options, expansion, it)) },
)
}
if (options.seafarers) {
ChipRow(
label = "Map",
values = SeaScenario.entries,
selected = options.scenario,
display = { it.title },
onSelect = { onChange(options.copy(scenario = it)) },
)
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
}
ChipRow(
label = "Players",
values = listOf(3, 4),
selected = options.playerCount,
display = { "$it players" },
onSelect = { onChange(options.copy(playerCount = it)) },
)
ChipRow(
label = "Game to",
values = catanPointsChoices(options.expansions),
selected = options.pointsToWin,
display = { "$it points" },
onSelect = { onChange(options.copy(pointsToWin = it)) },
)
}
}
@Composable
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -659,13 +791,6 @@ private fun ChessOptionsEditor(
display = { if (it) "White" else "Black" },
onSelect = onColour,
)
ChipRow(
label = "Opponent",
values = ChessLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "Fifty-move rule",
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
@@ -689,13 +814,6 @@ private fun ChessOptionsEditor(
@Composable
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = CheckersLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "Flying kings",
subtitle = "A king slides the length of a diagonal and takes from a distance, " +
@@ -730,13 +848,6 @@ private fun BackgammonOptionsEditor(
onChange: (BackgammonOptions) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = BackgammonLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "Count gammons",
subtitle = "A loser who bore nothing off has lost double, and one still on the " +
@@ -758,13 +869,6 @@ private fun MastermindOptionsEditor(
onChange: (MastermindOptions) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = MastermindLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ChipRow(
label = "Colours",
values = listOf(6, 8),
@@ -829,13 +933,6 @@ private fun PiratesOptionsEditor(
display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
onSelect = onSide,
)
ChipRow(
label = "Opponent",
values = PiratesLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "A pirate who can take must take",
subtitle = "The rule that huffing exists to enforce, applied directly",
@@ -861,13 +958,6 @@ private fun PiratesOptionsEditor(
@Composable
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = MorrisLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ChipRow(
label = "Pieces each",
values = listOf(3, 6, 9),
@@ -1041,6 +1131,36 @@ private fun PegSolitaireOptionsEditor(
}
}
@Composable
private fun MeowdokuOptionsEditor(
options: MeowdokuOptions,
onChange: (MeowdokuOptions) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Board",
values = (MeowdokuGenerator.MIN_SIDE..MeowdokuGenerator.MAX_SIDE).toList(),
selected = options.side,
display = { "$it × $it" },
onSelect = { onChange(options.copy(side = it)) },
)
ChipRow(
label = "Hearts",
values = (1..5).toList(),
selected = options.hearts,
display = { if (it == 1) "One" else "$it" },
onSelect = { onChange(options.copy(hearts = it)) },
)
ChipRow(
label = "Hints",
values = (0..3).toList(),
selected = options.hints,
display = { if (it == 0) "None" else "$it" },
onSelect = { onChange(options.copy(hints = it)) },
)
}
}
@Composable
private fun YahtzeeOptionsEditor(options: YahtzeeOptions, onChange: (YahtzeeOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1141,3 +1261,30 @@ private fun PyramidOptionsEditor(options: PyramidOptions, onChange: (PyramidOpti
onSelect = { onChange(options.copy(redeals = it)) },
)
}
/**
* The game that was left unfinished, with the two things to do about it. Shown
* above the options so it is the first thing seen on coming back to a game.
*/
@Composable
private fun SavedGameBanner(saved: SavedMatch, onResume: () -> Unit, onDiscard: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("Game in progress", fontWeight = FontWeight.Bold)
Text(
buildString {
append("Saved ${agoText(System.currentTimeMillis(), saved.savedAtMillis)}")
if (saved.summary.isNotBlank()) append(" · ${saved.summary}")
},
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onResume) { Text("Resume") }
TextButton(onClick = onDiscard) { Text("Discard") }
}
}
}
}
@@ -49,7 +49,7 @@ fun LaunchUpdateCheck(env: AppEnv, visible: Boolean) {
}
// Held back behind the splash, so an update prompt cannot appear over the
// splash before the app itself has.
// tartan before the app itself has.
val pending = release
if (pending == null || dismissed || !visible) return
@@ -60,6 +60,7 @@ fun LobbyScreen(
hostName = playerName,
seatCount = seatCount,
teamOf = { seat -> teamForSeat(route.gameId, seat, seatCount) },
aiDifficulty = route.aiDifficulty,
)
} else {
env.lobby.startDiscovery()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
package org.prolibertate.games.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.SavedMatch
/**
* Every game that can be carried on from, most recently played first, each with
* a way to pick it up and a way to throw it away.
*/
@Composable
fun ResumeScreen(
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
onDelete: (SavedMatch) -> Unit,
onBack: () -> Unit,
) {
var deleting by remember { mutableStateOf<SavedMatch?>(null) }
ScreenScaffold(title = "Resume game", onBack = onBack) { modifier ->
if (saved.isEmpty()) {
Text("No games to resume.", modifier = modifier)
} else {
val now = System.currentTimeMillis()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(saved, key = { it.gameId }) { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(title, fontWeight = FontWeight.Bold)
Text(
buildString {
append("Saved ${agoText(now, match.savedAtMillis)}")
if (match.summary.isNotBlank()) append(" · ${match.summary}")
},
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onResume(match) }) { Text("Resume") }
TextButton(onClick = { deleting = match }) { Text("Delete") }
}
}
}
}
}
}
}
deleting?.let { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Delete this game?") },
text = { Text("Your saved game of $title will be removed and cannot be resumed.") },
confirmButton = {
TextButton(onClick = { deleting = null; onDelete(match) }) { Text("Delete") }
},
dismissButton = { TextButton(onClick = { deleting = null }) { Text("Keep it") } },
)
}
}
@@ -0,0 +1,24 @@
package org.prolibertate.games.ui
import org.prolibertate.games.game.engine.SavedMatch
/** How long ago something happened, in the words a person would use. */
fun agoText(nowMillis: Long, thenMillis: Long): String {
val seconds = ((nowMillis - thenMillis) / 1000).coerceAtLeast(0)
val minutes = seconds / 60
val hours = minutes / 60
val days = hours / 24
return when {
seconds < 45 -> "just now"
minutes < 2 -> "a minute ago"
minutes < 60 -> "$minutes minutes ago"
hours < 2 -> "an hour ago"
hours < 24 -> "$hours hours ago"
days < 2 -> "yesterday"
else -> "$days days ago"
}
}
/** "Chess · saved 5 minutes ago" for a menu line. */
fun savedLine(title: String, match: SavedMatch, nowMillis: Long): String =
"$title · saved ${agoText(nowMillis, match.savedAtMillis)}"
@@ -36,6 +36,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -114,6 +115,8 @@ fun ScorekeeperScreen(
local = repository.sheet.first()
loaded = true
}
val recentNames by repository.recentNames.collectAsState(initial = emptyList())
val nameUsed: (String) -> Unit = { name -> scope.launch { repository.rememberName(name) } }
val shared by session.state.collectAsState()
val role = shared.role
@@ -220,7 +223,7 @@ fun ScorekeeperScreen(
role == Role.GUEST -> Column(modifier = modifier) {
ConnectionBanner(shared = shared, onSync = { session.sync() })
Scoring(modifier = Modifier.weight(1f), sheet = sheet, onOp = change)
Scoring(Modifier.weight(1f), sheet, change, recentNames, nameUsed)
}
joining && role == Role.NONE ->
@@ -228,11 +231,16 @@ fun ScorekeeperScreen(
!sheet.started -> SetupPane(
modifier = modifier,
onStart = { count, mode -> change(StartSheet(count, mode)) },
recentNames = recentNames,
onStart = { count, mode, names ->
change(StartSheet(count, mode, names))
// Last first, so the list reads in seating order.
names.reversed().forEach(nameUsed)
},
onJoin = { joining = true },
)
else -> Scoring(modifier = modifier, sheet = sheet, onOp = change)
else -> Scoring(modifier, sheet, change, recentNames, nameUsed)
}
}
@@ -289,9 +297,46 @@ fun ScorekeeperScreen(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, onJoin: () -> Unit) {
private fun SetupPane(
modifier: Modifier,
recentNames: List<String>,
onStart: (Int, ScoringMode, List<String>) -> Unit,
onJoin: () -> Unit,
) {
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
var mode by remember { mutableStateOf(ScoringMode.ROUNDS) }
// -1 while the options are showing; otherwise the seat being named.
var naming by remember { mutableStateOf(-1) }
val names = remember { mutableStateListOf<String>() }
if (naming >= 0) {
NamePane(
modifier = modifier,
seat = naming,
count = count,
name = names.getOrElse(naming) { "" },
// Offered once only: two players on one sheet with the same name
// are indistinguishable.
suggestions = recentNames.filter { recent ->
names.withIndex().none { (seat, taken) ->
seat != naming && taken.trim().equals(recent.trim(), ignoreCase = true)
}
},
onName = { typed ->
while (names.size <= naming) names.add("")
names[naming] = typed
},
onBack = { naming -= 1 },
onNext = {
if (naming + 1 < count) {
naming += 1
} else {
onStart(count, mode, List(count) { names.getOrElse(it) { "" }.trim() })
}
},
)
return
}
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
@@ -339,33 +384,98 @@ private fun SetupPane(modifier: Modifier, onStart: (Int, ScoringMode) -> Unit, o
}
}
Text(
text = "Names come next: tap a column head to name it. Players can be " +
"added, removed or dragged into a different order at any point " +
"without losing anybody's score.",
text = "You will be asked for each name next, or skip them and tap a " +
"column head later. Players can be added, removed or dragged into " +
"a different order at any point without losing anybody's score.",
style = MaterialTheme.typography.bodySmall,
)
PrimaryAction(text = "Start scoring") { onStart(count, mode) }
PrimaryAction(text = "Next: name the players") { naming = 0 }
OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) {
Text("Join a shared sheet nearby")
}
}
}
/** One seat's name, asked for on its own; a blank leaves the seat as "Player N". */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun NamePane(
modifier: Modifier,
seat: Int,
count: Int,
name: String,
suggestions: List<String>,
onName: (String) -> Unit,
onBack: () -> Unit,
onNext: () -> Unit,
) {
val focus = remember { FocusRequester() }
LaunchedEffect(seat) { focus.requestFocus() }
val last = seat + 1 >= count
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Player ${seat + 1} of $count", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = name,
onValueChange = onName,
placeholder = { Text("Player ${seat + 1}") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = if (last) ImeAction.Done else ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { onNext() }, onDone = { onNext() }),
modifier = Modifier.fillMaxWidth().focusRequester(focus),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { recent ->
FilterChip(
selected = recent.equals(name.trim(), ignoreCase = true),
onClick = { onName(recent) },
label = { Text(recent) },
)
}
}
}
PrimaryAction(text = if (last) "Start scoring" else "Next") { onNext() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { onName(""); onNext() }) { Text(if (last) "Skip and start" else "Skip") }
}
}
}
// ---------------------------------------------------------------------------
// The sheet: a column per player, a row per round, totals ruled off at the foot
// ---------------------------------------------------------------------------
@Composable
private fun Scoring(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Scoring(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
if (sheet.mode == ScoringMode.TALLY) {
TallySheet(modifier = modifier, sheet = sheet, onOp = onOp)
TallySheet(modifier, sheet, onOp, recentNames, onNameUsed)
} else {
Sheet(modifier = modifier, sheet = sheet, onOp = onOp)
Sheet(modifier, sheet, onOp, recentNames, onNameUsed)
}
}
@Composable
private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun Sheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
// One scroll state shared by the head, the rows and the totals, so all three
// move together: a column and the name over it must never come apart. Every
// row is built the same way and is therefore exactly the same width, which
@@ -384,6 +494,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
var lastTyped by remember { mutableStateOf(-1) }
// The cell to put the cursor in when a round is opened by tapping it.
var focusPlayer by remember { mutableStateOf(-1) }
// Bumped when a round is finished, to put the cursor back in the first cell
// of the row that is now being filled in.
var nextRoundFocus by remember { mutableStateOf(0) }
val editing = if (correcting >= 0) correction else pending
@@ -513,7 +626,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
)
},
trailing = { EmptyCell(width = ADD_COLUMN, height = ROW_HEIGHT) },
) { _, player ->
) { index, player ->
if (correcting >= 0) {
// A round is being corrected above; what is typed here
// is kept, but only one row is edited at a time.
@@ -521,6 +634,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
} else {
PointsField(
text = pending[player.id].orEmpty(),
autoFocus = index == 0 && nextRoundFocus > 0,
focusRequest = nextRoundFocus,
onText = {
pending[player.id] = it
lastTyped = player.id
@@ -563,6 +678,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
onOp(AddRound(amountsAsPoints(pending)))
pending.clear()
}
nextRoundFocus += 1
lastTyped = -1
focusPlayer = -1
},
@@ -587,6 +703,8 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -607,7 +725,13 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit
* sheet, so tapping one in the list takes it back off.
*/
@Composable
private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) {
private fun TallySheet(
modifier: Modifier,
sheet: ScoreSheet,
onOp: (SheetOp) -> Unit,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
) {
var renaming by remember { mutableStateOf(-1) }
Column(modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -676,6 +800,8 @@ private fun TallySheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) ->
RenameDialog(
sheet = sheet,
player = renamed,
recentNames = recentNames,
onNameUsed = onNameUsed,
onName = { onOp(RenamePlayer(renamed.id, it)) },
onRemove = {
onOp(RemovePlayer(renamed.id))
@@ -961,9 +1087,14 @@ private fun PointsCell(text: String, muted: Boolean = false, onClick: (() -> Uni
/** A cell being typed into. Blank means nothing entered; a typed 0 is kept and shown. */
@Composable
private fun PointsField(text: String, onText: (String) -> Unit, autoFocus: Boolean = false) {
private fun PointsField(
text: String,
onText: (String) -> Unit,
autoFocus: Boolean = false,
focusRequest: Int = 0,
) {
val focus = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focus.requestFocus() }
LaunchedEffect(autoFocus, focusRequest) { if (autoFocus) focus.requestFocus() }
BasicTextField(
value = text,
onValueChange = { onText(sanitiseAmount(it)) },
@@ -1064,16 +1195,27 @@ private fun ActionBar(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenameDialog(
sheet: ScoreSheet,
player: ScorePlayer,
recentNames: List<String>,
onNameUsed: (String) -> Unit,
onName: (String) -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
// Closing the dialog is what files the name away, so a name is remembered
// once rather than once per keystroke.
val finish = {
onNameUsed(player.name)
onDismiss()
}
val taken = sheet.players.filter { it.id != player.id }.map { it.name.trim().lowercase() }
val suggestions = recentNames.filter { it.trim().lowercase() !in taken }
AlertDialog(
onDismissRequest = onDismiss,
onDismissRequest = finish,
title = { Text("Name this column") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -1084,6 +1226,21 @@ private fun RenameDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Text("Recent names", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
suggestions.forEach { name ->
FilterChip(
selected = name.equals(player.name.trim(), ignoreCase = true),
onClick = { onName(name) },
label = { Text(name) },
)
}
}
}
TextButton(
onClick = onRemove,
// The last two columns stay: a sheet needs somebody on it.
@@ -1091,7 +1248,7 @@ private fun RenameDialog(
) { Text("Remove this player and their score") }
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
confirmButton = { TextButton(onClick = finish) { Text("Done") } },
)
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.Release
import org.prolibertate.games.update.UpdateChannel
@@ -83,6 +84,26 @@ fun SettingsScreen(
Divider()
Column {
Text("Text size", fontWeight = FontWeight.Bold)
Text(
text = "${(settings.textScale * 100).roundToInt()}% — " +
"scales text throughout the app, on top of your phone's setting",
style = MaterialTheme.typography.bodySmall,
)
Slider(
value = settings.textScale,
onValueChange = { scale ->
scope.launch { env.settingsRepository.setTextScale(scale) }
},
valueRange = Settings.MIN_TEXT_SCALE..Settings.MAX_TEXT_SCALE,
// 10% increments across 80%-160%.
steps = 7,
)
}
Divider()
Column {
Text("Your name", fontWeight = FontWeight.Bold)
Text(
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -378,7 +379,12 @@ private fun LeaveDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -79,7 +80,12 @@ fun PatienceEndDialog(what: String, onEnd: () -> Unit, onKeepPlaying: () -> Unit
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text(what) },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -314,7 +315,12 @@ private fun EndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -215,7 +216,12 @@ private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -60,6 +60,22 @@ fun CrazyEightsScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
// Playing an eight is two decisions: the card, then the suit it turns the
@@ -68,13 +84,15 @@ fun CrazyEightsScreen(
val current = state
if (current == null) {
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
@@ -65,11 +65,28 @@ fun CribbageScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
@@ -79,7 +96,8 @@ fun CribbageScreen(
// together. Forgotten whenever a new hand is dealt.
var chosen by remember(current.handNumber) { mutableStateOf(emptySet<Card>()) }
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
@@ -72,18 +73,36 @@ fun EuchreScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.17f else 0.12f)
@@ -69,18 +69,36 @@ fun GolfScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
var mode by remember { mutableStateOf(PlaceMode.SWAP) }
val current = state
if (current == null) {
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val shortest = minOf(maxWidth, maxHeight)
val ownCard = (shortest * 0.16f).coerceIn(40.dp, 84.dp)
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -268,7 +269,12 @@ private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -74,18 +75,36 @@ fun KaiserScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.15f else 0.11f)
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -342,7 +343,12 @@ private fun EndTableDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The deal is left as it stands.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -356,7 +357,12 @@ private fun EndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -0,0 +1,379 @@
package org.prolibertate.games.ui.game
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.TextUnit
import org.prolibertate.games.game.meowdoku.MeowdokuMove
import org.prolibertate.games.game.meowdoku.MeowdokuState
import org.prolibertate.games.game.meowdoku.PlaceCat
import org.prolibertate.games.game.meowdoku.Square
import org.prolibertate.games.game.meowdoku.TakeHint
import org.prolibertate.games.game.meowdoku.TogglePencil
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.LocalEndGameHandle
import org.prolibertate.games.ui.ScreenScaffold
import org.prolibertate.games.ui.theme.CardRed
import kotlin.math.abs
import kotlin.math.roundToInt
private const val TITLE = "Meowdoku"
/**
* How long the second tap of a double tap may take to arrive.
*
* 350 ms, the number the original game ships: long enough that a deliberate
* one-two still reads as one question, short enough that nobody pairs two
* taps they meant for two separate squares.
*/
private const val DOUBLE_TAP_WINDOW_MILLIS = 350L
/**
* The region colours, one per cat the board can hold.
*
* The board never needs two regions of one colour — a side-ten board has ten
* regions and the palette has ten — so a region's colour is just its id, and
* nothing has to be computed to keep neighbours apart. The palette reads as
* watercolour on purpose: cats are dark ink on top of it, and a square the
* player has crossed over is too.
*/
private val RegionColours = listOf(
Color(0xFFF2C6C2), // rose
Color(0xFFF7E0A6), // butter
Color(0xFFCDE8C5), // mint
Color(0xFFC5E3F0), // sky
Color(0xFFD8CDEB), // lilac
Color(0xFFEBC9A0), // apricot
Color(0xFFBFE3D0), // spruce
Color(0xFFF0D7E3), // blossom
Color(0xFFCFCFE8), // periwinkle
Color(0xFFE3E7C1), // lime
)
/**
* The puzzle board, tapped the way the original's is.
*
* One tap crosses a square over; a second tap on the same square within
* [DOUBLE_TAP_WINDOW_MILLIS] asks the board whether a cat lives there. Drag
* strokes paint or erase crosses along the line the finger takes, which is
* how a row gets crossed out in one gesture rather than eight taps. Cats are
* permanent: tapping one does nothing, and a stroke skips over it.
*/
@Composable
fun MeowdokuScreen(
controller: MatchController<MeowdokuState, MeowdokuMove>,
onNewPuzzle: () -> Unit,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val current = state
if (current == null) {
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
}
return
}
val places = legal.filterIsInstance<PlaceCat>().map { it.square }.toSet()
val pencils = legal.filterIsInstance<TogglePencil>().map { it.square }.toSet()
val finished by controller.finished.collectAsState()
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = when {
current.solved -> "Found every cat" +
if (current.wrongCats == 0) ", not one tap wasted." else ", with ${current.wrongCats} wrong taps."
current.lost -> "Out of hearts, with ${current.side - current.catsFound} cats still hiding."
else -> "${current.catsFound} of ${current.side} cats found. " +
"Tap to cross a square, tap it again for a cat."
},
fontWeight = FontWeight.Bold,
)
Text(
text = "Hearts " + "♥".repeat(current.heartsLeft) +
" · Hints " + current.hintsLeft,
style = MaterialTheme.typography.bodySmall,
)
Board(
state = current,
// Moves are refused rather than hidden: an attempt or a note
// the board would reject (a tap on a cat, mostly) has to die
// here, in the gesture, where the player can be doing nothing
// else the same instant.
onNote = { square -> if (square in pencils) controller.submit(TogglePencil(square)) },
onAttempt = { square -> if (square in places) controller.submit(PlaceCat(square)) },
)
if (TakeHint in legal) {
FilterChip(
selected = false,
onClick = { controller.submit(TakeHint) },
label = { Text("Hint") },
)
}
if (finished) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onNewPuzzle) { Text("New puzzle") }
TextButton(onClick = onExit) { Text("Leave") }
}
}
current.log.takeLast(2).forEach {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
}
}
/**
* The board as one gesture surface.
*
* Cells are drawn by weight inside a square frame, but input is not per-cell:
* a stroke is one continuous gesture, so the pointer works in board
* coordinates and maps positions to squares itself. The cell list renders
* underneath, and the live state is read through [rememberUpdatedState]
* because a gesture outlives the recompositions it lands in.
*/
@Composable
private fun Board(
state: MeowdokuState,
onNote: (Square) -> Unit,
onAttempt: (Square) -> Unit,
) {
val side = state.side
val current by rememberUpdatedState(state)
val note by rememberUpdatedState(onNote)
val attempt by rememberUpdatedState(onAttempt)
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
// One cell's edge, so a cat is always about half a square whatever
// board was dealt.
val cell = maxWidth / side
Box(
modifier = Modifier
.aspectRatio(1f)
.pointerInput(side) {
val cellPx = size.width.toFloat() / side
var lastTap: Square? = null
var lastTapAt = 0L
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val board = current
val start = squareAt(down.position, cellPx, side)
?: return@awaitEachGesture
if (start in board.cats) return@awaitEachGesture
val now = System.currentTimeMillis()
if (start == lastTap && now - lastTapAt < DOUBLE_TAP_WINDOW_MILLIS) {
// The second tap of a pair: the cat attempt, and
// no note — the cross it lands on was drawn by
// this pair's first tap, and the board replaces it.
lastTap = null
attempt(start)
do {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
change?.consume()
} while (change != null && change.pressed)
return@awaitEachGesture
}
// The first tap: a note, and the stroke that may follow
// paints the same note everywhere it goes.
lastTap = start
lastTapAt = now
val erasing = start in board.marks
note(start)
val marks = board.marks.toMutableSet()
marks.flip(start)
var previous = start
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
change.consume()
val here = squareAt(change.position, cellPx, side) ?: continue
if (here == previous) continue
for (square in path(previous, here)) {
if (square in board.cats) continue
// `marks` is the stroke's own mirror: the base
// board plus every note this stroke has
// already submitted. The state flow lags a
// frame behind submissions, and a stroke that
// crosses a square twice must leave it at the
// stroke's target, not flip it back.
//
// Erasing means "flip everything still
// crossed"; painting means "flip everything
// still blank" — which is the same test read
// two ways: flip while marked == erasing.
if ((square in marks) == erasing) {
note(square)
marks.flip(square)
}
}
previous = here
}
}
}
) {
Column(modifier = Modifier.aspectRatio(1f)) {
for (row in 0 until side) {
Row(modifier = Modifier.weight(1f)) {
for (col in 0 until side) {
CellView(
square = Square(row, col),
state = current,
glyphSize = (cell.value * 0.55f).sp,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
/** Which square a pointer position is over, or null if it has left the board. */
private fun squareAt(position: Offset, cellPx: Float, side: Int): Square? {
val col = (position.x / cellPx).toInt()
val row = (position.y / cellPx).toInt()
if (row !in 0 until side || col !in 0 until side) return null
return Square(row, col)
}
/** Add the square, or drop it if it was already there — the stroke's own toggle. */
private fun MutableSet<Square>.flip(square: Square) {
if (!add(square)) remove(square)
}
/**
* The squares between two consecutive pointer reads, endpoints not repeated.
*
* A fast stroke publishes fewer positions than it crosses cells, so the line
* between two reads is walked in whole-cell steps. [path] returns at most one
* square per cell crossed, in order, which is what lets a stroke cross a row
* in one drag without missing the squares between two events.
*/
private fun path(from: Square, to: Square): List<Square> {
val dr = to.row - from.row
val dc = to.col - from.col
val steps = maxOf(abs(dr), abs(dc))
if (steps <= 1) return listOf(to)
return (1..steps).map { i ->
Square(
from.row + (dr * i.toFloat() / steps).roundToInt(),
from.col + (dc * i.toFloat() / steps).roundToInt(),
)
}
}
@Composable
private fun CellView(
square: Square,
state: MeowdokuState,
glyphSize: TextUnit,
modifier: Modifier,
) {
val isCat = square in state.cats
val isMark = square in state.marks
val justWrong = state.lastWrong && state.lastSquare == square
Box(
modifier = modifier
.aspectRatio(1f)
.border(1.dp, Color(0x33000000))
.background(RegionColours[state.regionOf(square) % RegionColours.size])
.then(
if (justWrong) Modifier.border(3.dp, CardRed) else Modifier
),
contentAlignment = Alignment.Center,
) {
when {
isCat -> Text("🐱", fontSize = glyphSize)
isMark -> Text(
"✕",
color = Color(0xAA1C1B1F),
fontWeight = FontWeight.Bold,
fontSize = glyphSize,
)
}
}
}
@Composable
private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
AlertDialog(
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The board is left as it stands.") },
confirmButton = {
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@Composable
private fun LeftBoardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
AlertDialog(
onDismissRequest = onStay,
title = { Text("The game has ended") },
text = { Text(notice) },
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -483,7 +484,12 @@ private fun LeaveBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -242,7 +243,12 @@ private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The board is left as it stands.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -371,7 +372,12 @@ private fun LeaveFieldDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"at the table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -20,11 +20,15 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -66,16 +70,34 @@ fun PresidentScreen(
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val current = state
if (current == null) {
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
dialogs()
}
return
}
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.15f).coerceIn(44.dp, 88.dp)
@@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -81,12 +82,29 @@ fun SequenceScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
var selected by remember { mutableStateOf<Card?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
@@ -97,7 +115,8 @@ fun SequenceScreen(
val removals = legal.filterIsInstance<RemoveChip>().filter { it.card == selected }
val highlighted = (placements.map { it.cell } + removals.map { it.cell }).toSet()
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight
val boardSide = if (wide) {
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
@@ -668,7 +669,12 @@ private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
"table is told you have left."
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -75,18 +75,36 @@ fun WizardScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.14f).coerceIn(40.dp, 88.dp)
@@ -1,11 +1,16 @@
package org.prolibertate.games.ui.game
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -29,12 +34,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.yahtzee.DICE_COUNT
import org.prolibertate.games.game.yahtzee.RollDice
import org.prolibertate.games.game.yahtzee.ScoreIn
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
import org.prolibertate.games.game.yahtzee.recordOf
import org.prolibertate.games.settings.YahtzeeStatsRepository
import kotlin.math.roundToInt
import org.prolibertate.games.game.yahtzee.YahtzeeMove
import org.prolibertate.games.game.yahtzee.YahtzeeSection
import org.prolibertate.games.game.yahtzee.YahtzeeState
@@ -60,6 +70,7 @@ private const val TITLE = "Yahtzee"
fun YahtzeeScreen(
controller: MatchController<YahtzeeState, YahtzeeMove>,
localSeat: Int,
stats: YahtzeeStatsRepository,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
@@ -68,6 +79,11 @@ fun YahtzeeScreen(
val kept = remember { mutableStateListOf<Int>() }
var confirmingEnd by remember { mutableStateOf(false) }
var confirmingZero by remember { mutableStateOf<YahtzeeCategory?>(null) }
var comparison by remember { mutableStateOf<YahtzeeComparison?>(null) }
// Only a game seen to finish is recorded: reopening a saved game that was
// already over must not count it a second time.
var sawInProgress by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
@@ -88,6 +104,15 @@ fun YahtzeeScreen(
// refused, and the Throw button would look broken again.
LaunchedEffect(current.turn, current.round) { kept.clear() }
LaunchedEffect(current.isOver) {
if (!current.isOver) {
sawInProgress = true
} else if (sawInProgress) {
sawInProgress = false
comparison = stats.finish(recordOf(current, localSeat))
}
}
val yours = current.turn == localSeat && !current.isOver
val canRoll = legal.any { it is RollDice }
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
@@ -137,8 +162,14 @@ fun YahtzeeScreen(
localSeat = localSeat,
writable = if (yours) writable else emptySet(),
onWrite = {
controller.submit(ScoreIn(it))
kept.clear()
// A zero is a box spent for nothing, and one mis-tap on a
// dense card is all it takes, so ask first.
if (scoreOf(it, current.dice) == 0) {
confirmingZero = it
} else {
controller.submit(ScoreIn(it))
kept.clear()
}
},
)
@@ -149,9 +180,79 @@ fun YahtzeeScreen(
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
comparison?.let { StatsDialog(it, onExit) { comparison = null } }
confirmingZero?.let { category ->
ZeroScoreDialog(
category = category,
onConfirm = {
confirmingZero = null
controller.submit(ScoreIn(category))
kept.clear()
},
onCancel = { confirmingZero = null },
)
}
}
}
@Composable
private fun StatsDialog(c: YahtzeeComparison, onLeave: () -> Unit, onClose: () -> Unit) {
fun pct(v: Double?) = v?.let { "${(it * 100).roundToInt()}%" } ?: "—"
val headline = when {
c.previousGames == 0 -> "Your first recorded game."
c.rank == 1 -> "A new personal best!"
else -> "${ordinal(c.rank)} best of ${c.gamesRanked} games."
}
AlertDialog(
onDismissRequest = onClose,
title = { Text("Game over: ${c.current.total} points") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(headline, fontWeight = FontWeight.Bold)
StatLine("", "This game", "Before")
StatLine("Score", "${c.current.total}", c.averageBefore?.let { "avg ${it.roundToInt()}, best ${c.bestBefore}" } ?: "—")
StatLine("Upper bonus", if (c.current.gotBonus) "Yes" else "No", pct(c.bonusRateBefore))
StatLine("Yahtzees", "${c.current.yahtzees}", "${c.yahtzeesBefore} total")
StatLine("Zeroed boxes", "${c.current.zeros}", "")
if (c.current.players > 1) {
StatLine("Result", if (c.current.won) "Won" else "Lost", "won ${pct(c.winRateBefore)}")
}
Text(
"Games before this one: ${c.previousGames}",
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = { TextButton(onClick = onLeave) { Text("Leave the game") } },
dismissButton = { TextButton(onClick = onClose) { Text("Look at the card") } },
)
}
@Composable
private fun StatLine(label: String, now: String, before: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(label, modifier = Modifier.weight(1.2f), fontWeight = FontWeight.Bold)
Text(now, modifier = Modifier.weight(1f))
Text(before, modifier = Modifier.weight(1.6f))
}
}
private fun ordinal(n: Int): String {
val suffix = if (n % 100 in 11..13) "th" else when (n % 10) { 1 -> "st"; 2 -> "nd"; 3 -> "rd"; else -> "th" }
return "$n$suffix"
}
@Composable
private fun ZeroScoreDialog(category: YahtzeeCategory, onConfirm: () -> Unit, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Score zero?") },
text = { Text("These dice are worth 0 in ${category.label}. Write the zero there?") },
confirmButton = { TextButton(onClick = onConfirm) { Text("Score 0") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Choose another") } },
)
}
@Composable
private fun DiceRow(
dice: List<Int>,
@@ -189,12 +290,29 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
.clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
text = face?.toString() ?: "·",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = WallaceRed,
)
if (face != null) {
Canvas(modifier = Modifier.size(48.dp)) {
val radius = size.minDimension * 0.08f
pipPositions(face).forEach { (x, y) ->
drawCircle(WallaceRed, radius, Offset(size.width * x, size.height * y))
}
}
}
}
}
/** Pip centres as fractions of the die face, in the usual casino layout. */
private fun pipPositions(face: Int): List<Pair<Float, Float>> {
val lo = 0.28f
val mid = 0.5f
val hi = 0.72f
return when (face) {
1 -> listOf(mid to mid)
2 -> listOf(lo to lo, hi to hi)
3 -> listOf(lo to lo, mid to mid, hi to hi)
4 -> listOf(lo to lo, hi to lo, lo to hi, hi to hi)
5 -> listOf(lo to lo, hi to lo, mid to mid, lo to hi, hi to hi)
else -> listOf(lo to lo, hi to lo, lo to mid, hi to mid, lo to hi, hi to hi)
}
}
@@ -205,28 +323,61 @@ private fun ScoreCard(
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
YahtzeeSection.entries.forEach { section ->
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
if (section == YahtzeeSection.UPPER) {
// Upper boxes and bonus on the left, lower boxes and total on the right, so
// each half is wide enough for text that can actually be read. Each half
// has a floor on its width; if two of them do not fit (many players, large
// text) the pair scrolls sideways instead of squeezing.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val needed = (CARD_LABEL_WIDTH + CARD_SEAT_WIDTH * state.cards.size).dp
val half = maxOf((maxWidth - CARD_GAP.dp) / 2, needed)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(CARD_GAP.dp),
) {
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.UPPER, state, localSeat, writable, onWrite)
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
}
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.LOWER, state, localSeat, writable, onWrite)
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
private const val CARD_LABEL_WIDTH = 120
private const val CARD_SEAT_WIDTH = 56
private const val CARD_GAP = 12
@Composable
private fun CardHeader(state: YahtzeeState, localSeat: Int) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f))
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun CardSection(
section: YahtzeeSection,
state: YahtzeeState,
localSeat: Int,
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
}
@@ -243,12 +394,12 @@ private fun CardRow(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = open) { onWrite(category) }
.padding(vertical = 2.dp),
.padding(vertical = 5.dp),
) {
Text(
text = category.label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
)
state.cards.forEachIndexed { seat, card ->
@@ -262,7 +413,7 @@ private fun CardRow(
else -> "—"
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
)
}
@@ -271,18 +422,18 @@ private fun CardRow(
@Composable
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Text(
text = label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
values.forEach {
Text(
text = "$it",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
}
@@ -295,7 +446,12 @@ private fun EndCardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@@ -0,0 +1,743 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntSize
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
/**
* The island, drawn and tappable.
*
* A stateless view: it is handed the state, the parts of the board that are
* currently tappable ([targets]) and the one setup settlement that is waiting
* for its road ([selectedVertex]), and it reports taps. It knows no rules; what
* a tap *means* is decided by [CatanInteraction]. Where things are is decided by
* [BoardLayout], and what they look like by [CatanStyle], so this file is only
* the glue between the three.
*
* To change how a piece is drawn, edit the matching `draw…` function below. To
* change a colour or a proportion, edit [CatanStyle] instead.
*/
@Composable
fun CatanBoardView(
state: CatanState,
targets: BoardTargets,
selectedVertex: Int?,
onVertexTap: (Int) -> Unit,
onEdgeTap: (Int) -> Unit,
onHexTap: (Int) -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
/** Hexes to light up as having produced on the latest roll. */
rolledHexes: Set<Int> = emptySet(),
/** Cities & Knights: the knight whose menu is open or that is about to move. */
selectedKnight: Int? = null,
/** A placement lined up but not yet confirmed (or being dragged over): drawn as a solid ghost. */
stagedVertex: Int? = null,
stagedEdge: Int? = null,
/** The seat that will own the staged piece, for its colour. */
stagedSeat: Int? = null,
) {
val measurer = rememberTextMeasurer()
var size by remember { mutableStateOf(IntSize.Zero) }
val topo = state.topo
val layout = remember(size, topo) {
if (size.width > 0 && size.height > 0) {
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat(), topo)
} else {
null
}
}
// Held in State so the gesture detector, which is only restarted when the
// layout or targets change, always calls the latest callbacks.
val vertexTap by rememberUpdatedState(onVertexTap)
val edgeTap by rememberUpdatedState(onEdgeTap)
val hexTap by rememberUpdatedState(onHexTap)
Canvas(
modifier = modifier
.aspectRatio(CatanGeometry.aspect(topo))
.onSizeChanged { size = it }
.semantics { contentDescription = "Katan island board" }
.pointerInput(layout, targets, style) {
detectTapGestures { tap ->
val board = layout ?: return@detectTapGestures
val at = Pt(tap.x, tap.y)
val reach = board.radius * style.tapReach
when {
targets.tappableVertices.isNotEmpty() ->
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
targets.edges.isNotEmpty() || targets.ships.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges + targets.ships)?.let { edgeTap(it) }
targets.hexes.isNotEmpty() ->
// A hex is big: accept a tap anywhere near its middle.
board.nearestHex(at, board.radius * 0.95f, targets.hexes)?.let { hexTap(it) }
}
}
},
) {
val board = layout ?: return@Canvas
drawRect(color = style.sea)
drawPorts(state, board, style, measurer)
drawHexes(state, board, style, measurer)
drawRolled(rolledHexes, board, style)
drawRobber(state, board, style)
drawPirate(state, board, style)
drawMerchant(state, board, style, measurer)
drawRoads(state, board, style)
drawShips(state, board, style)
drawEdgeTargets(targets, board, style)
drawShipTargets(targets, board, style)
drawBuildings(state, board, style)
drawWalls(state, board, style)
drawMetropolises(state, board, style)
drawKnights(state, board, style)
drawKnightTargets(targets, board, style)
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
drawHexTargets(targets, board, style)
drawStaged(stagedVertex, stagedEdge, stagedSeat, board, style)
}
}
// ---------------------------------------------------------------------------
// Terrain and tokens
// ---------------------------------------------------------------------------
private fun DrawScope.drawHexes(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
for (hex in 0 until state.topo.HEX_COUNT) {
val centre = board.hexCenter(hex)
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
val path = polygon(corners)
if (state.terrains[hex] == Terrain.SEA) {
// Open water: only a faint outline, so ships have a grid to sail on.
drawPath(path, color = style.hexEdge.copy(alpha = 0.12f), style = Stroke(width = board.radius * 0.02f))
continue
}
drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
val number = state.numbers[hex]
if (number != 0) drawToken(number, centre, board, style, measurer)
}
}
/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */
private fun DrawScope.drawRolled(hexes: Set<Int>, board: BoardLayout, style: CatanStyle) {
for (hex in hexes) {
val centre = board.hexCenter(hex)
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) })
drawPath(path, color = style.rolled.copy(alpha = 0.28f))
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f))
drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f))
}
}
private fun DrawScope.drawToken(
number: Int,
at: Pt,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
val radius = board.radius * style.tokenRadius
drawCircle(color = style.token, radius = radius, center = at.offset())
drawCircle(
color = style.hexEdge,
radius = radius,
center = at.offset(),
style = Stroke(width = board.radius * 0.02f),
)
val hot = number == 6 || number == 8
drawCentered(
measurer = measurer,
text = number.toString(),
at = Pt(at.x, at.y - radius * 0.12f),
fontPx = board.radius * style.tokenText,
color = if (hot) style.tokenHotInk else style.tokenInk,
weight = if (hot) FontWeight.ExtraBold else FontWeight.Bold,
)
// One pip per thirty-sixth of the odds, so the likelier numbers read at a glance.
val pips = CatanBoard.numberOdds(number)
val pipRadius = radius * 0.075f
val gap = pipRadius * 2.6f
val startX = at.x - gap * (pips - 1) / 2f
for (i in 0 until pips) {
drawCircle(
color = if (hot) style.tokenHotInk else style.tokenInk,
radius = pipRadius,
center = Offset(startX + i * gap, at.y + radius * 0.52f),
)
}
}
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
// In Cities & Knights the robber sleeps, drawn faintly, until the barbarians first land.
val asleep = state.ck?.attacked == false
val tint = if (asleep) style.robber.copy(alpha = 0.3f) else style.robber
val rim = Color.White.copy(alpha = if (asleep) 0.25f else 0.7f)
val hex = board.hexCenter(state.robber)
// Beside the number token rather than on it, so the number stays readable.
val x = hex.x + board.radius * 0.5f
val y = hex.y + board.radius * 0.3f
val s = board.radius * style.robberSize
val pawn = Path().apply {
moveTo(x - s * 0.55f, y + s * 0.9f)
lineTo(x - s * 0.25f, y - s * 0.15f)
lineTo(x + s * 0.25f, y - s * 0.15f)
lineTo(x + s * 0.55f, y + s * 0.9f)
close()
}
drawPath(pawn, color = tint)
drawCircle(color = tint, radius = s * 0.38f, center = Offset(x, y - s * 0.42f))
drawPath(pawn, color = rim, style = Stroke(width = s * 0.07f))
drawCircle(
color = rim,
radius = s * 0.38f,
center = Offset(x, y - s * 0.42f),
style = Stroke(width = s * 0.07f),
)
}
// ---------------------------------------------------------------------------
// Harbours
// ---------------------------------------------------------------------------
private fun DrawScope.drawPorts(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
for (port in state.ports) {
val anchor = board.portAnchor(port.edge)
val (a, b) = board.edgeEnds(port.edge)
val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk
// Two piers from the marker to the vertices that can use the harbour.
for (end in listOf(a, b)) {
drawLine(
color = style.portMarker.copy(alpha = 0.9f),
start = anchor.offset(),
end = end.offset(),
strokeWidth = board.radius * 0.07f,
cap = StrokeCap.Round,
)
}
val radius = board.radius * style.portRadius
drawCircle(color = style.portMarker, radius = radius, center = anchor.offset())
drawCircle(
color = tint,
radius = radius,
center = anchor.offset(),
style = Stroke(width = board.radius * 0.07f),
)
// What the harbour trades: a picture of the resource, or "?" for any.
val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f)
val iconSize = radius * 0.5f
val resource = port.resource
if (resource != null) {
drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline)
} else {
drawCentered(
measurer = measurer,
text = "?",
at = iconAt,
fontPx = board.radius * style.portText * 1.5f,
color = style.portInk,
weight = FontWeight.ExtraBold,
)
}
drawCentered(
measurer = measurer,
text = "${port.ratio}:1",
at = Pt(anchor.x, anchor.y + radius * 0.5f),
fontPx = board.radius * style.portText,
color = style.portInk,
weight = FontWeight.Bold,
)
}
}
/**
* A small pictogram of [resource] centred on [at], about 2·[s] across: a tree,
* bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no
* image assets to ship or scale.
*/
private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) {
val outline = Stroke(width = s * 0.12f)
when (resource) {
Resource.LUMBER -> {
val crown = Path().apply {
moveTo(at.x, at.y - s)
lineTo(at.x + s * 0.8f, at.y + s * 0.3f)
lineTo(at.x - s * 0.8f, at.y + s * 0.3f)
close()
}
drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f))
drawPath(crown, color = fill)
drawPath(crown, color = line, style = outline)
}
Resource.BRICK -> {
val w = s * 0.9f
val h = s * 0.42f
for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) {
val top = at.y - h + row * h
val left = at.x - w + shift - w * 0.25f
for (col in 0..1) {
val x = left + col * w
drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h))
drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline)
}
}
}
Resource.WOOL -> {
for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) {
drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s))
}
drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f))
drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
}
Resource.GRAIN -> {
drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f)
for (i in 0..2) {
val y = at.y - s * 0.7f + i * s * 0.5f
for (side in listOf(-1f, 1f)) {
val kernel = Path().apply {
moveTo(at.x, y + s * 0.3f)
lineTo(at.x + side * s * 0.6f, y - s * 0.05f)
lineTo(at.x + side * s * 0.15f, y - s * 0.2f)
close()
}
drawPath(kernel, color = fill)
drawPath(kernel, color = line, style = Stroke(width = s * 0.08f))
}
}
}
Resource.ORE -> {
val peak = Path().apply {
moveTo(at.x - s, at.y + s * 0.8f)
lineTo(at.x - s * 0.3f, at.y - s * 0.6f)
lineTo(at.x + s * 0.1f, at.y + s * 0.05f)
lineTo(at.x + s * 0.45f, at.y - s * 0.3f)
lineTo(at.x + s, at.y + s * 0.8f)
close()
}
drawPath(peak, color = fill)
drawPath(peak, color = line, style = outline)
}
}
}
// ---------------------------------------------------------------------------
// Pieces
// ---------------------------------------------------------------------------
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (edge in 0 until state.topo.EDGE_COUNT) {
val owner = state.roads[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
// Pulled in from the ends so adjacent roads do not fuse into a blob.
val from = lerp(a, b, 0.12f)
val to = lerp(a, b, 0.88f)
val width = board.radius * style.roadWidth
drawLine(
color = style.pieceOutline,
start = from.offset(),
end = to.offset(),
strokeWidth = width * 1.45f,
cap = StrokeCap.Round,
)
drawLine(
color = style.seats[owner % style.seats.size],
start = from.offset(),
end = to.offset(),
strokeWidth = width,
cap = StrokeCap.Round,
)
}
}
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (vertex in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[vertex] ?: continue
val at = board.vertex(vertex)
val fill = style.seats[building.owner % style.seats.size]
val path = when (building.type) {
BuildingType.SETTLEMENT -> housePath(at, board.radius * style.settlementSize)
BuildingType.CITY -> cityPath(at, board.radius * style.citySize * 0.8f)
}
drawPath(path, color = fill)
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.045f))
}
}
/** One gable: a settlement. [s] is its half-width. */
private fun housePath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s, at.y + s * 0.7f)
lineTo(at.x - s, at.y - s * 0.1f)
lineTo(at.x, at.y - s)
lineTo(at.x + s, at.y - s * 0.1f)
lineTo(at.x + s, at.y + s * 0.7f)
close()
}
/** Two gables, the taller on the left: a city. [s] is the unit its outline is scaled by. */
private fun cityPath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s, at.y + s * 0.7f)
lineTo(at.x - s, at.y - s * 0.5f)
lineTo(at.x - s * 0.45f, at.y - s * 1.1f)
lineTo(at.x + s * 0.1f, at.y - s * 0.5f)
lineTo(at.x + s * 0.1f, at.y - s * 0.1f)
lineTo(at.x + s * 0.55f, at.y - s * 0.55f)
lineTo(at.x + s * 1.1f, at.y - s * 0.1f)
lineTo(at.x + s * 1.1f, at.y + s * 0.7f)
close()
}
// ---------------------------------------------------------------------------
// Cities & Knights pieces
// ---------------------------------------------------------------------------
/** A dashed wall round each walled city, crenellated by the dashes. */
private fun DrawScope.drawWalls(state: CatanState, board: BoardLayout, style: CatanStyle) {
val walls = state.ck?.walls ?: return
for (vertex in walls) {
val at = board.vertex(vertex).offset()
val radius = board.radius * style.citySize * 1.25f
val dash = PathEffect.dashPathEffect(floatArrayOf(radius * 0.38f, radius * 0.22f))
drawCircle(
color = style.pieceOutline,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.13f, pathEffect = dash),
)
drawCircle(
color = style.wall,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.09f, pathEffect = dash),
)
}
}
/** A pennant in the track's colour flying over a city that carries its metropolis. */
private fun DrawScope.drawMetropolises(state: CatanState, board: BoardLayout, style: CatanStyle) {
val metropolises = state.ck?.metropolises ?: return
for ((track, at) in metropolises) {
val base = board.vertex(at.vertex)
val unit = board.radius * style.citySize
val poleTop = Pt(base.x + unit * 0.9f, base.y - unit * 2.0f)
val poleBottom = Pt(base.x + unit * 0.9f, base.y - unit * 0.4f)
drawLine(
color = style.pieceOutline,
start = poleBottom.offset(),
end = poleTop.offset(),
strokeWidth = board.radius * 0.05f,
)
val flag = Path().apply {
moveTo(poleTop.x, poleTop.y)
lineTo(poleTop.x + unit * 1.1f, poleTop.y + unit * 0.35f)
lineTo(poleTop.x, poleTop.y + unit * 0.7f)
close()
}
drawPath(flag, color = style.track.getValue(track))
drawPath(flag, color = style.pieceOutline, style = Stroke(width = board.radius * 0.035f))
}
}
/**
* Each knight is a shield in its owner's colour, marked with one, two or three
* dots for its strength. A sleeping knight is drawn faded; an awake one has a
* bright rim.
*/
private fun DrawScope.drawKnights(state: CatanState, board: BoardLayout, style: CatanStyle) {
val knights = state.ck?.knights ?: return
for (knight in knights) {
val at = board.vertex(knight.vertex)
val s = board.radius * style.knightSize
val seat = style.seats[knight.owner % style.seats.size]
val path = shieldPath(at, s)
if (knight.active) {
drawPath(path, color = style.knightReady, style = Stroke(width = s * 0.55f))
}
drawPath(path, color = if (knight.active) seat else seat.copy(alpha = 0.55f))
drawPath(path, color = style.pieceOutline, style = Stroke(width = s * 0.16f))
val pip = s * 0.14f
val gap = pip * 2.8f
val startX = at.x - gap * (knight.level - 1) / 2f
for (i in 0 until knight.level) {
drawCircle(
color = style.pieceOutline,
radius = pip,
center = Offset(startX + i * gap, at.y - s * 0.05f),
)
}
}
}
private fun shieldPath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s * 0.8f, at.y - s)
lineTo(at.x + s * 0.8f, at.y - s)
lineTo(at.x + s * 0.8f, at.y + s * 0.1f)
quadraticBezierTo(at.x + s * 0.8f, at.y + s * 0.9f, at.x, at.y + s * 1.25f)
quadraticBezierTo(at.x - s * 0.8f, at.y + s * 0.9f, at.x - s * 0.8f, at.y + s * 0.1f)
close()
}
/** A ring round each of the player's knights that can do something now. */
private fun DrawScope.drawKnightTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (vertex in targets.knights) {
drawCircle(
color = style.target,
radius = board.radius * style.knightSize * 1.9f,
center = board.vertex(vertex).offset(),
style = Stroke(width = board.radius * 0.07f),
)
}
}
/** The merchant: a coin in its controller's colour, beside the hex it sits on. */
private fun DrawScope.drawMerchant(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
val merchant = state.ck?.merchant ?: return
val hex = board.hexCenter(merchant.hex)
val at = Pt(hex.x - board.radius * 0.5f, hex.y + board.radius * 0.3f)
val r = board.radius * 0.2f
drawCircle(color = style.seats[merchant.seat % style.seats.size], radius = r, center = at.offset())
drawCircle(color = style.pieceOutline, radius = r, center = at.offset(), style = Stroke(width = r * 0.3f))
drawCentered(
measurer = measurer,
text = "M",
at = at,
fontPx = r * 1.3f,
color = style.pieceOutline,
weight = FontWeight.ExtraBold,
)
}
// ---------------------------------------------------------------------------
// Tap targets
// ---------------------------------------------------------------------------
private fun DrawScope.drawVertexTargets(
targets: BoardTargets,
selected: Int?,
board: BoardLayout,
style: CatanStyle,
) {
val radius = board.radius * style.targetRadius
for (vertex in targets.vertices) {
val at = board.vertex(vertex).offset()
drawCircle(color = style.target.copy(alpha = 0.85f), radius = radius, center = at)
drawCircle(
color = style.pieceOutline,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.04f),
)
}
if (selected != null) {
val at = board.vertex(selected).offset()
drawCircle(
color = style.targetSelected,
radius = radius * 1.6f,
center = at,
style = Stroke(width = board.radius * 0.09f),
)
}
}
private fun DrawScope.drawEdgeTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (edge in targets.edges) {
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.15f)
val to = lerp(a, b, 0.85f)
drawLine(
color = style.pieceOutline,
start = from.offset(),
end = to.offset(),
strokeWidth = board.radius * style.roadWidth * 1.5f,
cap = StrokeCap.Round,
)
drawLine(
color = style.target.copy(alpha = 0.9f),
start = from.offset(),
end = to.offset(),
strokeWidth = board.radius * style.roadWidth,
cap = StrokeCap.Round,
)
}
}
private fun DrawScope.drawHexTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (hex in targets.hexes) {
val centre = board.hexCenter(hex)
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.08f) })
drawPath(path, color = style.target.copy(alpha = 0.38f))
drawPath(path, color = style.target, style = Stroke(width = board.radius * 0.07f))
}
}
// ---------------------------------------------------------------------------
// Drawing helpers
// ---------------------------------------------------------------------------
private fun Pt.offset(): Offset = Offset(x, y)
private fun lerp(a: Pt, b: Pt, t: Float): Pt = Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
/** [p] moved [fraction] of the way toward [centre]. */
private fun insetToward(p: Pt, centre: Pt, fraction: Float): Pt = lerp(p, centre, fraction)
private fun polygon(points: List<Pt>): Path = Path().apply {
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
close()
}
private fun DrawScope.drawCentered(
measurer: TextMeasurer,
text: String,
at: Pt,
fontPx: Float,
color: Color,
weight: FontWeight,
) {
val layout = measurer.measure(
text = text,
style = TextStyle(fontSize = fontPx.toSp(), fontWeight = weight, color = color),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
)
}
/** The ghost of a placement waiting for ✓: a solid piece in the player's colour with a bright ring. */
private fun DrawScope.drawStaged(vertex: Int?, edge: Int?, seat: Int?, board: BoardLayout, style: CatanStyle) {
val colour = seat?.let { style.seats.getOrNull(it) } ?: style.target
if (edge != null) {
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.1f)
val to = lerp(a, b, 0.9f)
drawLine(style.targetSelected, from.offset(), to.offset(), board.radius * style.roadWidth * 2.1f, StrokeCap.Round)
drawLine(colour, from.offset(), to.offset(), board.radius * style.roadWidth * 1.3f, StrokeCap.Round)
}
if (vertex != null) {
val at = board.vertex(vertex).offset()
val radius = board.radius * style.targetRadius
drawCircle(colour, radius * 1.3f, at)
drawCircle(style.targetSelected, radius * 1.9f, at, style = Stroke(width = board.radius * 0.1f))
}
}
// ---------------------------------------------------------------------------
// Seafarers: ships, the pirate
// ---------------------------------------------------------------------------
/** A ship on each sea edge that has one: a hull along the edge with a sail amidships, in the owner's colour. */
private fun DrawScope.drawShips(state: CatanState, board: BoardLayout, style: CatanStyle) {
val sea = state.sea ?: return
for (edge in sea.ships.indices) {
val owner = sea.ships[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.2f)
val to = lerp(a, b, 0.8f)
val width = board.radius * style.roadWidth * 1.15f
val colour = style.seats[owner % style.seats.size]
drawLine(style.pieceOutline, from.offset(), to.offset(), width * 1.5f, StrokeCap.Round)
drawLine(colour, from.offset(), to.offset(), width, StrokeCap.Round)
// The sail: a small triangle standing off the hull, on the screen's upper side.
val mid = lerp(a, b, 0.5f)
val s = board.radius * 0.17f
val sail = Path().apply {
moveTo(mid.x - s * 0.1f, mid.y - s * 1.9f)
lineTo(mid.x + s * 1.1f, mid.y - s * 0.2f)
lineTo(mid.x - s * 0.1f, mid.y - s * 0.2f)
close()
}
drawPath(sail, style.shipSail)
drawPath(sail, style.pieceOutline, style = Stroke(width = board.radius * 0.03f))
}
}
/** A ring round each of the player's ships that can sail, so they read as tappable. */
private fun DrawScope.drawShipTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (edge in targets.ships) {
val mid = board.edgeMidpoint(edge).offset()
drawCircle(style.target, board.radius * 0.38f, mid, style = Stroke(width = board.radius * 0.07f))
}
}
/** The pirate: a black pawn with a flag, drawn beside the middle of its sea hex like the robber. */
private fun DrawScope.drawPirate(state: CatanState, board: BoardLayout, style: CatanStyle) {
val sea = state.sea ?: return
val hex = board.hexCenter(sea.pirate)
val x = hex.x
val y = hex.y + board.radius * 0.1f
val s = board.radius * style.robberSize
val hull = Path().apply {
moveTo(x - s * 1.0f, y)
lineTo(x + s * 1.0f, y)
lineTo(x + s * 0.6f, y + s * 0.7f)
lineTo(x - s * 0.6f, y + s * 0.7f)
close()
}
drawPath(hull, style.pirate)
drawLine(style.pirate, Offset(x, y), Offset(x, y - s * 1.5f), strokeWidth = s * 0.12f)
val flag = Path().apply {
moveTo(x, y - s * 1.5f)
lineTo(x + s * 0.9f, y - s * 1.15f)
lineTo(x, y - s * 0.8f)
close()
}
drawPath(flag, style.pirate)
drawPath(hull, Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f))
}
@@ -0,0 +1,813 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.GoodsCounter
import org.prolibertate.games.game.catan.GoodsOffer
import org.prolibertate.games.game.catan.IMPROVEMENT_MAX
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.PickDeck
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.ProgressCard
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.catan.PromoteKnight
import org.prolibertate.games.game.catan.SpyTake
import org.prolibertate.games.game.catan.TakeAqueduct
import org.prolibertate.games.game.catan.Track
/*
* The Cities & Knights dialogs. Like the base game's, each is a stateless
* composable apart from the choice it is helping the player make, and what is
* allowed is always asked of the engine's legal list through CatanInteraction.
*/
// ---------------------------------------------------------------------------
// Shared pieces
// ---------------------------------------------------------------------------
/** A card tile with a minus and a plus, for choosing how many of a good. */
@Composable
private fun GoodCounter(good: Good, count: Int, max: Int, onChange: (Int) -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
GoodTile(good, count, enabled = max > 0 || count > 0)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedButton(
onClick = { onChange(count - 1) },
enabled = count > 0,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("−") }
OutlinedButton(
onClick = { onChange(count + 1) },
enabled = count < max,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("+") }
}
}
}
private fun Map<Good, Int>.asGoods(): List<Good> = Good.entries.flatMap { g -> List(this[g] ?: 0) { g } }
/** A row of the three track colours, used for decks and improvements. */
@Composable
private fun TrackChip(track: Track, style: CatanStyle = LocalCatanStyle.current) {
Box(
modifier = Modifier
.size(14.dp)
.clip(RoundedCornerShape(3.dp))
.background(style.track.getValue(track)),
)
}
// ---------------------------------------------------------------------------
// Choosing cards to give up
// ---------------------------------------------------------------------------
/**
* Pick exactly [quota] cards out of [held] — to discard on a seven or a saboteur,
* or to hand over for a wedding or a commercial harbour. Not dismissable: the
* game is waiting on it. [allowed] is the engine's structural check, so the
* confirm button only lights when the move would be accepted.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsPickDialog(
title: String,
intro: String,
held: Map<Good, Int>,
quota: Int,
confirmLabel: String,
selectable: (Good) -> Boolean,
allowed: (List<Good>) -> Boolean,
onConfirm: (List<Good>) -> Unit,
) {
var picked by remember { mutableStateOf(emptyMap<Good, Int>()) }
val selection = picked.asGoods()
AlertDialog(
onDismissRequest = {},
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("$intro (${selection.size} of $quota chosen).")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Good.entries.forEach { good ->
val have = held[good] ?: 0
if (have > 0 && selectable(good)) {
GoodCounter(
good = good,
count = picked[good] ?: 0,
max = if (selection.size < quota) have else (picked[good] ?: 0),
onChange = { n -> picked = picked + (good to n.coerceIn(0, have)) },
)
}
}
}
}
},
confirmButton = {
Button(onClick = { onConfirm(selection) }, enabled = allowed(selection)) { Text(confirmLabel) }
},
)
}
// ---------------------------------------------------------------------------
// City improvements
// ---------------------------------------------------------------------------
/** The three improvement tracks: where the player stands, what the next level costs, and what it earns. */
@Composable
fun CatanImproveDialog(
state: CatanState,
seat: Int,
legal: List<CatanMove>,
onBuy: (BuyImprovement) -> Unit,
onCancel: () -> Unit,
) {
val ck = state.ck ?: return
val buyable = CatanInteraction.improvable(legal)
AlertDialog(
onDismissRequest = onCancel,
title = { Text(CatanText.IMPROVE) },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Track.entries.forEach { track ->
val level = CatanCk.improvement(state, seat, track)
val have = ck.commodities[seat][track.commodity] ?: 0
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TrackChip(track)
Text(CatanText.track(track), fontWeight = FontWeight.Bold)
Text("level $level of $IMPROVEMENT_MAX", style = MaterialTheme.typography.labelMedium)
}
Text(CatanText.trackPower(track), style = MaterialTheme.typography.bodySmall)
if (level >= IMPROVEMENT_MAX) {
Text("Fully built.", style = MaterialTheme.typography.bodySmall)
} else {
val cost = CatanCk.improvementCost(state, seat, track)
Button(onClick = { onBuy(BuyImprovement(track)) }, enabled = track in buyable) {
Text(
"${CatanCk.improvementName(track, level + 1)}: $cost " +
"${CatanText.good(Good.of(track.commodity))} (you hold $have)",
)
}
if (level + 1 == org.prolibertate.games.game.catan.METROPOLIS_LEVEL ||
level + 1 == IMPROVEMENT_MAX
) {
Text(
if (level + 1 == IMPROVEMENT_MAX) {
"Level five takes a metropolis from a seat still on four."
} else {
"Level four earns the first metropolis in this track: two more points."
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
)
}
// ---------------------------------------------------------------------------
// Progress cards
// ---------------------------------------------------------------------------
/**
* The player's progress cards. A card with nothing to decide plays at once; one
* that names a seat, a card or dice asks for it in a second step; one that
* needs a tap on the board closes the dialog and arms the board (see [ArmedCard]).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanProgressDialog(
hand: List<ProgressInHand>,
seats: CatanSeats,
onPlay: (CatanMove) -> Unit,
onArm: (ArmedCard) -> Unit,
onCancel: () -> Unit,
) {
var chosen by remember { mutableStateOf<ProgressInHand?>(null) }
var target by remember { mutableStateOf<Int?>(null) }
var yellow by remember { mutableStateOf(1) }
var red by remember { mutableStateOf(1) }
val entry = chosen
AlertDialog(
onDismissRequest = onCancel,
title = { Text(entry?.let { CatanText.progress(it.card.kind) } ?: "Progress cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (entry == null) {
if (hand.isEmpty()) Text("You hold no progress cards.")
hand.forEach { card ->
ProgressRow(card) { picked ->
val kind = picked.card.kind
when {
kind in CatanInteraction.BOARD_CARDS -> onArm(ArmedCard(kind, picked.plays))
picked.plays.size == 1 && picked.plays[0].isBare() -> onPlay(picked.plays[0])
else -> {
chosen = picked
target = null
}
}
}
}
} else {
Text(CatanText.progressHelp(entry.card.kind), style = MaterialTheme.typography.bodySmall)
val kind = entry.card.kind
val plays = entry.plays
when {
kind == ProgressType.ALCHEMIST -> {
Text("Production die (yellow)", fontWeight = FontWeight.Bold)
DiePicker(yellow) { yellow = it }
Text("Progress die (red)", fontWeight = FontWeight.Bold)
DiePicker(red) { red = it }
}
kind == ProgressType.DESERTER || kind == ProgressType.SPY -> {
Text("Choose a player:", fontWeight = FontWeight.Bold)
plays.mapNotNull { it.target }.distinct().forEach { seat ->
OutlinedButton(
onClick = { plays.firstOrNull { it.target == seat }?.let(onPlay) },
modifier = Modifier.fillMaxWidth(),
) { Text(seats.label(seat)) }
}
}
kind == ProgressType.MASTER_MERCHANT -> {
Text("Choose a player with more points:", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
plays.mapNotNull { it.target }.distinct().forEach { seat ->
if (target == seat) {
FilledTonalButton(onClick = { target = seat }) { Text(seats.label(seat)) }
} else {
OutlinedButton(onClick = { target = seat }) { Text(seats.label(seat)) }
}
}
}
val seat = target
if (seat != null) {
Text("Take:", fontWeight = FontWeight.Bold)
plays.filter { it.target == seat }.forEach { play ->
OutlinedButton(
onClick = { onPlay(play) },
modifier = Modifier.fillMaxWidth(),
) { Text(CatanText.goods(play.goods.orEmpty())) }
}
}
}
else -> {
val label = when (kind) {
ProgressType.COMMERCIAL_HARBOR -> "Offer which resource?"
ProgressType.MERCHANT_FLEET -> "Trade which card two for one?"
ProgressType.RESOURCE_MONOPOLY -> "Name a resource:"
else -> "Name a commodity:"
}
Text(label, fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
plays.mapNotNull { it.good }.distinct().forEach { good ->
GoodTile(
good = good,
count = 0,
onClick = { plays.firstOrNull { it.good == good }?.let(onPlay) },
)
}
}
}
}
}
}
},
confirmButton = {
if (entry != null && entry.card.kind == ProgressType.ALCHEMIST) {
val play = entry.plays.firstOrNull { it.dice == listOf(yellow, red) }
Button(onClick = { play?.let(onPlay) }, enabled = play != null) { Text("Play") }
}
},
dismissButton = {
TextButton(
onClick = {
if (entry == null) {
onCancel()
} else {
chosen = null
target = null
}
},
) { Text(if (entry == null) CatanText.CANCEL else "Back") }
},
)
}
private fun PlayProgress.isBare(): Boolean =
target == null && vertex == null && edge == null && hexA == null && hexB == null &&
good == null && dice == null && goods == null
@Composable
private fun DiePicker(selected: Int, onPick: (Int) -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(1..6).forEach { n ->
if (n == selected) {
FilledTonalButton(
onClick = { onPick(n) },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(40.dp),
) { Text("$n") }
} else {
OutlinedButton(
onClick = { onPick(n) },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(40.dp),
) { Text("$n") }
}
}
}
}
@Composable
private fun ProgressRow(entry: ProgressInHand, onChoose: (ProgressInHand) -> Unit) {
val kind = entry.card.kind
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrackChip(kind.track)
Column(modifier = Modifier.weight(1f)) {
Text(CatanText.progress(kind), fontWeight = FontWeight.Bold)
Text(CatanText.progressHelp(kind), style = MaterialTheme.typography.bodySmall)
}
if (entry.playable) {
Button(onClick = { onChoose(entry) }) { Text("Play") }
} else {
Text("Not now", style = MaterialTheme.typography.labelSmall)
}
}
}
// ---------------------------------------------------------------------------
// Knights
// ---------------------------------------------------------------------------
/** What the knight on one crossing can do: wake, promote, move, chase the robber. */
@Composable
fun CatanKnightDialog(
state: CatanState,
vertex: Int,
actions: List<CatanMove>,
onDo: (CatanMove) -> Unit,
onMove: (Int) -> Unit,
onCancel: () -> Unit,
) {
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
AlertDialog(
onDismissRequest = onCancel,
title = { Text(knight?.let { CatanText.knightName(it.level) } ?: "Knight") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (knight != null) {
val mood = when {
!knight.active -> "Asleep. Waking it costs ${CatanText.ACTIVATE_COST}."
knight.activatedTurn == state.turnNumber -> "Awake, but it woke this turn and cannot act yet."
else -> "Awake and ready. It counts against the barbarians."
}
Text(mood, style = MaterialTheme.typography.bodySmall)
}
actions.forEach { move ->
when (move) {
is ActivateKnight ->
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Wake (${CatanText.ACTIVATE_COST})")
}
is PromoteKnight ->
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Promote (${CatanText.KNIGHT_COST})")
}
is MoveKnight ->
OutlinedButton(onClick = { onMove(move.from) }, modifier = Modifier.fillMaxWidth()) {
Text("Move along your roads")
}
is ChaseRobber ->
OutlinedButton(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Chase away the robber")
}
else -> Unit
}
}
if (actions.isEmpty()) Text("Nothing to do with it right now.")
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
)
}
// ---------------------------------------------------------------------------
// Chores that are answered in a dialog
// ---------------------------------------------------------------------------
/** Choose a colour of progress deck to draw from. */
@Composable
fun CatanPickDeckDialog(legal: List<CatanMove>, onPick: (PickDeck) -> Unit) {
val open = legal.filterIsInstance<PickDeck>()
AlertDialog(
onDismissRequest = {},
title = { Text("Draw a progress card") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Catan held. You tied for the strongest defence, so choose any deck.")
open.forEach { pick ->
Button(onClick = { onPick(pick) }, modifier = Modifier.fillMaxWidth()) {
TrackChip(pick.track)
Text(" ${CatanText.track(pick.track)}")
}
}
}
},
confirmButton = {},
)
}
/** The hand is over its limit: give one progress card back. */
@Composable
fun CatanDropProgressDialog(hand: List<ProgressCard>, onDrop: (DropProgress) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Too many progress cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("You may keep four. Choose one to give back.")
hand.forEach { card ->
OutlinedButton(onClick = { onDrop(DropProgress(card.id)) }, modifier = Modifier.fillMaxWidth()) {
TrackChip(card.kind.track)
Text(" ${CatanText.progress(card.kind)}")
}
}
}
},
confirmButton = {},
)
}
/** The aqueduct pays: take any one resource. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanAqueductDialog(legal: List<CatanMove>, onTake: (TakeAqueduct) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Aqueduct") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("The dice paid you nothing. Take any resource.")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
legal.filterIsInstance<TakeAqueduct>().forEach { take ->
ResourceTile(resource = take.resource, count = 1, onClick = { onTake(take) })
}
}
}
},
confirmButton = {},
)
}
/** A spy: these are the progress cards the other player holds; take one. */
@Composable
fun CatanSpyDialog(cards: List<ProgressCard>, legal: List<CatanMove>, onTake: (SpyTake) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Spy") },
text = {
Column(
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Choose a card to take.")
cards.forEach { card ->
val take = SpyTake(card.id)
OutlinedButton(
onClick = { onTake(take) },
enabled = take in legal,
modifier = Modifier.fillMaxWidth(),
) {
TrackChip(card.kind.track)
Text(" ${CatanText.progress(card.kind)}")
}
}
}
},
confirmButton = {},
)
}
// ---------------------------------------------------------------------------
// Trading in goods
// ---------------------------------------------------------------------------
private enum class GoodsTab { BANK, PLAYER }
/**
* Trade with the bank at the best ratio each card has, or offer cards —
* commodities included — to one other player.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsTradeDialog(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (GoodsBankTrade) -> Unit,
onOffer: (OfferGoods) -> Unit,
onCancel: () -> Unit,
) {
var tab by remember { mutableStateOf(GoodsTab.BANK) }
var bankGive by remember { mutableStateOf<Good?>(null) }
var everyone by remember { mutableStateOf(true) }
var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Good, Int>()) }
var get by remember { mutableStateOf(emptyMap<Good, Int>()) }
val held = CatanCk.goodsOf(state, seat)
AlertDialog(
onDismissRequest = onCancel,
title = { Text(CatanText.TRADE) },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GoodsTabButton(CatanText.TRADE_WITH_BANK, tab == GoodsTab.BANK) { tab = GoodsTab.BANK }
GoodsTabButton(CatanText.TRADE_WITH_PLAYER, tab == GoodsTab.PLAYER) { tab = GoodsTab.PLAYER }
}
if (tab == GoodsTab.BANK) {
val trades = CatanInteraction.goodsBankTrades(legal)
if (trades.isEmpty()) {
Text("You do not hold enough of any one card to trade with the bank.")
} else {
Text("Give:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.keys.forEach { good ->
val ratio = CatanCk.bankRatio(state, seat, good)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
GoodTile(good, ratio, selected = bankGive == good, onClick = { bankGive = good })
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
}
}
}
val chosen = bankGive
if (chosen != null && chosen in trades) {
Text("Get one:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.getValue(chosen).forEach { good ->
GoodTile(good, 1, onClick = { onBank(GoodsBankTrade(chosen, good)) })
}
}
}
}
} else {
Text("Offer to:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
GoodsTabButton(CatanText.EVERYONE, everyone) {
everyone = true
partner = null
}
(0 until state.playerCount).filter { it != seat }.forEach { other ->
GoodsTabButton(seats.label(other), !everyone && partner == other) {
everyone = false
partner = other
}
}
}
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
GoodCounters(give, { held[it] ?: 0 }) { give = it }
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
GoodCounters(get, { 9 }) { get = it }
}
}
},
confirmButton = {
if (tab == GoodsTab.PLAYER) {
val to = if (everyone) null else partner
val giveCards = give.asGoods()
val getCards = get.asGoods()
val allowed = (everyone || partner != null) &&
CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards)
Button(
onClick = { onOffer(OfferGoods(to, giveCards, getCards)) },
enabled = allowed,
) { Text(CatanText.SEND_OFFER) }
}
},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GoodCounters(counts: Map<Good, Int>, limit: (Good) -> Int, onChange: (Map<Good, Int>) -> Unit) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Good.entries.forEach { good ->
GoodCounter(
good = good,
count = counts[good] ?: 0,
max = limit(good),
onChange = { n -> onChange(counts + (good to n.coerceIn(0, limit(good)))) },
)
}
}
}
@Composable
private fun GoodsTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
if (selected) {
FilledTonalButton(onClick = onClick) { Text(label) }
} else {
OutlinedButton(onClick = onClick) { Text(label) }
}
}
/**
* A goods offer has reached the local player: accept it, decline it, or answer
* with different terms. The counter's two sides start as the offer turned around.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsTradeResponseDialog(
state: CatanState,
seat: Int,
offer: GoodsOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
onCounter: (CounterGoods) -> Unit,
) {
var countering by remember(offer) { mutableStateOf(false) }
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
AlertDialog(
onDismissRequest = {},
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to " +
"${if (offer.to == null) "the table" else "you"} for ${CatanText.goods(offer.get)}",
)
if (countering) {
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
GoodCounters(give, { CatanCk.goodsOf(state, seat)[it] ?: 0 }) { give = it }
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
GoodCounters(get, { CatanCk.goodsOf(state, offer.by)[it] ?: 0 }) { get = it }
} else if (!canAccept) {
Text(
"You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
if (countering) {
val move = CounterGoods(give.asGoods(), get.asGoods())
Button(
onClick = { onCounter(move) },
enabled = CatanInteraction.goodsCounterAllowed(state, seat, move.give, move.get),
) { Text(CatanText.SEND_COUNTER) }
} else {
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
}
},
dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (countering) {
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
} else {
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
}
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
}
},
)
}
/** The offerer's choice once nobody accepted a goods offer: take a counter-offer, or none. */
@Composable
fun CatanGoodsCounterChoiceDialog(
counters: List<GoodsCounter>,
seats: CatanSeats,
onTake: (AcceptCounter) -> Unit,
onNoDeal: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("Counter-offers") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (counters.isEmpty()) Text("None of the counter-offers can be met any more.")
counters.forEach { counter ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"${seats.label(counter.by)} would give ${CatanText.goods(counter.give)} " +
"for ${CatanText.goods(counter.get)}",
)
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
}
}
}
},
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
)
}
@@ -0,0 +1,675 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.ui.LocalEndGameHandle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.TradeCounter
import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.TradeOffer
/*
* Every Katan dialog. Each is a stateless composable apart from the selection it
* is helping the player make (cards picked, a partner chosen); the move it ends
* in is reported through a lambda and sent by the screen. What is *allowed* is
* always asked of [CatanInteraction], never decided here.
*/
// ---------------------------------------------------------------------------
// Shared pieces
// ---------------------------------------------------------------------------
/** A resource tile with a minus and a plus, for choosing how many cards. */
@Composable
private fun ResourceCounter(
resource: Resource,
count: Int,
max: Int,
onChange: (Int) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
ResourceTile(resource, count, enabled = max > 0 || count > 0)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedButton(
onClick = { onChange(count - 1) },
enabled = count > 0,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("−") }
OutlinedButton(
onClick = { onChange(count + 1) },
enabled = count < max,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("+") }
}
}
}
private fun Map<Resource, Int>.asCards(): List<Resource> =
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
// ---------------------------------------------------------------------------
// Discarding on a seven
// ---------------------------------------------------------------------------
/**
* A seven was rolled and this player holds more than seven cards: choose which
* half to give up. Not dismissable — the game is waiting on it.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanDiscardDialog(
held: Map<Resource, Int>,
quota: Int,
legal: List<CatanMove>,
onSubmit: (Discard) -> Unit,
) {
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
val selection = picked.asCards()
val move = CatanInteraction.discardMove(legal, selection)
AlertDialog(
onDismissRequest = {},
title = { Text("Discard $quota cards") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"A seven was rolled. Choose $quota cards to give up " +
"(${selection.size} of $quota chosen).",
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
val have = held[resource] ?: 0
if (have > 0) {
ResourceCounter(
resource = resource,
count = picked[resource] ?: 0,
max = if (selection.size < quota) have else (picked[resource] ?: 0),
onChange = { n -> picked = picked + (resource to n.coerceIn(0, have)) },
)
}
}
}
}
},
confirmButton = {
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text("Discard") }
},
)
}
// ---------------------------------------------------------------------------
// Gold (Seafarers)
// ---------------------------------------------------------------------------
/**
* A gold field paid out: choose the resource cards it gives. Not dismissable: the game
* is waiting. The engine's list says which resources the bank can still pay.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoldDialog(
legal: List<CatanMove>,
onSubmit: (org.prolibertate.games.game.catan.PickGold) -> Unit,
) {
val count = CatanInteraction.goldCount(legal)
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
val selection = picked.asCards()
val move = CatanInteraction.goldMove(legal, selection)
// A resource is on offer if some legal pick names it.
val offered = legal.filterIsInstance<org.prolibertate.games.game.catan.PickGold>()
.flatMap { it.cards }.toSet()
AlertDialog(
onDismissRequest = {},
title = { Text(CatanText.GOLD_TITLE) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gold! Choose $count resource cards (${selection.size} of $count chosen).")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.filter { it in offered }.forEach { resource ->
ResourceCounter(
resource = resource,
count = picked[resource] ?: 0,
max = if (selection.size < count) count else (picked[resource] ?: 0),
onChange = { n -> picked = picked + (resource to n.coerceIn(0, count)) },
)
}
}
}
},
confirmButton = {
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text(CatanText.GOLD_TAKE) }
},
)
}
// ---------------------------------------------------------------------------
// The robber
// ---------------------------------------------------------------------------
/** More than one player can be robbed from this hex: choose whom. */
@Composable
fun CatanVictimDialog(
victims: List<Int>,
seats: CatanSeats,
onPick: (Int) -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Steal from…") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
victims.forEach { seat ->
OutlinedButton(onClick = { onPick(seat) }, modifier = Modifier.fillMaxWidth()) {
Text(seats.label(seat))
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
// ---------------------------------------------------------------------------
// Development cards
// ---------------------------------------------------------------------------
/**
* The player's development cards. A Knight or Road Building plays at once; a
* Monopoly or Year of Plenty asks for its resource choice in a second step.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanPlayCardDialog(
hand: List<DevCardInHand>,
onPlay: (CatanMove) -> Unit,
onCancel: () -> Unit,
) {
var chosen by remember { mutableStateOf<DevCardInHand?>(null) }
var picked by remember { mutableStateOf(emptyList<Resource>()) }
val card = chosen
AlertDialog(
onDismissRequest = onCancel,
title = { Text(card?.let { CatanText.devCard(it.card.type) } ?: "Development cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
when {
card == null -> {
if (hand.isEmpty()) Text("You have no development cards.")
hand.forEach { entry -> DevCardRow(entry, onPlay) { chosen = it } }
}
card.card.type == DevCardType.MONOPOLY -> {
Text("Name a resource. Every other player hands over all of it.")
val choices = CatanInteraction.monopolyChoices(card.plays)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
val move = choices[resource]
ResourceTile(
resource = resource,
count = 0,
enabled = move != null,
onClick = { move?.let(onPlay) },
)
}
}
}
card.card.type == DevCardType.YEAR_OF_PLENTY -> {
Text("Take two resource cards from the bank (${picked.size} of 2 chosen).")
val available = CatanInteraction.plentyAvailable(card.plays, picked)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
ResourceTile(
resource = resource,
count = picked.count { it == resource },
selected = resource in picked,
enabled = resource in available,
onClick = { picked = picked + resource },
)
}
}
if (picked.isNotEmpty()) {
TextButton(onClick = { picked = emptyList() }) { Text("Clear") }
}
}
}
}
},
confirmButton = {
if (card != null && card.card.type == DevCardType.YEAR_OF_PLENTY) {
val move = CatanInteraction.plentyMove(card.plays, picked)
Button(onClick = { move?.let(onPlay) }, enabled = move != null) { Text("Take") }
}
},
dismissButton = {
TextButton(
onClick = {
if (card == null) {
onCancel()
} else {
chosen = null
picked = emptyList()
}
},
) { Text(if (card == null) CatanText.CANCEL else "Back") }
},
)
}
@Composable
private fun DevCardRow(
entry: DevCardInHand,
onPlay: (CatanMove) -> Unit,
onChoose: (DevCardInHand) -> Unit,
) {
val type = entry.card.type
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(CatanText.devCard(type), fontWeight = FontWeight.Bold)
Text(CatanText.devCardHelp(type), style = MaterialTheme.typography.bodySmall)
}
when {
type == DevCardType.VICTORY_POINT -> Text("Hidden", style = MaterialTheme.typography.labelSmall)
!entry.playable -> Text("Not now", style = MaterialTheme.typography.labelSmall)
entry.plays.size == 1 && (entry.plays[0] is PlayKnight || entry.plays[0] is PlayRoadBuilding) ->
Button(onClick = { onPlay(entry.plays[0]) }) { Text("Play") }
else -> Button(onClick = { onChoose(entry) }) { Text("Play…") }
}
}
}
// ---------------------------------------------------------------------------
// Trading
// ---------------------------------------------------------------------------
private enum class TradeTab { BANK, PLAYER }
/**
* Trade with the bank at the best ratio the player has, or offer cards to one
* other player. Offers are free-form, so they are checked with the engine's
* structural rule ([CatanInteraction.offerAllowed]) rather than looked up in
* the legal list.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanTradeDialog(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (MaritimeTrade) -> Unit,
onOffer: (OfferTrade) -> Unit,
onCancel: () -> Unit,
) {
var tab by remember { mutableStateOf(TradeTab.BANK) }
var bankGive by remember { mutableStateOf<Resource?>(null) }
var everyone by remember { mutableStateOf(true) }
var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text(CatanText.TRADE) },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TabButton(CatanText.TRADE_WITH_BANK, tab == TradeTab.BANK) { tab = TradeTab.BANK }
TabButton(CatanText.TRADE_WITH_PLAYER, tab == TradeTab.PLAYER) { tab = TradeTab.PLAYER }
}
if (tab == TradeTab.BANK) {
val trades = CatanInteraction.bankTrades(legal)
if (trades.isEmpty()) {
Text("You do not hold enough of any one resource to trade with the bank.")
} else {
Text("Give:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.keys.forEach { resource ->
val ratio = CatanInteraction.bankRatio(state, seat, resource)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ResourceTile(
resource = resource,
count = ratio,
selected = bankGive == resource,
onClick = { bankGive = resource },
)
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
}
}
}
val chosen = bankGive
if (chosen != null && chosen in trades) {
Text("Get one:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.getValue(chosen).forEach { resource ->
ResourceTile(
resource = resource,
count = 1,
onClick = { onBank(MaritimeTrade(chosen, resource)) },
)
}
}
}
}
} else {
Text("Offer to:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TabButton(CatanText.EVERYONE, everyone) { everyone = true; partner = null }
(0 until state.playerCount).filter { it != seat }.forEach { other ->
TabButton(seats.label(other), !everyone && partner == other) {
everyone = false
partner = other
}
}
}
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
CounterRow(
counts = give,
limit = { state.resources[seat][it] ?: 0 },
onChange = { give = it },
)
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
CounterRow(counts = get, limit = { 9 }, onChange = { get = it })
}
}
},
confirmButton = {
if (tab == TradeTab.PLAYER) {
val to = if (everyone) null else partner
val giveCards = give.asCards()
val getCards = get.asCards()
val allowed = (everyone || partner != null) &&
CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
Button(
onClick = { onOffer(OfferTrade(to, giveCards, getCards)) },
enabled = allowed,
) { Text(CatanText.SEND_OFFER) }
}
},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CounterRow(
counts: Map<Resource, Int>,
limit: (Resource) -> Int,
onChange: (Map<Resource, Int>) -> Unit,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
ResourceCounter(
resource = resource,
count = counts[resource] ?: 0,
max = limit(resource),
onChange = { n -> onChange(counts + (resource to n.coerceIn(0, limit(resource)))) },
)
}
}
}
@Composable
private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) {
if (selected) {
FilledTonalButton(onClick = onClick) { Text(label) }
} else {
OutlinedButton(onClick = onClick) { Text(label) }
}
}
/**
* Another player has put an offer to the local player (alone, or as one of the
* table). Accept, decline, or — via a second step — answer with different terms.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanTradeResponseDialog(
state: CatanState,
seat: Int,
offer: TradeOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
onCounter: (CounterTrade) -> Unit,
) {
var countering by remember(offer) { mutableStateOf(false) }
// What we would give the offerer, and what we would ask of them.
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
AlertDialog(
onDismissRequest = {},
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
CatanText.tradeSummary(
by = seats.label(offer.by),
to = if (offer.to == null) "the table" else "you",
give = offer.give,
get = offer.get,
),
)
if (countering) {
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
CounterRow(
counts = give,
limit = { state.resources[seat][it] ?: 0 },
onChange = { give = it },
)
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
CounterRow(
counts = get,
limit = { state.resources[offer.by][it] ?: 0 },
onChange = { get = it },
)
} else if (!canAccept) {
Text(
"You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
if (countering) {
val move = CounterTrade(give.asCards(), get.asCards())
Button(
onClick = { onCounter(move) },
enabled = CatanInteraction.counterAllowed(state, seat, move.give, move.get),
) { Text(CatanText.SEND_COUNTER) }
} else {
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
}
},
dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (countering) {
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
} else {
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
}
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
}
},
)
}
/** The offerer's choice once nobody accepted: take a counter-offer, or none. */
@Composable
fun CatanCounterChoiceDialog(
counters: List<TradeCounter>,
seats: CatanSeats,
onTake: (AcceptCounter) -> Unit,
onNoDeal: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("Counter-offers") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (counters.isEmpty()) {
Text("None of the counter-offers can be met any more.")
}
counters.forEach { counter ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(CatanText.counterSummary(seats.label(counter.by), counter.give, counter.get))
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
}
}
}
},
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
)
}
// ---------------------------------------------------------------------------
// Ending
// ---------------------------------------------------------------------------
/** The game is over: who won, and what everyone finished with. */
@Composable
fun CatanGameOverDialog(
state: CatanState,
seats: CatanSeats,
onExit: () -> Unit,
onLook: () -> Unit,
) {
val points = (0 until state.playerCount).map { seat ->
// The winner's hidden victory-point cards are revealed by winning; the
// host's copy of the state has them, a client's has only its own.
seat to if (seat == seats.me) CatanRules.victoryPoints(state, seat)
else CatanRules.publicVictoryPoints(state)[seat]
}
AlertDialog(
onDismissRequest = onLook,
title = { Text(CatanText.winnerHeadline(state, seats)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
points.sortedByDescending { it.second }.forEach { (seat, vp) ->
Text("${seats.label(seat)}: $vp points")
}
}
},
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
dismissButton = { TextButton(onClick = onLook) { Text(CatanText.LOOK_AT_BOARD) } },
)
}
@Composable
fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
AlertDialog(
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = {
Text(
"This game ends here and the position is not saved. Anyone else " +
"at the table is told you have left.",
)
},
confirmButton = {
// Ending a game on purpose also forgets the saved position: only
// leaving the table keeps it to be picked up again.
val handle = LocalEndGameHandle.current
TextButton(onClick = { handle.end(); onEnd() }) { Text("End the game") }
},
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@Composable
fun CatanAbandonedDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
AlertDialog(
onDismissRequest = onStay,
title = { Text("The game has ended") },
text = { Text("$notice There are no more moves to play.") },
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
dismissButton = { TextButton(onClick = onStay) { Text(CatanText.LOOK_AT_BOARD) } },
)
}
@@ -0,0 +1,143 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.Topology
import kotlin.math.sqrt
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
data class Pt(val x: Float, val y: Float)
/**
* Where the island sits on screen: the one place the engine's integer lattice
* (see [CatanBoard]) becomes pixels, and pixels become "which vertex / edge /
* hex did the player tap".
*
* Pure and Android-free, so it is unit-tested (`CatanGeometryTest`). The drawing
* code ([CatanBoardView]) and the tap handling both go through this, which is
* what keeps what is drawn and what is tappable from ever disagreeing.
*
* Lattice to pixel: a hex of circumradius `R` is pointy-top, so one lattice x-unit
* is `R·√3/2` and one lattice y-unit is `R/2`, and lattice +Y points up the board
* while pixel +y points down.
*/
class BoardLayout(
val width: Float,
val height: Float,
/** Circumradius of one hex, in pixels. */
val radius: Float,
/** The board this layout draws: the standard island unless the game is Seafarers. */
val topo: Topology = CatanBoard.base,
) {
private val centreX = width / 2f
private val centreY = height / 2f
private val unitX = radius * SQRT3 / 2f
private val unitY = radius / 2f
/** The lattice point drawn at the middle of the box: the middle of the frame. */
private val midX = (topo.lattice[0] + topo.lattice[1]) / 2f
private val midY = (topo.lattice[2] + topo.lattice[3]) / 2f
fun point(latticeX: Int, latticeY: Int): Pt =
Pt(centreX + (latticeX - midX) * unitX, centreY - (latticeY - midY) * unitY)
fun hexCenter(hex: Int): Pt {
val (lx, ly) = topo.hexCenter(hex)
return point(lx, ly)
}
fun vertex(vertex: Int): Pt {
val (lx, ly) = topo.VERTEX_POS[vertex]
return point(lx, ly)
}
fun hexCorners(hex: Int): List<Pt> = topo.HEX_CORNERS[hex].map { vertex(it) }
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
val (a, b) = topo.EDGE_VERTICES[edge]
return vertex(a) to vertex(b)
}
fun edgeMidpoint(edge: Int): Pt {
val (a, b) = edgeEnds(edge)
return Pt((a.x + b.x) / 2f, (a.y + b.y) / 2f)
}
/**
* Where a harbour's marker is drawn: just off the coast, straight out from
* the middle of its edge. The marker is joined to the edge's two vertices by
* the board view, which is what shows which settlements can use it.
*/
fun portAnchor(edge: Int): Pt {
val mid = edgeMidpoint(edge)
// Straight out from the middle of the island on the standard board; on a map of
// islands, straight out from the land hex the harbour sits on.
val from = if (topo.EDGE_HEXES[edge].size == 1) {
Pt(centreX, centreY)
} else {
topo.EDGE_HEXES[edge].firstOrNull { topo.land[it] }?.let { hexCenter(it) } ?: Pt(centreX, centreY)
}
val dx = mid.x - from.x
val dy = mid.y - from.y
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
}
/** The nearest of [among] within [reach] pixels of [at], or null. */
fun nearestVertex(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { vertex(it) }
fun nearestEdge(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { edgeMidpoint(it) }
fun nearestHex(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { hexCenter(it) }
private fun nearest(at: Pt, reach: Float, among: Collection<Int>, position: (Int) -> Pt): Int? {
var best: Int? = null
var bestDistance = reach * reach
for (candidate in among) {
val p = position(candidate)
val dx = p.x - at.x
val dy = p.y - at.y
val d = dx * dx + dy * dy
if (d <= bestDistance) {
best = candidate
bestDistance = d
}
}
return best
}
}
object CatanGeometry {
/** Empty space round the island, in hex radii, for the sea and the harbours. */
const val MARGIN = 1.75f
/** A Seafarers frame already ends in a ring of sea, so it needs almost none. */
private const val FRAME_MARGIN = 0.3f
private fun margin(topo: Topology) = if (topo.land.all { it }) MARGIN else FRAME_MARGIN
private fun widthInRadii(topo: Topology) =
(topo.lattice[1] - topo.lattice[0]) * SQRT3 / 2f + 2 * margin(topo)
private fun heightInRadii(topo: Topology) =
(topo.lattice[3] - topo.lattice[2]) / 2f + 2 * margin(topo)
/** Width ÷ height of the board including its margin, for sizing its box. */
val ASPECT: Float = widthInRadii(CatanBoard.base) / heightInRadii(CatanBoard.base)
fun aspect(topo: Topology): Float = widthInRadii(topo) / heightInRadii(topo)
/** The largest layout that fits [width] × [height] pixels. */
fun fit(width: Float, height: Float, topo: Topology = CatanBoard.base): BoardLayout {
val radius = minOf(width / widthInRadii(topo), height / heightInRadii(topo))
return BoardLayout(width, height, radius, topo)
}
}
private const val SQRT3 = 1.7320508f
/** How far past the coast a harbour marker sits, in hex radii. Far enough that its piers are clearly visible. */
internal const val PORT_OFFSET = 1.0f
@@ -0,0 +1,474 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Surface
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/*
* The heads-up display pieces of the landscape Katan screen: the controls the
* Catan Universe match screen uses (see docs/RE-catan-universe.md), redrawn in
* 2D. Stateless, like everything in CatanPanels.kt: values in, lambdas out.
*/
/** A round HUD button: a glyph or short word, optionally with a one-word caption. */
@Composable
fun HudRoundButton(
glyph: String,
modifier: Modifier = Modifier,
caption: String? = null,
enabled: Boolean = true,
primary: Boolean = false,
tint: Color = Color.Unspecified,
diameter: Dp = 56.dp,
onClick: () -> Unit,
) {
val container = when {
tint != Color.Unspecified -> tint
primary -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondaryContainer
}
val ink = if (primary || tint != Color.Unspecified) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(diameter)
.clip(CircleShape)
.background(container.copy(alpha = if (enabled) 1f else 0.35f))
.border(2.dp, Color(0x55000000), CircleShape)
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier),
contentAlignment = Alignment.Center,
) {
Text(glyph, color = ink.copy(alpha = if (enabled) 1f else 0.6f), fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
// Always laid out, so the button column never changes height.
Text(caption ?: " ", fontSize = 10.sp, maxLines = 1)
}
}
/**
* The left-hand build column: one icon per piece. **Drag one onto the board** and
* release over a lit spot to stage it; or tap to arm the board and tap a spot. The
* column reports pointer positions in window coordinates so the screen can find the
* spot under the finger whatever the board's zoom and pan.
*/
@Composable
fun HudBuildColumn(
kinds: List<BuildKind>,
canBuild: Set<BuildKind>,
armed: BuildKind?,
modifier: Modifier = Modifier,
onTap: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(3.dp), horizontalAlignment = Alignment.CenterHorizontally) {
kinds.forEach { kind ->
BuildChip(kind, enabled = kind in canBuild, armed = armed == kind, onTap, onDragStart, onDrag, onDragEnd)
}
}
}
@Composable
private fun BuildChip(
kind: BuildKind,
enabled: Boolean,
armed: Boolean,
onTap: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
) {
var coords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val start by rememberUpdatedState(onDragStart)
val move by rememberUpdatedState(onDrag)
val end by rememberUpdatedState(onDragEnd)
val tap by rememberUpdatedState(onTap)
val shape = RoundedCornerShape(12.dp)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(46.dp)
.clip(shape)
.background(
when {
armed -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = if (enabled) 1f else 0.35f)
},
)
.border(2.dp, Color(0x55000000), shape)
.onGloballyPositioned { coords = it }
.pointerInput(enabled, kind) {
if (!enabled) return@pointerInput
detectDragGestures(
onDragStart = { local ->
coords?.localToWindow(local)?.let { start(kind, it) }
},
onDrag = { change, _ ->
change.consume()
coords?.localToWindow(change.position)?.let { move(it) }
},
onDragEnd = { end() },
onDragCancel = { end() },
)
}
.then(if (enabled) Modifier.clickable { tap(kind) } else Modifier),
contentAlignment = Alignment.Center,
) {
Text(
CatanHudGlyphs.of(kind),
fontSize = 22.sp,
color = if (armed) Color.White else MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Text(CatanHudGlyphs.label(kind), fontSize = 10.sp, maxLines = 1)
}
}
/** Glyphs and captions of the build column. Kept apart so the art can be swapped for drawables. */
object CatanHudGlyphs {
fun of(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> "━"
BuildKind.SETTLEMENT -> "⌂"
BuildKind.CITY -> "▲"
BuildKind.WALL -> "▦"
BuildKind.KNIGHT -> "♞"
BuildKind.SHIP -> "⛵"
}
fun label(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
}
}
/** ✓ beside ✗: confirm or cancel a staged placement. Always laid out; enabled only while something is staged. */
@Composable
fun HudConfirmButtons(
staged: Boolean,
modifier: Modifier = Modifier,
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.Bottom) {
HudRoundButton("✓", caption = CatanText.CONFIRM_BUILD, enabled = staged, tint = Color(0xFF2E7D32), diameter = 48.dp, onClick = onConfirm)
HudRoundButton("✕", caption = CatanText.CANCEL_BUILD, enabled = staged, tint = Color(0xFFC62828), diameter = 48.dp, onClick = onCancel)
}
}
/**
* Pan with one finger, pinch to zoom, like the Universe board camera. The content
* is drawn through a graphics layer, so taps inside it arrive already in the
* content's own coordinates and the board's hit-testing needs no changes.
*/
@Composable
fun ZoomPanBox(
modifier: Modifier = Modifier,
minScale: Float = 1f,
maxScale: Float = 3f,
content: @Composable () -> Unit,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.clip(RoundedCornerShape(0.dp))
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, _ ->
val newScale = (scale * zoom).coerceIn(minScale, maxScale)
val centre = Offset(size.width / 2f, size.height / 2f)
// The content point under the fingers stays under them while zooming; then pan.
val world = (centroid - centre - offset) / scale
val next = centroid - centre - world * newScale + pan
val maxX = size.width * (newScale - 1f) / 2f
val maxY = size.height * (newScale - 1f) / 2f
offset = Offset(next.x.coerceIn(-maxX, maxX), next.y.coerceIn(-maxY, maxY))
scale = newScale
}
},
) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
contentAlignment = Alignment.Center,
) { content() }
}
}
/** A small caption pill, for the drag hint and the staged-move text. */
@Composable
fun HudPill(text: String, modifier: Modifier = Modifier) {
Text(
text,
color = Color.White,
fontSize = 12.sp,
maxLines = 1,
modifier = modifier
.clip(RoundedCornerShape(50))
.background(Color(0xCC000000))
.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
/**
* The landscape screen, laid out like the Catan Universe match view:
*
* ```
* ┌ player strip ───────────────────────────────────────────────┐
* │ prompt + dice │
* │ [build] the island (pinch / pan) [✓] [✕] │
* │ [column] │
* │ [log] ┌ your cards ┐ [buy][play] [⇄][●]│
* └─────────────┴────────────┴───────────────────────────────────┘
* ```
*
* Everything here is wiring around the existing panels; the island is handed in as [board].
*/
@Composable
fun CatanHud(
state: org.prolibertate.games.game.catan.CatanState,
seats: CatanSeats,
style: CatanStyle,
actions: CatanActions,
prompt: String,
notice: String?,
legal: List<org.prolibertate.games.game.catan.CatanMove>,
localSeat: Int,
targets: BoardTargets,
pointsOf: (Int) -> Int,
staged: org.prolibertate.games.game.catan.CatanMove?,
dragKind: BuildKind?,
dragAt: Offset?,
showLog: Boolean,
boardCoords: LayoutCoordinates?,
building: BuildKind?,
onBoardCoords: (LayoutCoordinates) -> Unit,
onToggleLog: () -> Unit,
onArm: (BuildKind) -> Unit,
onDragStart: (BuildKind, Offset) -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
onConfirm: () -> Unit,
onCancelStaged: () -> Unit,
onRoll: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onEndSmith: () -> Unit,
onRepickSetup: () -> Unit,
onBuyCard: () -> Unit,
onPlayCard: () -> Unit,
onTrade: () -> Unit,
onImprove: () -> Unit,
onProgress: () -> Unit,
board: @Composable (Modifier) -> Unit,
) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
HudPlayerStrip(state, seats, pointsOf, style)
// Cities & Knights: the ship and the improvement levels are as much part
// of the game as the scores, so they sit with them rather than in a panel
// this layout does not have room for.
if (state.isCk) CatanBarbarians(state, Modifier.padding(horizontal = 8.dp), style)
Box(Modifier.weight(1f).fillMaxWidth()) {
// The island fills the whole area; controls float over its sea margin.
ZoomPanBox(Modifier.fillMaxSize().padding(start = 64.dp, end = 64.dp, top = 30.dp, bottom = 44.dp)) {
board(Modifier.onGloballyPositioned(onBoardCoords))
}
// What is going on, and the dice. Two lines, always.
Row(
Modifier.align(Alignment.TopCenter).padding(horizontal = 64.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f))
.padding(horizontal = 8.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = notice ?: prompt,
style = MaterialTheme.typography.bodySmall,
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
minLines = 2,
maxLines = 2,
modifier = Modifier.weight(1f),
)
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
}
// Left: pieces to drag onto the island.
HudBuildColumn(
kinds = actions.buildKinds,
canBuild = actions.canBuild,
armed = building,
modifier = Modifier.align(Alignment.CenterStart).padding(start = 6.dp),
onTap = onArm,
onDragStart = onDragStart,
onDrag = onDrag,
onDragEnd = onDragEnd,
)
// Right, middle: the card actions.
Column(
Modifier.align(Alignment.CenterEnd).padding(end = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (actions.ck) {
HudRoundButton("⬆", caption = CatanText.IMPROVE, enabled = actions.canImprove, diameter = 44.dp, onClick = onImprove)
HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayProgress, diameter = 44.dp, onClick = onProgress)
} else {
HudRoundButton("+", caption = CatanText.BUY_SHORT, enabled = actions.canBuyCard, diameter = 44.dp, onClick = onBuyCard)
HudRoundButton("🂠", caption = CatanText.CARDS_SHORT, enabled = actions.canPlayCard, diameter = 44.dp, onClick = onPlayCard)
}
}
// Bottom centre: your cards.
Box(
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp, start = 80.dp, end = 270.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
.horizontalScroll(rememberScrollState())
.padding(6.dp),
) { CatanHand(state, localSeat) }
// Bottom left: the log.
Column(Modifier.align(Alignment.BottomStart).padding(start = 6.dp, bottom = 4.dp)) {
if (showLog) {
Surface(
modifier = Modifier.padding(bottom = 6.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
tonalElevation = 4.dp,
) {
CatanLog(state, Modifier.padding(8.dp).height(150.dp).width(260.dp))
}
}
HudRoundButton("☰", caption = CatanText.LOG_BUTTON, diameter = 40.dp, onClick = onToggleLog)
}
// Bottom right: place / cancel a lined-up piece, trade, and the turn button.
Row(
Modifier.align(Alignment.BottomEnd).padding(end = 6.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.Bottom,
) {
HudConfirmButtons(staged = staged != null, onConfirm = onConfirm, onCancel = onCancelStaged)
HudRoundButton("⇄", caption = CatanText.TRADE, enabled = actions.canTrade, diameter = 48.dp, onClick = onTrade)
PrimaryTurnButton(actions, onRoll, onEndTurn, onEndFreeRoads, onEndSmith, onRepickSetup)
}
if (dragKind != null && dragAt == null) HudPill(CatanText.DRAG_HINT, Modifier.align(Alignment.TopCenter))
}
}
}
/** The one big button: roll, end the turn, finish roads, or step back from a setup choice. */
@Composable
private fun PrimaryTurnButton(
actions: CatanActions,
onRoll: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onEndSmith: () -> Unit,
onRepickSetup: () -> Unit,
) {
val (glyph, caption, enabled, click) = when {
actions.canRoll -> Quad("🎲", CatanText.ROLL_SHORT, true, onRoll)
actions.canEndFreeRoads -> Quad("✔", CatanText.DONE_SHORT, true, onEndFreeRoads)
actions.canEndSmith -> Quad("✔", CatanText.DONE_SHORT, true, onEndSmith)
actions.canRepickSetup -> Quad("↺", CatanText.CANCEL, true, onRepickSetup)
else -> Quad("➜", CatanText.END_SHORT, actions.canEndTurn, onEndTurn)
}
HudRoundButton(glyph, caption = caption, enabled = enabled, primary = true, diameter = 72.dp, onClick = click)
}
private data class Quad(val glyph: String, val caption: String, val enabled: Boolean, val click: () -> Unit)
/** Everyone's colour, name, points and card count in one thin row across the top. */
@Composable
private fun HudPlayerStrip(
state: org.prolibertate.games.game.catan.CatanState,
seats: CatanSeats,
pointsOf: (Int) -> Int,
style: CatanStyle,
) {
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(0 until state.playerCount).forEach { seat ->
val onTurn = seat == state.turn
val shape = RoundedCornerShape(10.dp)
Row(
Modifier.weight(1f)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(if (onTurn) 2.dp else 1.dp, if (onTurn) MaterialTheme.colorScheme.primary else Color(0x33000000), shape)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
Modifier.size(14.dp).clip(CircleShape).background(style.seats[seat]).border(1.dp, style.pieceOutline, CircleShape),
)
Text(seats.label(seat), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, modifier = Modifier.weight(1f))
// Trade, politics, science: the three improvement levels.
if (state.isCk) {
Text(
text = org.prolibertate.games.game.catan.Track.entries.joinToString(" ") {
"${it.name.first()}${org.prolibertate.games.game.catan.CatanCk.improvement(state, seat, it)}"
},
fontSize = 12.sp,
maxLines = 1,
)
}
Text("${pointsOf(seat)} VP · ${state.resourceCount(seat)}🂠", fontSize = 12.sp, maxLines = 1)
}
}
}
}
@@ -0,0 +1,691 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildShip
import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.Topology
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.Chore
import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DevCard
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.GoodsCounter
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.HireKnight
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PickGold
import org.prolibertate.games.game.catan.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis
import org.prolibertate.games.game.catan.TradeCounter
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.PlayYearOfPlenty
import org.prolibertate.games.game.catan.ProgressCard
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.catan.PromoteKnight
import org.prolibertate.games.game.catan.RelocateKnight
import org.prolibertate.games.game.catan.RemoveKnight
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.Track
/**
* What a player can ask to build by tapping the board. Wall and Knight exist
* only in Cities & Knights; the base game's build menu lists the first three.
*/
enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
/** Seafarers: a ship, laid on a sea edge. */
SHIP;
companion object {
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
/** Seafarers adds the ship, listed after the road it sails beside. */
val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, SETTLEMENT, CITY)
}
}
/**
* The parts of the board the player may tap right now. Everything else is inert.
* [knights] are crossings holding one of the player's own knights that can do
* something this turn: tapping one opens its menu.
*/
data class BoardTargets(
val vertices: Set<Int> = emptySet(),
val edges: Set<Int> = emptySet(),
val hexes: Set<Int> = emptySet(),
val knights: Set<Int> = emptySet(),
/** Seafarers: the player's ships that can sail this turn, tapped to pick one up. */
val ships: Set<Int> = emptySet(),
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
val idleKnights: Set<Int> = emptySet(),
) {
val isEmpty: Boolean
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() &&
idleKnights.isEmpty() && ships.isEmpty()
/** Every crossing a tap can land on. */
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
}
/**
* A progress card that is waiting for a tap on the board: [plays] are every play
* of it the engine accepts, and the player's tap picks one. [firstHex] is the
* first of the two hexes an Inventor swaps, once tapped.
*/
data class ArmedCard(val kind: ProgressType, val plays: List<PlayProgress>, val firstHex: Int? = null)
/** One progress card in the local player's hand, and the plays the engine allows for it. */
data class ProgressInHand(val card: ProgressCard, val plays: List<PlayProgress>) {
val playable: Boolean get() = plays.isNotEmpty()
}
/** What a tap on the board amounts to. */
sealed interface TapOutcome {
/** Nothing: the tap landed somewhere that is not a legal target. */
data object Ignored : TapOutcome
/** Send this move to the table. */
data class Submit(val move: CatanMove) : TapOutcome
/** Setup: remember this settlement; the road that goes with it is tapped next. */
data class PickSetupVertex(val vertex: Int) : TapOutcome
/** The robber's (or the pirate's) hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>, val pirate: Boolean = false) : TapOutcome
/** Seafarers: one of the player's ships was tapped; its destination is tapped next. */
data class PickShip(val edge: Int) : TapOutcome
/** One of the player's own knights was tapped: open its menu. */
data class PickKnight(val vertex: Int) : TapOutcome
/** An Inventor's first hex was tapped; the second completes the swap. */
data class PickFirstHex(val hex: Int) : TapOutcome
}
/**
* One development card in the local player's hand, and what can be done with it
* right now. [plays] is empty when it cannot be played this turn (bought this
* turn, a card has already been played, a victory-point card, or it is not the
* player's turn).
*/
data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
val playable: Boolean get() = plays.isNotEmpty()
}
/**
* The rules of *tapping*: given the game state, the moves the engine says are
* legal, and what the player has selected, what can be tapped and what a tap does.
*
* Pure functions with no Android and no Compose, so the whole interaction model is
* unit-tested (`CatanInteractionTest`). The screen holds the two bits of UI state
* ([BuildKind] and the chosen setup settlement) and passes them in; it contains no
* game rules of its own. **Anything that decides whether a tap is allowed belongs
* here, never in a composable** — the engine's [legal] list is the single source
* of truth, so the UI can never offer a move the rules would refuse.
*/
object CatanInteraction {
// --- Staged placements and drag-to-build ------------------------------------
/** A place on the board a dragged piece is over: one crossing or one edge. */
data class Spot(val vertex: Int? = null, val edge: Int? = null)
/**
* The spot a finger at [at] (board pixels) is nearest to, among [targets]. [reach] is
* generous (fingers cover what they point at), and edges are only offered when no
* crossing is lit, matching what a tap would do.
*/
fun snap(layout: BoardLayout, targets: BoardTargets, at: Pt, reach: Float): Spot? = when {
targets.tappableVertices.isNotEmpty() ->
layout.nearestVertex(at, reach, targets.tappableVertices)?.let { Spot(vertex = it) }
targets.edges.isNotEmpty() -> layout.nearestEdge(at, reach, targets.edges)?.let { Spot(edge = it) }
else -> null
}
/** The move a build tap or drop would make, if it is a placement that waits for ✓. */
fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? {
if (building == null || outcome !is TapOutcome.Submit) return null
return outcome.move.takeIf {
it is BuildRoad || it is BuildShip || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight
}
}
/** Where a staged move sits on the board, for drawing its ghost. */
fun spotOf(move: CatanMove?): Spot = when (move) {
is BuildRoad -> Spot(edge = move.edge)
is BuildShip -> Spot(edge = move.edge)
is BuildSettlement -> Spot(vertex = move.vertex)
is BuildCity -> Spot(vertex = move.vertex)
is BuildWall -> Spot(vertex = move.vertex)
is HireKnight -> Spot(vertex = move.vertex)
else -> Spot()
}
// --- Board ---------------------------------------------------------------
fun targets(
state: CatanState,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
movingKnight: Int? = null,
armed: ArmedCard? = null,
/** The local seat, so its sleeping knights can be tapped for their menu too. */
localSeat: Int? = null,
/** Seafarers: the ship that has been picked up and is waiting for a destination. */
movingShip: Int? = null,
): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
return if (setupVertex == null) {
BoardTargets(vertices = setup.map { it.vertex }.toSet())
} else {
BoardTargets(edges = setup.filter { it.vertex == setupVertex }.map { it.edge }.toSet())
}
}
val robber = legal.filterIsInstance<MoveRobber>()
val pirate = legal.filterIsInstance<MovePirate>()
if (robber.isNotEmpty() || pirate.isNotEmpty()) {
return BoardTargets(
hexes = (robber.map { it.hex } + pirate.map { it.hex }).mapNotNull { state.topo.hexIndex[it] }.toSet(),
)
}
// Cities & Knights: answers to a queued chore are taps on the board.
val chore = choreVertices(legal)
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
val ships = legal.filterIsInstance<BuildShip>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads + ships)
if (movingShip != null) {
return BoardTargets(
edges = legal.filterIsInstance<MoveShip>().filter { it.from == movingShip }.map { it.to }.toSet(),
)
}
if (armed != null) return armedTargets(armed)
if (movingKnight != null) {
return BoardTargets(
vertices = legal.filterIsInstance<MoveKnight>().filter { it.from == movingKnight }.map { it.to }.toSet(),
)
}
return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SHIP -> BoardTargets(edges = ships)
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
BuildKind.CITY ->
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
BuildKind.WALL ->
BoardTargets(vertices = legal.filterIsInstance<BuildWall>().map { it.vertex }.toSet())
BuildKind.KNIGHT ->
BoardTargets(vertices = legal.filterIsInstance<HireKnight>().map { it.vertex }.toSet())
null -> {
val ready = knightsWithActions(legal)
val mine = if (localSeat != null && legal.isNotEmpty()) {
state.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
} else {
emptySet()
}
BoardTargets(
knights = ready,
idleKnights = mine - ready,
ships = legal.filterIsInstance<MoveShip>().map { it.from }.toSet(),
)
}
}
}
/** The crossings a queued chore is asking the player to tap, from the legal list. */
private fun choreVertices(legal: List<CatanMove>): Set<Int> = buildSet {
legal.forEach { move ->
when (move) {
is PillageCity -> add(move.vertex)
is PlaceMetropolis -> add(move.vertex)
is RelocateKnight -> add(move.to)
is RemoveKnight -> add(move.vertex)
is PlaceKnight -> add(move.vertex)
else -> Unit
}
}
// A Smith's promotions are knights to tap, while the chore is open.
if (legal.any { it is EndSmith }) {
legal.filterIsInstance<PromoteKnight>().forEach { add(it.vertex) }
}
}
private fun choreMove(legal: List<CatanMove>, vertex: Int): CatanMove? = legal.firstOrNull { move ->
when (move) {
is PillageCity -> move.vertex == vertex
is PlaceMetropolis -> move.vertex == vertex
is RelocateKnight -> move.to == vertex
is RemoveKnight -> move.vertex == vertex
is PlaceKnight -> move.vertex == vertex
is PromoteKnight -> legal.any { it is EndSmith } && move.vertex == vertex
else -> false
}
}
// --- Cities & Knights: knights ---------------------------------------------
/** Crossings of the player's knights that have an action available. */
fun knightsWithActions(legal: List<CatanMove>): Set<Int> = buildSet {
legal.forEach { move ->
when (move) {
is ActivateKnight -> add(move.vertex)
is MoveKnight -> add(move.from)
is ChaseRobber -> add(move.vertex)
is PromoteKnight -> if (legal.none { it is EndSmith }) add(move.vertex)
else -> Unit
}
}
}
/** What the knight on [vertex] can do right now, in the order its menu lists them. */
fun knightActions(legal: List<CatanMove>, vertex: Int): List<CatanMove> =
legal.filter { move ->
when (move) {
is ActivateKnight -> move.vertex == vertex
is PromoteKnight -> move.vertex == vertex && legal.none { it is EndSmith }
is ChaseRobber -> move.vertex == vertex
else -> false
}
} + listOfNotNull(legal.firstOrNull { it is MoveKnight && it.from == vertex })
fun canEndSmith(legal: List<CatanMove>): Boolean = legal.any { it is EndSmith }
// --- Cities & Knights: progress cards --------------------------------------
/** [seat]'s progress cards, each with the plays the engine currently allows. */
fun progressHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<ProgressInHand> =
state.ck?.progressHands?.getOrNull(seat).orEmpty().map { card ->
ProgressInHand(card, legal.filterIsInstance<PlayProgress>().filter { it.card == card.id })
}.sortedWith(compareBy({ it.card.kind.track.ordinal }, { it.card.kind.ordinal }, { it.card.id }))
/** The cards that are played by tapping the board, not by choosing in a dialog. */
val BOARD_CARDS: Set<ProgressType> = setOf(
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.MERCHANT,
ProgressType.DIPLOMAT, ProgressType.INTRIGUE, ProgressType.INVENTOR,
)
private fun armedTargets(armed: ArmedCard): BoardTargets = when (armed.kind) {
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.INTRIGUE ->
BoardTargets(vertices = armed.plays.mapNotNull { it.vertex }.toSet())
ProgressType.DIPLOMAT -> BoardTargets(edges = armed.plays.mapNotNull { it.edge }.toSet())
ProgressType.MERCHANT -> BoardTargets(hexes = armed.plays.mapNotNull { it.hexA }.toSet())
ProgressType.INVENTOR -> {
val first = armed.firstHex
val all = armed.plays.flatMap { listOfNotNull(it.hexA, it.hexB) }.toSet()
if (first == null) {
BoardTargets(hexes = all)
} else {
BoardTargets(
hexes = armed.plays
.filter { it.hexA == first || it.hexB == first }
.flatMap { listOfNotNull(it.hexA, it.hexB) }
.filter { it != first }
.toSet(),
)
}
}
else -> BoardTargets()
}
/** The improvement tracks the player can buy a level of right now. */
fun improvable(legal: List<CatanMove>): Set<Track> =
legal.filterIsInstance<BuyImprovement>().map { it.track }.toSet()
// --- Cities & Knights: goods ----------------------------------------------
/** The gold pick that names exactly [selection], if the engine allows it. */
fun goldMove(legal: List<CatanMove>, selection: List<Resource>): PickGold? {
val want = selection.groupingBy { it }.eachCount()
return legal.filterIsInstance<PickGold>().firstOrNull { it.cards.groupingBy { c -> c }.eachCount() == want }
}
/** How many cards the gold pick on offer must name. */
fun goldCount(legal: List<CatanMove>): Int = legal.filterIsInstance<PickGold>().firstOrNull()?.cards?.size ?: 0
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
legal.filterIsInstance<GoodsBankTrade>()
.groupBy({ it.give }, { it.get })
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
.toSortedMap(compareBy<Good> { it.ordinal })
fun goodsOfferAllowed(
state: CatanState,
seat: Int,
to: Int?,
give: List<Good>,
get: List<Good>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get))
/** Whether [seat] may answer the goods offer on the table with [give] for [get]. */
fun goodsCounterAllowed(state: CatanState, seat: Int, give: List<Good>, get: List<Good>): Boolean =
give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, CounterGoods(give, get))
/** The goods counter-offers the offerer may take right now, in the order they came in. */
fun goodsCounterChoices(state: CatanState, legal: List<CatanMove>): List<GoodsCounter> {
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
return state.ck?.goodsOffer?.counters.orEmpty().filter { it.by in takeable }
}
/** The chore at the front of the queue, if the table is waiting on one. */
fun chore(state: CatanState): Chore? =
if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null
fun onVertexTap(
vertex: Int,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
movingKnight: Int? = null,
armed: ArmedCard? = null,
/** The local player's knights, which can be tapped for their menu even with nothing to do. */
ownKnights: Set<Int> = emptySet(),
): TapOutcome {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
// Re-picking is allowed until the road is chosen, so a misplaced
// tap does not commit the settlement.
return if (setup.any { it.vertex == vertex }) {
TapOutcome.PickSetupVertex(vertex)
} else {
TapOutcome.Ignored
}
}
choreMove(legal, vertex)?.let { return TapOutcome.Submit(it) }
if (armed != null) {
val play = armed.plays.firstOrNull { it.vertex == vertex } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
if (movingKnight != null) {
val move = MoveKnight(movingKnight, vertex)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
val move: CatanMove = when (building) {
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
BuildKind.CITY -> BuildCity(vertex)
BuildKind.WALL -> BuildWall(vertex)
BuildKind.KNIGHT -> HireKnight(vertex)
else -> return if (building == null && (vertex in knightsWithActions(legal) || vertex in ownKnights)) {
TapOutcome.PickKnight(vertex)
} else {
TapOutcome.Ignored
}
}
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onEdgeTap(
edge: Int,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
armed: ArmedCard? = null,
/** Seafarers: the ship picked up and waiting for a destination. */
movingShip: Int? = null,
): TapOutcome {
if (legal.any { it is SetupPlace }) {
if (setupVertex == null) return TapOutcome.Ignored
val move = SetupPlace(setupVertex, edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
if (armed != null) {
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
if (movingShip != null) {
val move = MoveShip(movingShip, edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
// Free roads (and free ships) need no build mode; a paid one does.
val free = legal.any { it is EndFreeRoads }
if (building == BuildKind.SHIP || (free && BuildShip(edge) in legal)) {
val move = BuildShip(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
if (building == null && !free) {
// Nothing armed: tapping one of your own ships that can sail picks it up.
return if (legal.any { it is MoveShip && it.from == edge }) TapOutcome.PickShip(edge) else TapOutcome.Ignored
}
val canBuild = building == BuildKind.ROAD || free
if (!canBuild) return TapOutcome.Ignored
val move = BuildRoad(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onHexTap(
hex: Int,
legal: List<CatanMove>,
armed: ArmedCard? = null,
/** The board the hex index belongs to (the standard island unless the game is Seafarers). */
topo: Topology = CatanBoard.base,
): TapOutcome {
if (armed != null) return onArmedHexTap(hex, legal, armed)
val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
return when {
moves.isNotEmpty() -> if (moves.size == 1) {
TapOutcome.Submit(moves.first())
} else {
TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
}
pirates.isNotEmpty() -> if (pirates.size == 1) {
TapOutcome.Submit(pirates.first())
} else {
TapOutcome.PickRobberVictim(coord, pirates.mapNotNull { it.victim }, pirate = true)
}
else -> TapOutcome.Ignored
}
}
private fun onArmedHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard): TapOutcome {
if (armed.kind == ProgressType.INVENTOR) {
val first = armed.firstHex ?: return if (hex in armedTargets(armed).hexes) {
TapOutcome.PickFirstHex(hex)
} else {
TapOutcome.Ignored
}
val play = armed.plays.firstOrNull {
(it.hexA == first && it.hexB == hex) || (it.hexA == hex && it.hexB == first)
} ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
val play = armed.plays.firstOrNull { it.hexA == hex } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
// --- Action bar ------------------------------------------------------------
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
/**
* The hexes that paid out for this turn's roll, so the board can light them.
* Empty until the turn's roll has happened (the dice left over from the last
* turn are not this turn's), on a seven, and for the hex the robber sits on.
*/
fun rolledHexes(state: CatanState): Set<Int> {
if (state.lastDice.size != 2) return emptySet()
if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST ||
state.phase == CatanPhase.SETUP_SECOND
) {
return emptySet()
}
val total = state.lastDice.sum()
if (total == 7) return emptySet()
return (0 until state.topo.HEX_COUNT)
.filter { state.numbers[it] == total && it != state.robber }
.toSet()
}
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
fun canBuyDevCard(legal: List<CatanMove>): Boolean = legal.any { it is BuyDevCard }
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
if (legal.any { it is BuildShip }) add(BuildKind.SHIP)
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
}
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
legal.any { it is AcceptTrade } to legal.any { it is DeclineTrade }
// --- Development cards -----------------------------------------------------
/** [seat]'s development cards, each with the plays the engine currently allows. */
fun devHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<DevCardInHand> =
state.devHands.getOrNull(seat).orEmpty().map { card ->
DevCardInHand(card, legal.filter { playOf(it) == card.id })
}.sortedWith(compareBy({ it.card.type.ordinal }, { it.card.id }))
private fun playOf(move: CatanMove): Int? = when (move) {
is PlayKnight -> move.card
is PlayRoadBuilding -> move.card
is PlayMonopoly -> move.card
is PlayYearOfPlenty -> move.card
else -> null
}
/** The Monopoly moves for one card, by the resource each would name. */
fun monopolyChoices(plays: List<CatanMove>): Map<Resource, PlayMonopoly> =
plays.filterIsInstance<PlayMonopoly>().associateBy { it.resource }
/** The Year of Plenty move that takes exactly [take], if the engine allows it. */
fun plentyMove(plays: List<CatanMove>, take: List<Resource>): PlayYearOfPlenty? {
val want = take.groupingBy { it }.eachCount()
return plays.filterIsInstance<PlayYearOfPlenty>().firstOrNull {
it.take.groupingBy { r -> r }.eachCount() == want
}
}
/**
* Resources a Year of Plenty can still be asked for, given what is already
* [picked]: those that complete some take the engine allows. Empty once the
* two cards are chosen.
*/
fun plentyAvailable(plays: List<CatanMove>, picked: List<Resource>): Set<Resource> {
val chosen = picked.groupingBy { it }.eachCount()
val available = mutableSetOf<Resource>()
for (play in plays.filterIsInstance<PlayYearOfPlenty>()) {
if (!chosen.all { (resource, n) -> play.take.count { it == resource } >= n }) continue
val remaining = play.take.toMutableList()
picked.forEach { remaining.remove(it) }
available += remaining
}
return available
}
// --- Discarding -----------------------------------------------------------
/** How many cards a player holding [held] must discard on a seven. */
fun discardQuota(held: Int): Int = held / 2
/** The discard move that gives up exactly [selection], if the engine allows it. */
fun discardMove(legal: List<CatanMove>, selection: List<Resource>): Discard? {
val want = selection.groupingBy { it }.eachCount()
return legal.filterIsInstance<Discard>().firstOrNull {
it.cards.groupingBy { r -> r }.eachCount() == want
}
}
// --- Trading ---------------------------------------------------------------
/** Bank trades on offer: what can be given, and for what. */
fun bankTrades(legal: List<CatanMove>): Map<Resource, List<Resource>> =
legal.filterIsInstance<MaritimeTrade>()
.groupBy({ it.give }, { it.get })
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
.toSortedMap(compareBy<Resource> { it.ordinal })
/** What it costs [seat] to give one [resource] to the bank: 4, 3 or 2 cards. */
fun bankRatio(state: CatanState, seat: Int, resource: Resource): Int =
CatanRules.bestPortRatio(state, seat, resource)
/**
* Whether [seat] can offer [give] for [get] to [to] right now. Uses the
* engine's own structural check, since offers are not in the enumerated
* move list.
*/
fun offerAllowed(
state: CatanState,
seat: Int,
to: Int?,
give: List<Resource>,
get: List<Resource>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get))
/** Whether [seat] may answer the offer on the table with [give] for [get]. */
fun counterAllowed(state: CatanState, seat: Int, give: List<Resource>, get: List<Resource>): Boolean =
give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, CounterTrade(give, get))
/** The counter-offers the offerer may take right now, in the order they came in. */
fun counterChoices(state: CatanState, legal: List<CatanMove>): List<TradeCounter> {
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
return state.pendingTrade?.counters.orEmpty().filter { it.by in takeable }
}
}
@@ -0,0 +1,636 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.EventFace
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Track
/*
* Stateless building blocks for the Katan screen.
*
* Every composable here takes plain values and reports events through lambdas;
* none of them holds game state or touches the controller, so each can be
* restyled, re-laid-out or replaced on its own. Colours and proportions come
* from [CatanStyle] and wording from [CatanText].
*/
// ---------------------------------------------------------------------------
// Resource tile
// ---------------------------------------------------------------------------
/**
* One resource as a coloured tile with its count. The hand, the discard picker
* and the trade pickers all use this, so a resource looks the same everywhere.
*/
@Composable
fun ResourceTile(
resource: Resource,
count: Int,
modifier: Modifier = Modifier,
selected: Boolean = false,
enabled: Boolean = true,
style: CatanStyle = LocalCatanStyle.current,
onClick: (() -> Unit)? = null,
) {
val shape = RoundedCornerShape(10.dp)
Column(
modifier = modifier
.defaultMinSize(minWidth = 56.dp)
.clip(shape)
.background(style.resource.getValue(resource).copy(alpha = if (enabled) 1f else 0.4f))
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) Color.White else Color(0x33000000),
shape = shape,
)
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 6.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = count.toString(),
color = style.resourceInk,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Text(
text = CatanText.resource(resource),
color = style.resourceInk,
fontSize = 11.sp,
maxLines = 1,
)
}
}
/**
* One card type — a resource or a commodity — as a coloured tile with its count.
* Cities & Knights hands, discards and trades use this so commodities look the
* same everywhere.
*/
@Composable
fun GoodTile(
good: Good,
count: Int,
modifier: Modifier = Modifier,
selected: Boolean = false,
enabled: Boolean = true,
style: CatanStyle = LocalCatanStyle.current,
onClick: (() -> Unit)? = null,
) {
val resource = good.resource
if (resource != null) {
ResourceTile(resource, count, modifier, selected, enabled, style, onClick)
return
}
val commodity = good.commodity!!
val shape = RoundedCornerShape(10.dp)
val ink = style.commodityInk.getValue(commodity)
Column(
modifier = modifier
.defaultMinSize(minWidth = 56.dp)
.clip(shape)
.background(style.commodity.getValue(commodity).copy(alpha = if (enabled) 1f else 0.4f))
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) Color.White else Color(0x33000000),
shape = shape,
)
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 6.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = count.toString(), color = ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(text = CatanText.good(good), color = ink, fontSize = 11.sp, maxLines = 1)
}
}
/**
* The local player's cards: one tile per resource, and in Cities & Knights one
* per commodity as well, in a row that wraps.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanHand(
state: CatanState,
seat: Int,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (state.isCk) {
val held = CatanCk.goodsOf(state, seat)
Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) }
} else {
Resource.entries.forEach { resource ->
ResourceTile(resource, state.resources[seat][resource] ?: 0)
}
}
}
}
// ---------------------------------------------------------------------------
// Players
// ---------------------------------------------------------------------------
/**
* One player's public standing: colour, name, points, how many cards they hold
* (not which), and — in the roomier layout — development cards, knights and
* their longest road.
*
* **Fixed height.** Every line is always present (blank when there is nothing to
* say) and never wraps, so the card is the same height whatever the game is
* doing. The board is given whatever room is left, so anything that made this
* card taller or shorter would rescale the island under the player's thumb.
*
* [points] is passed in rather than computed here because the local player's
* own count includes hidden victory-point cards and everyone else's does not;
* the screen decides which. [compact] is the narrow portrait strip (four cards
* across a phone); the default has room for the detail lines.
*/
@Composable
fun CatanPlayerCard(
state: CatanState,
seat: Int,
seats: CatanSeats,
points: Int,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
compact: Boolean = false,
) {
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
val shape = RoundedCornerShape(12.dp)
Column(
modifier = modifier
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(
width = if (onTurn) 3.dp else 1.dp,
color = if (onTurn) MaterialTheme.colorScheme.primary else Color(0x22000000),
shape = shape,
)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(
modifier = Modifier
.size(14.dp)
.clip(CircleShape)
.background(style.seats[seat % style.seats.size])
.border(1.dp, style.pieceOutline, CircleShape),
)
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
}
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
val ck = state.ck
if (ck != null) {
val cards = CatanCk.goodsCount(state, seat)
val strength = CatanCk.strength(state, seat)
if (compact) {
CardLine("$cards cards · ⚔$strength", MaterialTheme.typography.bodySmall)
} else {
CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall)
CardLine(
"⚔ ${CatanCk.knightsOf(state, seat).size} · str $strength · " +
"Road ${CatanRules.longestRoadFor(state, seat)}",
MaterialTheme.typography.bodySmall,
)
}
// Trade, politics, science: the three improvement levels.
CardLine(
Track.entries.joinToString(" ") { "${it.name.first()}${CatanCk.improvement(state, seat, it)}" },
MaterialTheme.typography.bodySmall,
color = Color.Unspecified,
)
val ckAwards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
ck.metropolises.forEach { (track, at) ->
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
}
if (ck.merchant?.seat == seat) add("Merchant")
}
CardLine(
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
style = MaterialTheme.typography.labelSmall,
weight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
return@Column
}
if (compact) {
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
} else {
CardLine(
"${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
MaterialTheme.typography.bodySmall,
)
CardLine(
"Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
MaterialTheme.typography.bodySmall,
)
}
val awards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
}
// Always drawn, so the card does not grow when somebody takes an award.
CardLine(
text = awards.joinToString(" · ").ifEmpty { BLANK },
style = MaterialTheme.typography.labelSmall,
weight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
/** A non-breaking space: a line of text with nothing in it, still one line tall. */
private const val BLANK = "\u00A0"
/** One line of a player card: never wraps, so the card's height cannot change. */
@Composable
private fun CardLine(
text: String,
style: TextStyle,
weight: FontWeight? = null,
color: Color = Color.Unspecified,
) {
Text(
text = text,
style = style,
fontWeight = weight,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// ---------------------------------------------------------------------------
// Dice
// ---------------------------------------------------------------------------
/**
* The two dice as last rolled, with their total. Blank until the first roll. In
* Cities & Knights the dice are yellow and red (the red one decides progress
* cards) and a third, the event die, shows a ship or a coloured city gate.
*/
@Composable
fun CatanDice(
dice: List<Int>,
modifier: Modifier = Modifier,
ck: Boolean = false,
event: EventFace? = null,
style: CatanStyle = LocalCatanStyle.current,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val shown = if (dice.size == 2) dice else listOf(null, null)
shown.forEachIndexed { index, value ->
val face = when {
!ck -> Color.White
index == 0 -> Color(0xFFFFF176)
else -> Color(0xFFEF9A9A)
}
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(face)
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = value?.toString() ?: "–",
color = Color(0xFF1C1B1F),
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
textAlign = TextAlign.Center,
)
}
}
if (ck) {
val gate = when (event) {
EventFace.TRADE -> style.track.getValue(Track.TRADE)
EventFace.POLITICS -> style.track.getValue(Track.POLITICS)
EventFace.SCIENCE -> style.track.getValue(Track.SCIENCE)
EventFace.SHIP -> style.barbarian
null -> Color(0x33000000)
}
Box(
modifier = Modifier
.size(width = 52.dp, height = 36.dp)
.clip(RoundedCornerShape(8.dp))
.background(gate)
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = if (event == EventFace.SHIP) "⛵ Ship" else CatanText.event(event),
color = if (event == EventFace.TRADE) Color(0xFF1C1B1F) else Color.White,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
maxLines = 1,
)
}
}
// Always drawn ("= –" before the first roll) so the row never changes width.
Text(
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
/**
* Cities & Knights: how close the barbarians are, and how the cities on the
* island compare with the knights standing ready. **Fixed height**, like every
* other strip around the board.
*/
@Composable
fun CatanBarbarians(state: CatanState, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current) {
val ck = state.ck ?: return
val cities = state.buildings.count { it?.type == BuildingType.CITY }
val defence = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
Row(
modifier = modifier.fillMaxWidth().height(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("⛵", fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) {
// The ship starts seven steps out and lands when the last box fills.
val sailed = BARBARIAN_STEPS_DISPLAY - ck.barbarians
for (i in 0 until BARBARIAN_STEPS_DISPLAY) {
Box(
modifier = Modifier
.weight(1f)
.height(10.dp)
.clip(RoundedCornerShape(3.dp))
.background(if (i < sailed) style.barbarian else Color(0x33000000)),
)
}
}
val verdict = if (defence >= cities) "holds" else "falls"
Text(
text = "${CatanText.barbarianSteps(ck.barbarians)} · knights $defence v. $cities cities ($verdict)",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** Steps shown on the barbarian strip: the engine's seven. */
private const val BARBARIAN_STEPS_DISPLAY = org.prolibertate.games.game.catan.BARBARIAN_STEPS
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
data class CatanActions(
val canRoll: Boolean = false,
val canBuild: Set<BuildKind> = emptySet(),
val building: BuildKind? = null,
val canBuyCard: Boolean = false,
val canPlayCard: Boolean = false,
val canTrade: Boolean = false,
val canEndTurn: Boolean = false,
val canEndFreeRoads: Boolean = false,
/** A setup settlement is chosen and a different one may still be picked. */
val canRepickSetup: Boolean = false,
/** Which kinds the Build menu lists: three in the base game, five in Cities & Knights. */
val buildKinds: List<BuildKind> = BuildKind.BASE,
/** Cities & Knights: the table plays the expansion, so the bar shows its buttons instead. */
val ck: Boolean = false,
val canImprove: Boolean = false,
val canPlayProgress: Boolean = false,
val progressCount: Int = 0,
val canEndSmith: Boolean = false,
)
/**
* The buttons for whatever the player can do now. Pure display: every press is a lambda.
*
* **The bar never changes size.** The top row holds the one "primary" action
* (roll, end turn, done with roads, cancel) and keeps its height when it is
* empty; the second row is always all six buttons, greyed out when they do not
* apply. Buttons appearing and disappearing used to resize this area and, with
* it, the board above — so nothing here may be conditionally *present*, only
* conditionally *enabled*.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanActionBar(
actions: CatanActions,
onRoll: () -> Unit,
onBuild: (BuildKind?) -> Unit,
onBuyCard: () -> Unit,
onPlayCard: () -> Unit,
onTrade: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onRepickSetup: () -> Unit,
modifier: Modifier = Modifier,
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
compact: Boolean = false,
onImprove: () -> Unit = {},
onProgress: () -> Unit = {},
onEndSmith: () -> Unit = {},
) {
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
if (actions.canEndSmith) Button(onClick = onEndSmith) { Text(CatanText.STOP_SMITH) }
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (compact || actions.ck) {
BuildMenuButton(actions, onBuild)
} else {
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
}
if (actions.ck) {
OutlinedButton(onClick = onImprove, enabled = actions.canImprove) {
LabelWithCost(CatanText.IMPROVE, CatanText.NO_COST)
}
OutlinedButton(onClick = onProgress, enabled = actions.canPlayProgress) {
LabelWithCost(CatanText.CARDS_BUTTON, "${actions.progressCount} held")
}
} else {
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
}
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
}
}
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
}
}
}
}
/** Room for one button, held whether or not there is one to show. */
private val PRIMARY_ROW_HEIGHT = 48.dp
@Composable
private fun BuildButton(
kind: BuildKind,
label: String,
cost: String,
actions: CatanActions,
onBuild: (BuildKind?) -> Unit,
) {
val enabled = kind in actions.canBuild
val active = actions.building == kind
val onClick = { onBuild(if (active) null else kind) }
if (active) {
FilledTonalButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
} else {
OutlinedButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
}
}
/**
* The small-screen stand-in for the three build buttons: one "Build" button
* whose menu lists Road, Settlement and City with their costs. While a build
* mode is armed the button shows which, and choosing that entry again cancels it.
*/
@Composable
private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) {
var open by remember { mutableStateOf(false) }
val armed = actions.building
val anyEnabled = actions.canBuild.isNotEmpty() || armed != null
val label: @Composable () -> Unit = {
LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST)
}
Box {
if (armed != null) {
FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() }
} else {
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
for (kind in actions.buildKinds) {
DropdownMenuItem(
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
enabled = kind in actions.canBuild,
onClick = {
open = false
onBuild(if (armed == kind) null else kind)
},
)
}
}
}
}
private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
}
private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD_COST
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
BuildKind.CITY -> CatanText.CITY_COST
BuildKind.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
}
@Composable
private fun LabelWithCost(label: String, cost: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(label)
Text(cost, fontSize = 9.sp, maxLines = 1)
}
}
// ---------------------------------------------------------------------------
// Log
// ---------------------------------------------------------------------------
/** The most recent happenings, newest last. Scrolls. */
@Composable
fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
if (state.log.isEmpty()) {
Text(CatanText.NO_LOG, style = MaterialTheme.typography.bodySmall, modifier = modifier)
return
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
state.log.takeLast(40).forEach {
Text(text = it, style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -0,0 +1,132 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/*
* Android Studio previews for the Katan UI. They exist so the look can be
* iterated on without starting a match: open this file, open the Preview pane,
* and edit CatanStyle / CatanText / the panels. Each preview draws a real
* position (the computer plays itself for a while), not a mock.
*
* Nothing here ships in a behaviour sense: previews are only compiled for tooling.
*/
private val previewJson = Json { encodeDefaults = true }
/** A real game, played forward by the computer for [moves] moves, for previewing. */
private fun sampleState(players: Int = 4, moves: Int = 90, seed: Long = 11L): CatanState {
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
optionsJson = previewJson.encodeToString(CatanOptions(playerCount = players)),
seed = seed,
)
val ai = CatanAi()
var state = CatanRules.initialState(config)
repeat(moves) {
if (state.phase == CatanPhase.GAME_OVER) return state
val seat = CatanRules.currentSeat(state) ?: return state
val legal = CatanRules.legalMoves(state, seat)
if (legal.isEmpty()) return state
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
return state
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardPreview() {
val state = remember { sampleState() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
CatanBoardView(
state = state,
targets = BoardTargets(),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardDarkPreview() {
val state = remember { sampleState() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = true)) {
CatanBoardView(
state = state,
targets = BoardTargets(),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardWithTargetsPreview() {
val state = remember { sampleState(moves = 6) }
val legal = remember(state) { CatanRules.currentSeat(state)?.let { CatanRules.legalMoves(state, it) }.orEmpty() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
CatanBoardView(
state = state,
targets = CatanInteraction.targets(state, legal, building = null, setupVertex = null),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 560)
@Composable
private fun PanelsPreview() {
val state = remember { sampleState() }
val seats = CatanSeats(me = 0, names = listOf("You", "Ann", "Bo", "Cy"))
val points = remember(state) { CatanRules.publicVictoryPoints(state) }
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(0 until state.playerCount).forEach { seat ->
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f), compact = true)
}
}
CatanHand(state, seat = 0)
CatanDice(state.lastDice)
CatanActionBar(
actions = CatanActions(
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
building = BuildKind.ROAD,
canBuyCard = true,
canTrade = true,
canEndTurn = true,
),
onRoll = {}, onBuild = {}, onBuyCard = {}, onPlayCard = {}, onTrade = {},
onEndTurn = {}, onEndFreeRoads = {}, onRepickSetup = {},
)
}
}
@@ -0,0 +1,731 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.background
import androidx.compose.material3.Surface
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntSize
import org.prolibertate.games.game.catan.BuildRoad
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.AqueductChore
import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DiscardChore
import org.prolibertate.games.game.catan.DiscardGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.DropProgressChore
import org.prolibertate.games.game.catan.DismissCounters
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.SpyChore
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
/**
* Katan, on the island.
*
* This composable is only wiring. It reads the match from the controller, holds
* the handful of purely visual choices (which build mode is armed, which setup
* settlement is waiting for its road, which dialog is open), and hands
* everything else to single-purpose pieces:
*
* | Concern | Where it lives |
* | ------------------------------ | -------------------------- |
* | Colours, sizes, shapes | [CatanStyle] |
* | Every word on screen | [CatanText] |
* | Island → pixels, taps → hexes | [CatanGeometry] |
* | What is tappable, what a tap does | [CatanInteraction] |
* | Drawing the island | [CatanBoardView] |
* | Cards, players, dice, buttons | `CatanPanels.kt` |
* | Every dialog | `CatanDialogs.kt` |
*
* There are no game rules here: the engine's legal-move list is the only source
* of what the player may do, so the screen cannot offer a move the rules refuse.
* See `docs/UI-catan.md` for how to adapt the look and feel.
*/
@Composable
fun CatanScreen(
controller: MatchController<CatanState, CatanMove>,
localSeat: Int,
onExit: () -> Unit,
seatNames: List<String> = emptyList(),
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
val notice by controller.notice.collectAsState()
// Purely visual state. Anything that affects the game goes through submit().
var building by remember { mutableStateOf<BuildKind?>(null) }
var setupVertex by remember { mutableStateOf<Int?>(null) }
var victimChoice by remember { mutableStateOf<TapOutcome.PickRobberVictim?>(null) }
var showCards by remember { mutableStateOf(false) }
var showTrade by remember { mutableStateOf(false) }
var confirmingEnd by remember { mutableStateOf(false) }
var resultDismissed by remember { mutableStateOf(false) }
// Cities & Knights: the knight whose menu is open, the one about to move,
// a progress card waiting for a tap on the board, and the two list dialogs.
var knightMenu by remember { mutableStateOf<Int?>(null) }
var movingKnight by remember { mutableStateOf<Int?>(null) }
var armed by remember { mutableStateOf<ArmedCard?>(null) }
// Seafarers: the ship that has been picked up and is waiting for somewhere to sail.
var movingShip by remember { mutableStateOf<Int?>(null) }
var showImprove by remember { mutableStateOf(false) }
var showProgress by remember { mutableStateOf(false) }
// The landscape HUD: a placement waiting for its ✓, the piece being dragged from the
// build column (and where the finger is, in window pixels), the log popup, and the board.
var staged by remember { mutableStateOf<CatanMove?>(null) }
var dragKind by remember { mutableStateOf<BuildKind?>(null) }
var dragAt by remember { mutableStateOf<Offset?>(null) }
var showLog by remember { mutableStateOf(false) }
var boardCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val config = LocalConfiguration.current
// Landscape gets the heads-up display; portrait keeps the stacked layout.
val wide = config.screenWidthDp > config.screenHeightDp * 1.1f
val staging = wide
val dark = isSystemInDarkTheme()
val style = remember(dark) { catanStyle(dark) }
val seats = remember(localSeat, seatNames) { CatanSeats(localSeat, seatNames) }
// The engine's list changes after every move; drop any selection it no
// longer supports (a build mode the player can no longer afford, a setup
// settlement that is no longer pending).
LaunchedEffect(legal) {
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
if (staged != null && staged !in legal) staged = null
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
setupVertex = null
}
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
if (movingShip != null && legal.none { it is MoveShip && it.from == movingShip }) movingShip = null
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
if (legal.isEmpty()) {
showCards = false
showTrade = false
victimChoice = null
showImprove = false
showProgress = false
knightMenu = null
movingKnight = null
armed = null
staged = null
}
}
val submit: (CatanMove) -> Unit = { move ->
controller.submit(move)
building = null
setupVertex = null
victimChoice = null
showCards = false
showTrade = false
showImprove = false
showProgress = false
knightMenu = null
movingKnight = null
movingShip = null
armed = null
staged = null
dragKind = null
dragAt = null
}
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text(CatanText.END_GAME) }
}
val current = state
if (current == null) {
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
abandoned?.let { CatanAbandonedDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) CatanEndGameDialog(onExit) { confirmingEnd = false }
}
return
}
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat, movingShip)
val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
val devHand = CatanInteraction.devHand(current, localSeat, legal)
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions(
buildKinds = when {
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
current.isSea -> BuildKind.SEAFARERS
else -> BuildKind.BASE
},
ck = current.isCk,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable },
progressCount = progressHand.size,
canEndSmith = CatanInteraction.canEndSmith(legal),
canRoll = CatanInteraction.canRoll(legal),
canBuild = CatanInteraction.canBuild(legal),
building = building,
canBuyCard = CatanInteraction.canBuyDevCard(legal),
canPlayCard = devHand.any { it.playable },
canTrade = playing,
canEndTurn = CatanInteraction.canEndTurn(legal),
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
canRepickSetup = setupVertex != null,
)
val rolledHexes = CatanInteraction.rolledHexes(current)
val prompt = CatanText.prompt(
current,
seats,
setupVertexChosen = setupVertex != null,
building = building,
movingKnight = movingKnight != null,
armed = armed?.kind,
movingShip = movingShip != null,
)
val handle: (TapOutcome) -> Unit = { outcome ->
val wait = if (staging) CatanInteraction.stageable(outcome, building) else null
when {
wait != null -> staged = wait
else -> when (outcome) {
TapOutcome.Ignored -> Unit
is TapOutcome.Submit -> submit(outcome.move)
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
is TapOutcome.PickShip -> movingShip = outcome.edge
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
}
}
}
val publicPoints = CatanRules.publicVictoryPoints(current)
val pointsOf: (Int) -> Int = { seat ->
if (seat == localSeat) CatanRules.victoryPoints(current, seat) else publicPoints[seat]
}
CompositionLocalProvider(LocalCatanStyle provides style) {
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
BoxWithConstraints(modifier = modifier) {
// Phones (either way up) get the one-button build menu.
val compactBuild = !wide || maxWidth < 600.dp
val hoverSpot = if (dragKind != null) dragSpot(boardCoords, dragAt, targets) else null
val stagedSpot = CatanInteraction.spotOf(staged)
val ghostVertex = hoverSpot?.vertex ?: stagedSpot.vertex
val ghostEdge = hoverSpot?.edge ?: stagedSpot.edge
val board: @Composable (Modifier) -> Unit = { boardModifier ->
CatanBoardView(
state = current,
targets = targets,
selectedVertex = setupVertex,
onVertexTap = {
handle(
CatanInteraction.onVertexTap(
it, legal, building, setupVertex, movingKnight, armed,
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
),
)
},
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed, movingShip)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed, current.topo)) },
modifier = boardModifier,
style = style,
rolledHexes = rolledHexes,
selectedKnight = movingKnight ?: knightMenu,
stagedVertex = ghostVertex,
stagedEdge = ghostEdge,
stagedSeat = localSeat,
)
}
val players: @Composable (Boolean) -> Unit = { grid ->
PlayerCards(current, seats, pointsOf, style, twoPerRow = grid)
}
val promptRow: @Composable () -> Unit = {
PromptRow(prompt, notice, current)
}
val handAndActions: @Composable () -> Unit = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
CatanHand(current, localSeat)
CatanActionBar(
actions = actions,
onRoll = { submit(RollDice()) },
onBuild = { building = it },
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
onPlayCard = { showCards = true },
onTrade = { showTrade = true },
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
onEndFreeRoads = {
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
},
onRepickSetup = { setupVertex = null },
compact = compactBuild,
onImprove = { showImprove = true },
onProgress = { showProgress = true },
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
)
}
}
if (wide) {
CatanHud(
state = current,
seats = seats,
style = style,
actions = actions,
prompt = prompt,
notice = notice,
legal = legal,
localSeat = localSeat,
targets = targets,
pointsOf = pointsOf,
staged = staged,
dragKind = dragKind,
dragAt = dragAt,
showLog = showLog,
boardCoords = boardCoords,
building = building,
onBoardCoords = { boardCoords = it },
onToggleLog = { showLog = !showLog },
onArm = { kind -> building = if (building == kind) null else kind; staged = null },
onDragStart = { kind, at ->
building = kind
staged = null
dragKind = kind
dragAt = at
},
onDrag = { dragAt = it },
onDragEnd = {
val spot = dragSpot(boardCoords, dragAt, targets)
dragKind = null
dragAt = null
val outcome = when {
spot?.vertex != null -> CatanInteraction.onVertexTap(
spot.vertex, legal, building, setupVertex,
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
)
spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed, movingShip)
else -> TapOutcome.Ignored
}
handle(outcome)
},
onConfirm = { staged?.let(submit) },
onCancelStaged = { staged = null; building = null },
onRoll = { submit(RollDice()) },
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
onEndFreeRoads = { legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit) },
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
onRepickSetup = { setupVertex = null },
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
onPlayCard = { showCards = true },
onTrade = { showTrade = true },
onImprove = { showImprove = true },
onProgress = { showProgress = true },
board = { m -> board(m) },
)
} else {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
players(false)
if (current.isCk) CatanBarbarians(current, style = style)
promptRow()
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
board(Modifier)
}
handAndActions()
}
}
// The trade board slides up over the bottom of the island (base game; Cities &
// Knights trades goods and still uses its dialogs).
val offerToMe = current.pendingTrade?.takeIf {
current.phase == CatanPhase.TRADE_PENDING && it.asking == localSeat && !current.isCk
}
val boardsAllowed = abandoned == null && !confirmingEnd && current.phase != CatanPhase.GAME_OVER
if (boardsAllowed && offerToMe != null) {
CatanTradeReplyBoard(
state = current,
seat = localSeat,
offer = offerToMe,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { submit(AcceptTrade) },
onDecline = { submit(DeclineTrade) },
onCounter = submit,
modifier = Modifier.align(Alignment.BottomCenter),
style = style,
)
} else if (boardsAllowed && showTrade && !current.isCk) {
CatanTradeBoard(
state = current,
seat = localSeat,
seats = seats,
legal = legal,
onBank = submit,
onOffer = submit,
onClose = { showTrade = false },
modifier = Modifier.align(Alignment.BottomCenter),
style = style,
)
}
}
CatanDialogHost(
state = current,
seats = seats,
localSeat = localSeat,
legal = legal,
abandoned = abandoned,
confirmingEnd = confirmingEnd,
resultDismissed = resultDismissed,
victimChoice = victimChoice,
showCards = showCards,
showTrade = showTrade,
devHand = devHand,
knightMenu = knightMenu,
showImprove = showImprove,
showProgress = showProgress,
progressHand = progressHand,
onMoveKnight = { from ->
knightMenu = null
movingKnight = from
},
onArmCard = { card ->
showProgress = false
armed = card
},
onCancelKnight = { knightMenu = null },
onCancelImprove = { showImprove = false },
onCancelProgress = { showProgress = false },
onExit = onExit,
onSubmit = submit,
onStay = controller::dismissAbandoned,
onKeepPlaying = { confirmingEnd = false },
onDismissResult = { resultDismissed = true },
onCancelVictim = { victimChoice = null },
onCancelCards = { showCards = false },
onCancelTrade = { showTrade = false },
)
}
}
}
// ---------------------------------------------------------------------------
// Layout pieces
// ---------------------------------------------------------------------------
@Composable
private fun PlayerCards(
state: CatanState,
seats: CatanSeats,
pointsOf: (Int) -> Int,
style: CatanStyle,
twoPerRow: Boolean,
) {
val order = (0 until state.playerCount).toList()
val rows = if (twoPerRow) order.chunked(2) else listOf(order)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
rows.forEach { row ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
row.forEach { seat ->
CatanPlayerCard(
state = state,
seat = seat,
seats = seats,
points = pointsOf(seat),
modifier = Modifier.weight(1f),
style = style,
compact = !twoPerRow,
)
}
// Keep an odd last row's card the same width as the others.
if (twoPerRow && row.size == 1) Box(Modifier.weight(1f))
}
}
}
}
/**
* The one line telling the player what is going on, and the dice.
*
* **Fixed height.** The text always occupies exactly two lines (padded when it is
* shorter, clipped when it is longer) and a refused move replaces the prompt
* rather than adding to it, so this row is the same height whatever is said and
* the board below it never moves.
*/
@Composable
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = notice ?: prompt,
style = MaterialTheme.typography.bodyMedium,
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
}
}
/**
* Chooses which dialog, if any, is showing. Pulled out of [CatanScreen] so the
* priority order — an ended table beats a result beats a forced discard beats
* an optional picker — is in one readable place.
*/
@Composable
private fun CatanDialogHost(
state: CatanState,
seats: CatanSeats,
localSeat: Int,
legal: List<CatanMove>,
abandoned: String?,
confirmingEnd: Boolean,
resultDismissed: Boolean,
victimChoice: TapOutcome.PickRobberVictim?,
showCards: Boolean,
showTrade: Boolean,
devHand: List<DevCardInHand>,
knightMenu: Int?,
showImprove: Boolean,
showProgress: Boolean,
progressHand: List<ProgressInHand>,
onMoveKnight: (Int) -> Unit,
onArmCard: (ArmedCard) -> Unit,
onCancelKnight: () -> Unit,
onCancelImprove: () -> Unit,
onCancelProgress: () -> Unit,
onExit: () -> Unit,
onSubmit: (CatanMove) -> Unit,
onStay: () -> Unit,
onKeepPlaying: () -> Unit,
onDismissResult: () -> Unit,
onCancelVictim: () -> Unit,
onCancelCards: () -> Unit,
onCancelTrade: () -> Unit,
) {
val goodsOffer = state.ck?.goodsOffer
when {
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
confirmingEnd -> CatanEndGameDialog(onEnd = onExit, onKeepPlaying = onKeepPlaying)
state.phase == CatanPhase.GAME_OVER ->
if (!resultDismissed) CatanGameOverDialog(state, seats, onExit, onDismissResult)
state.phase == CatanPhase.DISCARDING && state.discardSeat == localSeat ->
CatanDiscardDialog(
held = state.resources[localSeat],
quota = CatanInteraction.discardQuota(state.resourceCount(localSeat)),
legal = legal,
onSubmit = onSubmit,
)
state.phase == CatanPhase.GOLD && CatanRules.currentSeat(state) == localSeat && legal.isNotEmpty() ->
CatanGoldDialog(legal, onSubmit)
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
ChoreDialog(state, localSeat, legal, onSubmit)
state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.asking == localSeat ->
CatanGoodsTradeResponseDialog(
state = state,
seat = localSeat,
offer = goodsOffer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
onCounter = onSubmit,
)
state.phase == CatanPhase.TRADE_COUNTERS && goodsOffer != null && state.turn == localSeat && legal.isNotEmpty() ->
CatanGoodsCounterChoiceDialog(
counters = CatanInteraction.goodsCounterChoices(state, legal),
seats = seats,
onTake = onSubmit,
onNoDeal = { onSubmit(DismissCounters) },
)
state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() ->
CatanCounterChoiceDialog(
counters = CatanInteraction.counterChoices(state, legal),
seats = seats,
onTake = onSubmit,
onNoDeal = { onSubmit(DismissCounters) },
)
victimChoice != null ->
CatanVictimDialog(
victims = victimChoice.victims,
seats = seats,
onPick = { victim ->
onSubmit(
if (victimChoice.pirate) {
org.prolibertate.games.game.catan.MovePirate(victimChoice.hex, victim)
} else {
MoveRobber(victimChoice.hex, victim)
},
)
},
onCancel = onCancelVictim,
)
knightMenu != null ->
CatanKnightDialog(
state = state,
vertex = knightMenu,
actions = CatanInteraction.knightActions(legal, knightMenu),
onDo = onSubmit,
onMove = onMoveKnight,
onCancel = onCancelKnight,
)
showImprove ->
CatanImproveDialog(
state = state,
seat = localSeat,
legal = legal,
onBuy = onSubmit,
onCancel = onCancelImprove,
)
showProgress ->
CatanProgressDialog(
hand = progressHand,
seats = seats,
onPlay = onSubmit,
onArm = onArmCard,
onCancel = onCancelProgress,
)
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
showTrade && state.isCk ->
CatanGoodsTradeDialog(
state = state,
seat = localSeat,
seats = seats,
legal = legal,
onBank = onSubmit,
onOffer = onSubmit,
onCancel = onCancelTrade,
)
}
}
/**
* The dialog for the chore at the front of the queue, when it is the local
* player's. Chores that are answered by tapping the board (a city to lose, a
* metropolis site, a knight to place) have no dialog: the prompt line tells the
* player what to tap.
*/
@Composable
private fun ChoreDialog(
state: CatanState,
localSeat: Int,
legal: List<CatanMove>,
onSubmit: (CatanMove) -> Unit,
) {
val ck = state.ck ?: return
val check: (List<org.prolibertate.games.game.catan.Good>, (List<org.prolibertate.games.game.catan.Good>) -> CatanMove) -> Boolean =
{ cards, make -> CatanRules.isLegalMove(state, localSeat, make(cards)) }
when (val chore = ck.chores.firstOrNull()) {
is DiscardChore -> {
val quota = minOf(chore.count, CatanCk.goodsCount(state, localSeat))
CatanGoodsPickDialog(
title = "Discard $quota cards",
intro = "Choose $quota cards to give up",
held = CatanCk.goodsOf(state, localSeat),
quota = quota,
confirmLabel = "Discard",
selectable = { true },
allowed = { check(it, ::DiscardGoods) },
onConfirm = { onSubmit(DiscardGoods(it)) },
)
}
is GiveChore -> {
val pool = CatanCk.goodsOf(state, localSeat).filterKeys { !chore.commoditiesOnly || it.isCommodity }
val quota = minOf(chore.count, pool.values.sum())
CatanGoodsPickDialog(
title = "Hand over $quota ${if (quota == 1) "card" else "cards"}",
intro = if (chore.commoditiesOnly) "Choose a commodity to give" else "Choose which cards to give",
held = pool,
quota = quota,
confirmLabel = "Give",
selectable = { !chore.commoditiesOnly || it.isCommodity },
allowed = { check(it, ::GiveGoods) },
onConfirm = { onSubmit(GiveGoods(it)) },
)
}
is PickDeckChore -> CatanPickDeckDialog(legal) { onSubmit(it) }
is DropProgressChore -> CatanDropProgressDialog(ck.progressHands[localSeat]) { onSubmit(it) }
is AqueductChore -> CatanAqueductDialog(legal) { onSubmit(it) }
is SpyChore -> CatanSpyDialog(ck.progressHands[chore.target], legal) { onSubmit(it) }
else -> Unit
}
}
/** The board spot under a dragged finger ([at], in window pixels), or null off the board. */
private fun dragSpot(coords: LayoutCoordinates?, at: Offset?, targets: BoardTargets): CatanInteraction.Spot? {
if (coords == null || at == null || !coords.isAttached) return null
val local = coords.windowToLocal(at)
val layout = CatanGeometry.fit(coords.size.width.toFloat(), coords.size.height.toFloat())
return CatanInteraction.snap(layout, targets, Pt(local.x, local.y), layout.radius * 1.2f)
}
@@ -0,0 +1,173 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import org.prolibertate.games.game.catan.Commodity
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.catan.Track
/**
* Every visual decision for the Katan screen, in one place.
*
* **To restyle Katan, edit this file.** Nothing else in `ui/game/catan/` hard-
* codes a colour or a proportion: the board, the hand, the player strip and the
* dialogs all read a [CatanStyle] from [LocalCatanStyle]. A different art
* direction is a different [CatanStyle] value (see [catanStyle]); a new theme
* can be supplied at the screen's root with
* `CompositionLocalProvider(LocalCatanStyle provides myStyle)`.
*
* Wording is the same story in [CatanText]; geometry is in [CatanGeometry].
*
* Proportions are fractions of the *hex radius* (centre of a hex to one of its
* corners), so they scale with the board whatever the screen size.
*/
@Immutable
data class CatanStyle(
// --- Board surfaces -----------------------------------------------------
/** The water around the island. */
val sea: Color,
/** Fill of each hex by terrain. */
val terrain: Map<Terrain, Color>,
/** Outline between hexes. */
val hexEdge: Color,
/** The number-token disc, and the ink for ordinary and hot (6 / 8) numbers. */
val token: Color,
val tokenInk: Color,
val tokenHotInk: Color,
/** The robber's pawn. */
val robber: Color,
/** Outline and wash on hexes that produced for the latest dice roll. */
val rolled: Color,
/** Harbour marker disc and its ink. */
val portMarker: Color,
val portInk: Color,
// --- Resources (hand tiles, trade pickers, harbour markers) -------------
val resource: Map<Resource, Color>,
val resourceInk: Color,
// --- Cities & Knights ---------------------------------------------------
/** Commodity cards: cloth, coin and paper. */
val commodity: Map<Commodity, Color> = LightCommodities,
/** Ink on a commodity tile (paper is pale, so it is not white). */
val commodityInk: Map<Commodity, Color> = LightCommodityInk,
/** The colour of each improvement track, its city gate, its deck and its metropolis. */
val track: Map<Track, Color> = LightTracks,
/** Wall round a city, and the barbarian strip. */
val wall: Color = Color(0xFF5D4037),
val barbarian: Color = Color(0xFFB71C1C),
/** The halo round a knight that is awake. White, so it is not mistaken for the yellow tap ring. */
val knightReady: Color = Color(0xFFFFFFFF),
// --- Players ------------------------------------------------------------
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
val seats: List<Color>,
/** Outline drawn round every piece so any seat colour reads on any terrain. */
val pieceOutline: Color,
// --- Interaction feedback ----------------------------------------------
/** Marks where the player may build or move right now. */
val target: Color,
/** The setup settlement that has been chosen and is waiting for its road. */
val targetSelected: Color,
// --- Metrics (fractions of the hex radius) ------------------------------
/** How far each tile polygon is pulled in from its corners, leaving a gap. */
val hexInset: Float = 0.04f,
val roadWidth: Float = 0.20f,
val settlementSize: Float = 0.30f,
val citySize: Float = 0.42f,
val tokenRadius: Float = 0.36f,
val robberSize: Float = 0.34f,
/** Seafarers: the pirate's flag, and the hull and sail of a ship. */
val pirate: Color = Color(0xFF000000),
val shipSail: Color = Color(0xFFFFFFFF),
val portRadius: Float = 0.46f,
val targetRadius: Float = 0.20f,
/** How close a tap must land to a vertex / edge / hex centre to count. */
val tapReach: Float = 0.55f,
val tokenText: Float = 0.34f,
val portText: Float = 0.27f,
val knightSize: Float = 0.30f,
)
private val LightCommodities = mapOf(
Commodity.CLOTH to Color(0xFF7E57C2),
Commodity.COIN to Color(0xFFF9A825),
Commodity.PAPER to Color(0xFFECEFF1),
)
private val LightCommodityInk = mapOf(
Commodity.CLOTH to Color(0xFFFFFFFF),
Commodity.COIN to Color(0xFF3E2723),
Commodity.PAPER to Color(0xFF263238),
)
private val LightTracks = mapOf(
Track.TRADE to Color(0xFFF2C300),
Track.POLITICS to Color(0xFF1E6FD9),
Track.SCIENCE to Color(0xFF2E9E4F),
)
/** The light palette, which is also the reference for the dark one. */
private val LightStyle = CatanStyle(
sea = Color(0xFF3A7CA5),
terrain = mapOf(
Terrain.FOREST to Color(0xFF2F6B3A),
Terrain.PASTURE to Color(0xFF9CCC65),
Terrain.FIELDS to Color(0xFFE8C84A),
Terrain.HILLS to Color(0xFFB4532F),
Terrain.MOUNTAINS to Color(0xFF7C8794),
Terrain.DESERT to Color(0xFFE6D3A0),
// Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow.
Terrain.SEA to Color(0xFF3A7CA5),
Terrain.GOLD to Color(0xFFF2B01E),
),
hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9),
tokenInk = Color(0xFF2B2B2B),
tokenHotInk = Color(0xFFC62828),
robber = Color(0xFF263238),
rolled = Color(0xFFFFFFFF),
portMarker = Color(0xFFFFF8E1),
portInk = Color(0xFF2B2B2B),
resource = mapOf(
Resource.LUMBER to Color(0xFF2F6B3A),
Resource.BRICK to Color(0xFFB4532F),
Resource.WOOL to Color(0xFF8BC34A),
Resource.GRAIN to Color(0xFFE0B020),
Resource.ORE to Color(0xFF6B7684),
),
resourceInk = Color(0xFFFFFFFF),
seats = listOf(
Color(0xFF1565C0), // blue
Color(0xFFEF6C00), // orange
Color(0xFFF5F5F5), // white
Color(0xFF8E24AA), // purple
),
pieceOutline = Color(0xFF1B1B1B),
target = Color(0xFFFFEB3B),
targetSelected = Color(0xFF00E5FF),
)
/** Deeper sea and slightly dimmer land so the board is not a glare at night. */
private val DarkStyle = LightStyle.copy(
sea = Color(0xFF14354D),
terrain = LightStyle.terrain.mapValues { (_, c) -> c.dim(0.82f) },
token = Color(0xFFE9DFC4),
)
/** The style for the current light / dark setting. */
fun catanStyle(dark: Boolean): CatanStyle = if (dark) DarkStyle else LightStyle
/**
* What the Katan composables read their look from. Defaults to the light style
* so a preview or a test with no provider still draws something sensible;
* [CatanScreen] provides the right one for the current theme.
*/
val LocalCatanStyle = staticCompositionLocalOf { LightStyle }
private fun Color.dim(factor: Float): Color =
Color(red * factor, green * factor, blue * factor, alpha)
@@ -0,0 +1,521 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AqueductChore
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.DiscardChore
import org.prolibertate.games.game.catan.DropProgressChore
import org.prolibertate.games.game.catan.EventFace
import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.MetropolisChore
import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.PillageChore
import org.prolibertate.games.game.catan.PlaceKnightChore
import org.prolibertate.games.game.catan.Port
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.catan.RelocateChore
import org.prolibertate.games.game.catan.RemoveKnightChore
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.SmithChore
import org.prolibertate.games.game.catan.SpyChore
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.catan.Track
/**
* Who is who at the table, for wording. [names] may be empty or short — a
* client device does not always know everyone's name — and the rest fall back
* to "Seat N".
*/
class CatanSeats(val me: Int, private val names: List<String> = emptyList()) {
fun label(seat: Int): String = when {
seat == me -> "You"
else -> names.getOrNull(seat)?.takeIf { it.isNotBlank() } ?: "Seat ${seat + 1}"
}
/** For mid-sentence use: "you" rather than "You". */
fun lower(seat: Int): String = if (seat == me) "you" else label(seat)
}
/**
* Every word the Katan screen shows, in one place.
*
* **To change copy, or to localise, edit this file.** The composables never
* build a sentence themselves; they ask for it here. Keeping the sentences as
* plain functions of state also means they are unit-tested (`CatanTextTest`)
* without a device.
*
* "Katan" is this app's name for the game (see `docs/RULES-catan.md`); the
* resources are lumber, brick, wool, grain and ore.
*/
object CatanText {
const val TITLE = "Katan"
// --- Names ---------------------------------------------------------------
fun resource(resource: Resource): String = when (resource) {
Resource.LUMBER -> "Lumber"
Resource.BRICK -> "Brick"
Resource.WOOL -> "Wool"
Resource.GRAIN -> "Grain"
Resource.ORE -> "Ore"
}
fun terrain(terrain: Terrain): String = when (terrain) {
Terrain.FOREST -> "Forest"
Terrain.PASTURE -> "Pasture"
Terrain.FIELDS -> "Fields"
Terrain.HILLS -> "Hills"
Terrain.MOUNTAINS -> "Mountains"
Terrain.DESERT -> "Desert"
Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field"
}
fun devCard(type: DevCardType): String = when (type) {
DevCardType.KNIGHT -> "Knight"
DevCardType.ROAD_BUILDING -> "Road building"
DevCardType.MONOPOLY -> "Monopoly"
DevCardType.YEAR_OF_PLENTY -> "Year of plenty"
DevCardType.VICTORY_POINT -> "Victory point"
}
fun devCardHelp(type: DevCardType): String = when (type) {
DevCardType.KNIGHT -> "Move the robber and steal a card. Three played knights take Largest Army."
DevCardType.ROAD_BUILDING -> "Build two roads for free."
DevCardType.MONOPOLY -> "Name a resource and take every card of it from the other players."
DevCardType.YEAR_OF_PLENTY -> "Take any two resource cards from the bank."
DevCardType.VICTORY_POINT -> "One victory point. Kept hidden until you win."
}
/** "3:1" for a generic harbour, "2:1" for a resource one. */
fun portRatio(port: Port): String = "${port.ratio}:1"
fun portName(port: Port): String =
port.resource?.let { "${portRatio(port)} ${resource(it).lowercase()}" } ?: "3:1 any"
// --- Costs ---------------------------------------------------------------
fun cost(cost: Map<Resource, Int>): String =
cost.entries.joinToString(" + ") { (r, n) -> "$n ${resource(r).lowercase()}" }
val ROAD_COST: String get() = cost(CatanRules.ROAD_COST)
val SETTLEMENT_COST: String get() = cost(CatanRules.SETTLEMENT_COST)
val CITY_COST: String get() = cost(CatanRules.CITY_COST)
val DEV_COST: String get() = cost(CatanRules.DEV_COST)
// --- Buttons and headings --------------------------------------------------
const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)."
const val SHIP = "Ship"
const val SHIP_COST_TEXT = "1 lumber + 1 wool"
const val GOLD_TITLE = "Gold field"
const val GOLD_TAKE = "Take"
const val ROLL = "Roll dice"
const val END_TURN = "End turn"
const val ROAD = "Road"
const val SETTLEMENT = "Settlement"
const val CITY = "City"
const val BUY_CARD = "Buy card"
const val PLAY_CARD = "Play card"
const val TRADE = "Trade"
const val CANCEL = "Cancel"
const val CONFIRM = "Confirm"
const val STOP_ROADS = "Done with roads"
const val BUILD = "Build"
const val COUNTER = "Counter…"
const val SEND_COUNTER = "Send counter"
const val EVERYONE = "Everyone"
const val NO_DEAL = "No deal"
/**
* The empty second line of a button that has no cost, so every button in the
* action bar is the same height. A non-breaking space: blank, but a line.
*/
const val NO_COST = "\u00A0"
const val YOUR_HAND = "Your hand"
const val CARDS = "cards"
const val ACCEPT = "Accept"
const val DECLINE = "Decline"
const val TRADE_WITH_PLAYER = "Offer to a player"
const val TRADE_WITH_BANK = "Trade with the bank"
const val YOU_GIVE = "You give"
const val YOU_GET = "You get"
const val SEND_OFFER = "Send offer"
// --- Trade board and HUD -----------------------------------------------------
const val TAB_PLAYER = "Player"
const val TAB_BANK = "Bank"
const val YOU_RECEIVE_ROW = "You get"
const val YOU_GIVE_ROW = "You give"
const val TRADE_HINT_PLAYER = "Tap cards to build an offer. Tap a player to address it to them alone."
const val TRADE_HINT_BANK = "Tap what you give, then what you want."
const val TRADE_HINT_BANK_EMPTY = "You do not hold enough of any one resource to trade with the bank."
const val TRADE_HINT_COUNTER = "Change the terms, then send them back."
const val HIDE = "Hide"
const val DENY = "Deny"
const val CONFIRM_BUILD = "Place"
const val CANCEL_BUILD = "Cancel"
const val DRAG_HINT = "Drag a piece onto the board"
const val LOG_BUTTON = "Log"
const val CARDS_SHORT = "Cards"
const val BUY_SHORT = "Buy"
const val ROLL_SHORT = "Roll"
const val END_SHORT = "End"
const val DONE_SHORT = "Done"
const val LOG_TITLE = "Table log"
const val NO_LOG = "Nothing has happened yet."
const val END_GAME = "End game"
const val LOOK_AT_BOARD = "Look at the board"
const val LEAVE_GAME = "Leave the game"
// --- The prompt line ------------------------------------------------------
/**
* The one line that tells the player what is going on and what to do.
*
* [setupVertexChosen] is the setup settlement already picked and waiting
* for its road; [building] is the build mode the player has entered, if any.
*/
fun prompt(
state: CatanState,
seats: CatanSeats,
setupVertexChosen: Boolean,
building: BuildKind?,
/** Cities & Knights: a knight has been picked to move, and its destination is awaited. */
movingKnight: Boolean = false,
/** Cities & Knights: a progress card is waiting for a tap on the board. */
armed: ProgressType? = null,
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */
movingShip: Boolean = false,
): String {
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
val me = seats.me
val onClock = CatanRules.currentSeat(state)
val mine = onClock == me
val turn = seats.label(state.turn)
return when (state.phase) {
CatanPhase.GAME_OVER -> {
val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
}
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
setupVertexChosen -> "Now tap the road to place beside that settlement."
state.phase == CatanPhase.SETUP_SECOND ->
"Place your second settlement. It earns resources from the hexes around it."
else -> "Place a settlement, then a road."
}
CatanPhase.AWAIT_ROLL ->
if (mine) "Your turn. Roll the dice." else "$turn is rolling…"
CatanPhase.DISCARDING -> {
val discarder = state.discardSeat
when {
discarder == me -> "A seven! You hold ${state.resourceCount(me)} cards; " +
"discard ${state.resourceCount(me) / 2}."
discarder != null -> "A seven! ${seats.label(discarder)} must discard half."
else -> "A seven! Discarding…"
}
}
CatanPhase.ROBBER ->
if (mine) {
if (state.isSea) SEA_ROBBER else "Move the robber. Tap a hex."
} else {
"$turn is moving the robber…"
}
CatanPhase.GOLD ->
if (mine) {
"Gold! Choose ${state.sea?.gold?.firstOrNull()?.count ?: 1} resource cards."
} else {
"${seats.label(onClock ?: state.turn)} is choosing from the gold…"
}
CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…"
movingShip -> "Tap the sea edge to sail the ship to."
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
else -> "Your turn. Build, trade, or end the turn."
}
CatanPhase.FREE_ROADS ->
if (mine) {
"Road building: place ${state.pendingFreeRoads} more " +
if (state.pendingFreeRoads == 1) "road." else "roads."
} else {
"$turn is placing free roads…"
}
CatanPhase.CHORE -> "Waiting for ${seats.label(onClock ?: state.turn)}…"
CatanPhase.TRADE_PENDING -> {
val offer = state.pendingTrade
when {
offer == null -> "Waiting for a reply…"
offer.asking == me -> if (offer.to == null) {
"${seats.label(offer.by)} offers the table a trade. Accept, decline or counter."
} else {
"${seats.label(offer.by)} offers you a trade. Accept, decline or counter."
}
offer.by == me -> "Waiting for ${seats.label(offer.asking)} to answer your offer…"
else -> "${seats.label(offer.by)}'s offer is with ${seats.label(offer.asking)}."
}
}
CatanPhase.TRADE_COUNTERS ->
if (mine) {
"Nobody accepted, but there are counter-offers. Take one or turn them down."
} else {
"$turn is weighing counter-offers…"
}
}
}
/** "3 Lumber + 1 Brick" for a flat list of cards. */
fun cards(cards: List<Resource>): String =
cards.groupingBy { it }.eachCount().entries
.sortedBy { it.key.ordinal }
.joinToString(" + ") { (r, n) -> "$n ${resource(r)}" }
.ifEmpty { "nothing" }
fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
"$by offers ${cards(give)} to $to for ${cards(get)}"
/** One counter-offer as the offerer sees it: what that seat gives and wants. */
fun counterSummary(by: String, give: List<Resource>, get: List<Resource>): String =
"$by gives ${cards(give)} for your ${cards(get)}"
fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
val winner = state.winner ?: return "Game over"
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
}
// --- Cities & Knights ----------------------------------------------------
fun good(good: Good): String = when (good) {
Good.LUMBER -> "Lumber"
Good.BRICK -> "Brick"
Good.WOOL -> "Wool"
Good.GRAIN -> "Grain"
Good.ORE -> "Ore"
Good.CLOTH -> "Cloth"
Good.COIN -> "Coin"
Good.PAPER -> "Paper"
}
/** "2 Lumber + 1 Cloth" for a flat list of goods. */
fun goods(cards: List<Good>): String =
cards.groupingBy { it }.eachCount().entries
.sortedBy { it.key.ordinal }
.joinToString(" + ") { (g, n) -> "$n ${good(g)}" }
.ifEmpty { "nothing" }
fun track(track: Track): String = when (track) {
Track.TRADE -> "Trade"
Track.POLITICS -> "Politics"
Track.SCIENCE -> "Science"
}
/** What the third level of a track gives, for the improvement dialog. */
fun trackPower(track: Track): String = when (track) {
Track.TRADE -> "From level 3, trade commodities with the bank two for one."
Track.POLITICS -> "From level 3, knights can be promoted to mighty."
Track.SCIENCE -> "From level 3, when the dice pay you nothing, take any resource."
}
fun knightName(level: Int): String = when (level) {
1 -> "Basic knight"
2 -> "Strong knight"
else -> "Mighty knight"
}
fun event(face: EventFace?): String = when (face) {
null -> "–"
EventFace.SHIP -> "Ship"
EventFace.TRADE -> "Trade"
EventFace.POLITICS -> "Politics"
EventFace.SCIENCE -> "Science"
}
fun progress(kind: ProgressType): String = CatanCk.progressName(kind)
fun progressHelp(kind: ProgressType): String = when (kind) {
ProgressType.ALCHEMIST -> "Before rolling, choose what the two number dice show."
ProgressType.CRANE -> "Your next city improvement costs one commodity less."
ProgressType.ENGINEER -> "Raise a wall round one of your cities for free."
ProgressType.INVENTOR -> "Swap two number tokens (not 2, 6, 8 or 12)."
ProgressType.IRRIGATION -> "Take 2 grain for each fields hex beside your settlements and cities."
ProgressType.MEDICINE -> "Upgrade a settlement to a city for 2 ore and 1 grain."
ProgressType.MINING -> "Take 2 ore for each mountains hex beside your settlements and cities."
ProgressType.PRINTER -> "One victory point."
ProgressType.ROAD_BUILDING -> "Build two roads for free."
ProgressType.SMITH -> "Promote up to two of your knights one level for free."
ProgressType.BISHOP -> "Move the robber and take a card from every player beside its new hex."
ProgressType.CONSTITUTION -> "One victory point."
ProgressType.DESERTER -> "Another player removes a knight; you place a free one of the same strength."
ProgressType.DIPLOMAT -> "Remove an open road. If it was yours, build it again anywhere for free."
ProgressType.INTRIGUE -> "Drive an opponent's knight off a crossing beside one of your roads."
ProgressType.SABOTEUR -> "Every player with as many points as you or more discards half their cards."
ProgressType.SPY -> "Look at another player's progress cards and take one."
ProgressType.WARLORD -> "Wake all your knights without paying grain."
ProgressType.WEDDING -> "Every player with more points gives you 2 cards."
ProgressType.COMMERCIAL_HARBOR -> "Offer a resource to each player; each swaps it for a commodity."
ProgressType.MASTER_MERCHANT -> "Take 2 cards from a player with more points."
ProgressType.MERCHANT -> "Put the merchant beside your settlement: trade that resource 2:1 and score a point."
ProgressType.MERCHANT_FLEET -> "This turn, trade one chosen card type with the bank two for one."
ProgressType.RESOURCE_MONOPOLY -> "Name a resource. Every other player gives you 2 of it."
ProgressType.TRADE_MONOPOLY -> "Name a commodity. Every other player gives you 1 of it."
}
const val IMPROVE = "Improve"
const val KNIGHTS = "Knights"
const val CARDS_BUTTON = "Progress"
const val WALL = "Wall"
const val KNIGHT = "Knight"
const val STOP_SMITH = "Done promoting"
val KNIGHT_COST: String get() = cost(CatanCk.KNIGHT_COST)
val ACTIVATE_COST: String get() = cost(CatanCk.ACTIVATE_COST)
val WALL_COST: String get() = cost(CatanCk.WALL_COST)
private fun ckPrompt(
state: CatanState,
seats: CatanSeats,
setupVertexChosen: Boolean,
building: BuildKind?,
movingKnight: Boolean,
armed: ProgressType?,
): String {
val me = seats.me
val onClock = CatanRules.currentSeat(state)
val mine = onClock == me
val turn = seats.label(state.turn)
val ck = state.ck!!
return when (state.phase) {
CatanPhase.GOLD -> ""
CatanPhase.GAME_OVER -> {
val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
}
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
setupVertexChosen -> "Now tap the road to place beside that settlement."
state.phase == CatanPhase.SETUP_SECOND ->
"Place your second piece: it is a city, and earns resources from the hexes around it."
else -> "Place a settlement, then a road."
}
CatanPhase.AWAIT_ROLL -> when {
!mine -> "$turn is rolling…"
armed != null -> armedPrompt(armed)
movingKnight -> "Tap where the knight should go."
else -> "Your turn. Roll the dice, or wake knights and play progress cards first."
}
CatanPhase.ROBBER -> when {
!mine -> "$turn is moving the robber…"
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
else -> "Move the robber. Tap a hex."
}
CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…"
armed != null -> armedPrompt(armed)
movingKnight -> "Tap where the knight should go."
building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
building == BuildKind.WALL -> "Tap one of your cities to raise a wall."
building == BuildKind.KNIGHT -> "Tap an empty crossing at the end of your road to hire a knight."
else -> "Your turn. Build, improve, trade, or end the turn. Tap a knight to use it."
}
CatanPhase.FREE_ROADS ->
if (mine) {
"Place ${state.pendingFreeRoads} free " +
if (state.pendingFreeRoads == 1) "road." else "roads."
} else {
"$turn is placing free roads…"
}
CatanPhase.TRADE_PENDING -> {
val offer = ck.goodsOffer
val asking = offer?.asking?.takeIf { it >= 0 }
when {
offer == null || asking == null -> "Waiting for a reply…"
asking == me -> "${seats.label(offer.by)} offers you a trade."
offer.by == me -> "Waiting for ${seats.label(asking)} to answer your offer…"
else -> "${seats.label(offer.by)} has offered ${seats.label(asking)} a trade."
}
}
CatanPhase.TRADE_COUNTERS ->
if (mine) {
"Nobody accepted, but there are counter-offers. Take one or turn them down."
} else {
"$turn is weighing counter-offers…"
}
CatanPhase.DISCARDING -> "Discarding…"
CatanPhase.CHORE -> {
val chore = ck.chores.firstOrNull()
val who = seats.label(chore?.seat ?: state.turn)
if (chore == null || chore.seat != me) {
when (chore) {
is PillageChore -> "The barbarians have landed. $who must give up a city."
is DiscardChore -> "$who must discard ${chore.count} cards."
else -> "Waiting for $who…"
}
} else {
when (chore) {
is DiscardChore -> "Discard ${chore.count} cards."
is GiveChore -> "Give ${chore.count} card${if (chore.count == 1) "" else "s"} to ${seats.lower(chore.to)}."
is PillageChore -> "The barbarians won. Tap a city of yours to give up."
is PickDeckChore -> "Choose a progress deck to draw from."
is DropProgressChore -> "You hold too many progress cards. Give one back."
is MetropolisChore -> "Tap the city that will bear the ${track(chore.track).lowercase()} metropolis."
is AqueductChore -> "Your aqueduct pays. Take any resource."
is RelocateChore -> "Your knight was driven off. Tap where it goes."
is RemoveKnightChore -> "Tap one of your knights to remove."
is PlaceKnightChore -> "Tap where your free ${knightName(chore.level).lowercase()} stands."
is SpyChore -> "Choose a progress card to take."
is SmithChore -> "Tap a knight to promote (${chore.left} left), or stop."
}
}
}
}
}
private fun armedPrompt(kind: ProgressType): String = when (kind) {
ProgressType.ENGINEER -> "Engineer: tap a city to wall."
ProgressType.MEDICINE -> "Medicine: tap a settlement to upgrade."
ProgressType.MERCHANT -> "Merchant: tap a hex beside one of your buildings."
ProgressType.DIPLOMAT -> "Diplomat: tap an open road to remove."
ProgressType.INTRIGUE -> "Intrigue: tap an enemy knight beside your road."
ProgressType.INVENTOR -> "Inventor: tap two hexes to swap their tokens."
else -> "Tap the board."
}
/** "6 steps away", "1 step away" for the barbarian ship. */
fun barbarianSteps(steps: Int): String = "$steps ${if (steps == 1) "step" else "steps"} away"
}
@@ -0,0 +1,415 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.TradeOffer
/*
* The trade board: the 2D version of Catan Universe's trade panel (see
* docs/RE-catan-universe.md). One panel slides up over the bottom of the board:
*
* header [Player | Bank] hint rival buttons
* top row the cards you GET, tinted with the other side's colour
* arrows
* bottom row the cards you GIVE, tinted with your colour
* right ▾ hide ✕ deny ✓ accept
*
* Like every Katan panel it decides nothing: what may be offered is asked of
* [CatanInteraction], and what a bank trade costs comes from the engine's legal list.
*/
private enum class TradeMode { PLAYER, BANK }
private fun Map<Resource, Int>.cards(): List<Resource> =
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
/** The panel for starting a trade: with the bank, or an offer to one rival or everyone. */
@Composable
fun CatanTradeBoard(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (MaritimeTrade) -> Unit,
onOffer: (OfferTrade) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
) {
var mode by remember { mutableStateOf(TradeMode.BANK) }
var to by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var bankGive by remember { mutableStateOf<Resource?>(null) }
var bankGet by remember { mutableStateOf<Resource?>(null) }
val bank = CatanInteraction.bankTrades(legal)
val giveCards = give.cards()
val getCards = get.cards()
val canSend = CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
val bankMove = bankGive?.let { g -> bankGet?.takeIf { it in bank[g].orEmpty() }?.let { MaritimeTrade(g, it) } }
val rivals = (0 until state.playerCount).filter { it != seat }
val headerRight: @Composable () -> Unit = {
if (mode == TradeMode.PLAYER) {
RivalButtons(rivals, to, style, seats) { to = if (to == it) null else it }
}
}
TradeBoardFrame(
modifier = modifier,
topTint = if (mode == TradeMode.PLAYER && to != null) style.seats[to!!] else Color.Gray,
bottomTint = style.seats[seat],
header = {
TradeTab(CatanText.TAB_BANK, mode == TradeMode.BANK) { mode = TradeMode.BANK }
TradeTab(CatanText.TAB_PLAYER, mode == TradeMode.PLAYER) { mode = TradeMode.PLAYER }
},
headline = when {
mode == TradeMode.PLAYER -> CatanText.TRADE_HINT_PLAYER
bank.isEmpty() -> CatanText.TRADE_HINT_BANK_EMPTY
else -> CatanText.TRADE_HINT_BANK
},
headerRight = headerRight,
topLabel = CatanText.YOU_RECEIVE_ROW,
bottomLabel = CatanText.YOU_GIVE_ROW,
topRow = {
if (mode == TradeMode.PLAYER) {
TradeCardRow(
counts = get,
limit = { 9 },
onChange = { get = it },
)
} else {
val choices = bankGive?.let { bank[it] }.orEmpty().toSet()
TradeCardRow(
counts = bankGet?.let { mapOf(it to 1) } ?: emptyMap(),
limit = { if (it in choices) 1 else 0 },
enabledOf = { it in choices },
onChange = {},
pick = { bankGet = if (bankGet == it) null else it },
)
}
},
bottomRow = {
if (mode == TradeMode.PLAYER) {
TradeCardRow(
counts = give,
limit = { state.resources[seat][it] ?: 0 },
onChange = { give = it },
)
} else {
TradeCardRow(
counts = bank.keys.associateWith { CatanInteraction.bankRatio(state, seat, it) },
limit = { 0 },
enabledOf = { it in bank },
selected = bankGive,
caption = { r -> if (r in bank) "${CatanInteraction.bankRatio(state, seat, r)} : 1" else " " },
onChange = {},
pick = { r ->
bankGive = if (bankGive == r) null else r
bankGet = null
},
)
}
},
acceptEnabled = if (mode == TradeMode.PLAYER) canSend else bankMove != null,
onAccept = {
if (mode == TradeMode.PLAYER) {
onOffer(OfferTrade(to, giveCards, getCards))
} else {
bankMove?.let(onBank)
}
},
denyEnabled = true,
onDeny = {
give = emptyMap()
get = emptyMap()
bankGive = null
bankGet = null
to = null
},
onHide = onClose,
)
}
/**
* The panel for an offer made to the local player: the same layout, read-only. ✓ accepts,
* ✕ declines, and *Counter…* turns the rows editable, pre-filled with the mirror of the offer.
*/
@Composable
fun CatanTradeReplyBoard(
state: CatanState,
seat: Int,
offer: TradeOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
onCounter: (CounterTrade) -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
) {
var countering by remember(offer) { mutableStateOf(false) }
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
val counter = CounterTrade(give.cards(), get.cards())
TradeBoardFrame(
modifier = modifier,
topTint = style.seats[offer.by],
bottomTint = style.seats[seat],
header = {
TradeTab(CatanText.COUNTER, countering) { countering = !countering }
},
headline = if (countering) {
CatanText.TRADE_HINT_COUNTER
} else {
CatanText.tradeSummary(
by = seats.label(offer.by),
to = if (offer.to == null) "the table" else "you",
give = offer.give,
get = offer.get,
)
},
headerRight = {},
topLabel = CatanText.YOU_RECEIVE_ROW,
bottomLabel = CatanText.YOU_GIVE_ROW,
topRow = {
if (countering) {
TradeCardRow(get, limit = { state.resources[offer.by][it] ?: 0 }, onChange = { get = it })
} else {
TradeCardRow(offer.give.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {})
}
},
bottomRow = {
if (countering) {
TradeCardRow(give, limit = { state.resources[seat][it] ?: 0 }, onChange = { give = it })
} else {
TradeCardRow(offer.get.groupingBy { it }.eachCount(), limit = { 0 }, onChange = {})
}
},
acceptEnabled = if (countering) {
CatanInteraction.counterAllowed(state, seat, counter.give, counter.get)
} else {
canAccept
},
onAccept = { if (countering) onCounter(counter) else onAccept() },
denyEnabled = true,
onDeny = onDecline,
onHide = null,
)
}
// ---------------------------------------------------------------------------
// Pieces
// ---------------------------------------------------------------------------
@Composable
private fun TradeBoardFrame(
topTint: Color,
bottomTint: Color,
header: @Composable () -> Unit,
headline: String,
headerRight: @Composable () -> Unit,
topLabel: String,
bottomLabel: String,
topRow: @Composable () -> Unit,
bottomRow: @Composable () -> Unit,
acceptEnabled: Boolean,
onAccept: () -> Unit,
denyEnabled: Boolean,
onDeny: () -> Unit,
onHide: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
tonalElevation = 6.dp,
shadowElevation = 12.dp,
) {
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
header()
Text(
headline,
style = MaterialTheme.typography.bodySmall,
// Two lines, always: the panel keeps its height whatever is said.
minLines = 2,
maxLines = 2,
modifier = Modifier.weight(1f),
)
headerRight()
}
HorizontalDivider()
TintedRow(topTint, topLabel, topRow)
Text("⇣ ⇣ ⇣", modifier = Modifier.align(Alignment.CenterHorizontally), fontSize = 14.sp)
TintedRow(bottomTint, bottomLabel, bottomRow)
}
Column(
modifier = Modifier.padding(start = 10.dp).width(72.dp),
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Always three slots, so the column does not shift when there is nothing to hide.
if (onHide != null) {
HudRoundButton("▾", caption = CatanText.HIDE, diameter = 48.dp, onClick = onHide)
} else {
Box(Modifier.size(48.dp))
}
HudRoundButton("✕", caption = CatanText.DENY, enabled = denyEnabled, tint = Color(0xFFC62828), diameter = 52.dp, onClick = onDeny)
HudRoundButton("✓", caption = CatanText.ACCEPT, enabled = acceptEnabled, tint = Color(0xFF2E7D32), diameter = 60.dp, onClick = onAccept)
}
}
}
}
@Composable
private fun TintedRow(tint: Color, label: String, content: @Composable () -> Unit) {
val shape = RoundedCornerShape(12.dp)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.background(tint.copy(alpha = 0.18f))
.border(1.dp, tint.copy(alpha = 0.5f), shape)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(label, fontSize = 10.sp, fontWeight = FontWeight.Bold)
Box(Modifier.horizontalScroll(rememberScrollState())) { content() }
}
}
/**
* Five resource cards in a row. Tapping a card adds one (up to [limit]); the "−" under it
* takes one back. A bank row passes [pick] instead, and a tap selects the card.
*/
@Composable
private fun TradeCardRow(
counts: Map<Resource, Int>,
limit: (Resource) -> Int,
onChange: (Map<Resource, Int>) -> Unit,
enabledOf: (Resource) -> Boolean = { true },
selected: Resource? = null,
caption: ((Resource) -> String)? = null,
pick: ((Resource) -> Unit)? = null,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Resource.entries.forEach { resource ->
val n = counts[resource] ?: 0
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ResourceTile(
resource = resource,
count = n,
selected = selected == resource,
enabled = enabledOf(resource) && (pick != null || n > 0 || limit(resource) > 0),
onClick = {
if (pick != null) {
pick(resource)
} else if (n < limit(resource)) {
onChange(counts + (resource to n + 1))
}
},
)
if (caption != null) {
Text(caption(resource), fontSize = 11.sp, fontWeight = FontWeight.Bold)
} else {
// Always laid out so the row never changes height.
Text(
"−",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.alpha(if (n > 0 && pick == null && limit(resource) >= 0) 1f else 0f)
.clickable(enabled = n > 0 && pick == null) {
onChange(counts + (resource to n - 1))
}
.padding(horizontal = 14.dp),
)
}
}
}
}
}
@Composable
private fun TradeTab(label: String, selected: Boolean, onClick: () -> Unit) {
val shape = RoundedCornerShape(50)
Text(
label,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clip(shape)
.background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
/** One round button per rival: tap to address the offer to them alone, tap again for everyone. */
@Composable
private fun RivalButtons(
rivals: List<Int>,
chosen: Int?,
style: CatanStyle,
seats: CatanSeats,
onTap: (Int) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
rivals.forEach { rival ->
val picked = chosen == rival
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.background(style.seats[rival])
.border(if (picked) 4.dp else 1.dp, if (picked) Color.White else Color(0x55000000), CircleShape)
.clickable { onTap(rival) },
contentAlignment = Alignment.Center,
) {
Text(seats.label(rival).take(1).uppercase(), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
}
}
}
@@ -1,19 +1,17 @@
package org.prolibertate.games.update
import java.net.URI
/**
* Which line of builds an APK belongs to.
*
* This is a property of the build, never a preference. The two channels are
* published by different workflows and have entirely separate version
* sequences — a dev build's versionCode is its CI build number, a production
* build's is its release build number, and the two are unrelated. There is no
* sequences — a dev build's versionCode is its CI run number, a production
* build's is its release run number, and the two are unrelated. There is no
* meaning to be had from comparing across them, so nothing does.
*
* They also carry different applicationIds — a dev build installs as
* `…pilgrimsofkatan.dev` — so both can sit on one device, each updating
* itself down its own line and leaving the other alone.
* `…games.dev` — so both can sit on one device, each updating itself down its
* own line and leaving the other alone.
*/
enum class UpdateChannel(val label: String, val blurb: String) {
PRODUCTION("Production", "Stable builds released from main"),
@@ -23,7 +21,7 @@ enum class UpdateChannel(val label: String, val blurb: String) {
/** Marks a tag or a version name as belonging to the dev channel. */
const val DEV_SUFFIX = "-dev"
/** One published build, as read off a repository release. */
/** One published build, as read off a channel's `latest.json`. */
data class Release(
val tag: String,
val versionCode: Int,
@@ -51,14 +49,14 @@ fun channelOfVersionName(versionName: String): UpdateChannel =
* The channel a release belongs to.
*
* Two independent marks, and either is enough: the `-dev` suffix the workflow
* puts on the tag, and the release's own prerelease flag. A release would have
* to lose both to be mistaken for the other channel.
* puts on the tag, and GitHub's own prerelease flag. A release would have to
* lose both to be mistaken for the other channel.
*/
fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
if (tag.endsWith(DEV_SUFFIX) || prerelease) UpdateChannel.DEV else UpdateChannel.PRODUCTION
/**
* The build number out of a `v<series>.<n>` tag, which is what an APK's
* The run number out of a `v<series>.<n>` tag, which is what an APK's
* versionCode is.
*
* Only the trailing number is read. The series is a name for people, and
@@ -67,37 +65,15 @@ fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
fun versionCodeOfTag(tag: String): Int? =
tag.substringAfterLast('.').removeSuffix(DEV_SUFFIX).toIntOrNull()
/**
* Rewrites a download URL onto [host], keeping its scheme, path and query.
*
* A release server can answer for more than one host name, and it hands out
* asset URLs on whichever one it treats as canonical — which need not be the
* one the app was told to talk to. Fetching the release list from one host and
* the APK from another is a single DNS change away from a channel that half
* works, so the app stays on the host it decided to trust and takes only the
* path from what the server reported.
*
* Anything that cannot be parsed is returned unchanged: a broken URL fails
* loudly at download time, while a swallowed one would fail as a silent
* "something went wrong".
*/
fun onReportedHost(url: String, host: String): String = try {
val reported = URI(url)
val scheme = reported.scheme ?: "https"
URI(scheme, null, host, -1, reported.path, reported.query, null).toString()
} catch (e: Exception) {
url
}
/**
* The build a check on launch should interrupt somebody for, or null to say
* nothing at all.
*
* Only an actual update is worth a word. Opening the app is not asking a
* question, so a check that finds nothing — or that cannot reach the release
* server at all — stays quiet; a phone on a train would otherwise be told off
* for it every single launch. The button in Settings *is* asking a question, and
* reports every answer including those two.
* question, so a check that finds nothing — or that cannot reach the update server at all
* — stays quiet; a phone on a train would otherwise be told off for it every
* single launch. The button in Settings *is* asking a question, and reports
* every answer including those two.
*/
fun launchOffer(verdict: UpdateVerdict): Release? =
(verdict as? UpdateVerdict.Install)?.release
@@ -8,36 +8,17 @@ import android.provider.Settings
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import org.prolibertate.games.BuildConfig
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Over-the-air updates from the repository's releases.
* Over-the-air updates from plg.wallacenet.ca (Cloudflare Pages).
*
* The channel is the Gitea instance this app ships from
* (`git.wallacenet.ca`), not GitHub. Gitea's release API answers with the
* same shape the updater reads — `tag_name`, `prerelease`, `draft`, and
* assets carrying `browser_download_url` — so this is the same code the
* upstream ran, pointed at `/api/v1/repos/...` routes instead of
* `api.github.com`, with `limit` where GitHub says `per_page`.
*
* Release tags are v<series>.<n>, where **n is the stamped build number the
* APK's versionCode was built from** (the CI run number plus the fixed
* `plgVersionRunOffset` — see docs/CI.md), so comparing the two is enough to
* tell whether a newer build exists.
*
* The repository and its releases are public: no token is embedded in the
* app, and every download is anonymous by design. If the repo were ever made
* private, every check would simply start failing rather than leaking
* anything.
*
* `BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision — it is
* true in this build; setting it false makes every check report that and
* fetch nothing.
* Release tags are v<series>.<run-number> and the APK's versionCode is that same run
* number, so comparing the two is enough to tell whether a newer build exists.
* Everything there is public — no token is embedded in the app.
*
* There is nothing to choose here. The channel comes from the running APK, so
* a dev install looks only at dev builds and a production install only at
@@ -52,11 +33,6 @@ class Updater(private val activity: Activity) {
* this phone is on, and the phone already knows.
*/
suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) {
if (!BuildConfig.OTA_ENABLED) {
return@withContext UpdateVerdict.Refused(
"In-app updates are not enabled for this build yet."
)
}
try {
val channel = installedChannel()
verdictFor(
@@ -70,70 +46,37 @@ class Updater(private val activity: Activity) {
}
/**
* Production reads the "latest release", which by definition skips
* prereleases — Gitea implements it as the most recent non-prerelease,
* non-draft release, so the rule holds on this instance too.
*
* Dev walks the release list for the newest prerelease, which is what the
* CI pipeline publishes from the dev branch. The list is taken as the
* highest versionCode among the dev releases rather than the first one
* returned, so the answer does not depend on how the server chose to
* order the page.
* Each channel publishes a `latest.json` next to its APK on Cloudflare
* Pages: `/prod/latest.json` and `/dev/latest.json`. The two live in one
* site but separate paths, so a build can only ever look down its own line.
*/
private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) {
UpdateChannel.PRODUCTION ->
getJsonObject("$API/releases/latest")
?.let { toRelease(it) }
UpdateChannel.DEV ->
getJsonArray("$API/releases?limit=30")
?.let { releases ->
(0 until releases.length())
.mapNotNull { toRelease(releases.getJSONObject(it)) }
.filter { it.channel == UpdateChannel.DEV }
.maxByOrNull { it.versionCode }
}
private fun fetchLatestRelease(channel: UpdateChannel): Release? {
val path = if (channel == UpdateChannel.DEV) "dev" else "prod"
return getJsonObject("$BASE_URL/$path/latest.json")?.let { toRelease(it) }
}
private fun toRelease(json: JSONObject): Release? {
val tag = json.optString("tag_name").ifBlank { return null }
val tag = json.optString("tag").ifBlank { return null }
val versionCode = versionCodeOfTag(tag) ?: return null
val channel = channelOfTag(tag, json.optBoolean("prerelease"))
val assets = json.optJSONArray("assets") ?: return null
val apks = (0 until assets.length())
.map { assets.getJSONObject(it) }
.filter { it.optString("name").endsWith(".apk") }
// A production release may carry the same build twice: once named for
// its version, and once under a fixed name so a permanent download link
// exists. Pick the versioned one explicitly rather than whichever was
// uploaded first, so what gets installed is traceable to its tag.
val chosen = apks.firstOrNull { it.optString("name").contains(tag) }
?: apks.firstOrNull()
?: return null
val apkUrl = json.optString("apkUrl").ifBlank { return null }
return Release(
tag = tag,
versionCode = versionCode,
apkUrl = onReportedHost(chosen.getString("browser_download_url"), SERVER_HOST),
apkName = chosen.optString("name"),
channel = channel,
apkUrl = apkUrl,
apkName = json.optString("apkName"),
channel = channelOfTag(tag, prerelease = false),
)
}
private fun openConnection(url: String): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
private fun getJsonObject(url: String): JSONObject? {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty("Accept", "application/json")
setRequestProperty("User-Agent", "PilgrimsOfKatan-Updater")
setRequestProperty("User-Agent", "ProLibertateGames-Updater")
connectTimeout = 15_000
readTimeout = 15_000
}
private fun getJsonObject(url: String): JSONObject? {
val connection = openConnection(url)
try {
// 404 means the channel has nothing published yet.
if (connection.responseCode != 200) return null
return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
} finally {
@@ -141,16 +84,6 @@ class Updater(private val activity: Activity) {
}
}
private fun getJsonArray(url: String): JSONArray? {
val connection = openConnection(url)
try {
if (connection.responseCode != 200) return null
return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
} finally {
connection.disconnect()
}
}
/** Read back from the installed APK's own version name. */
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
@@ -202,13 +135,6 @@ class Updater(private val activity: Activity) {
activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown"
private companion object {
/** The Gitea instance that publishes this app's releases. */
const val SERVER = "https://git.wallacenet.ca"
const val SERVER_HOST = "git.wallacenet.ca"
const val OWNER = "devadmin"
const val REPO = "PilgrimsOfKatan"
/** Release routes, Gitea's `/api/v1` spelling of GitHub's API. */
const val API = "$SERVER/api/v1/repos/$OWNER/$REPO"
const val BASE_URL = "https://plg.wallacenet.ca"
}
}
@@ -106,19 +106,36 @@ class CatalogTest {
}
@Test
fun `every reference game in the catalogue can be played`() {
// Every game the app ships is playable. The one exception is Katan,
// listed so the slot exists and marked *Coming soon* until its engine
// and screen land — `available = false` is what keeps an unfinished
// game out of a production build while leaving it visible on a dev one.
assertEquals(
listOf(GameCatalog.CATAN),
GameCatalog.all.filterNot { it.available }.map { it.id },
)
// It is also the only difference the two channels see in a menu.
val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
fun `every game in the catalogue can be played`() {
// `available` is the release gate. Nothing is held back at the moment,
// so a production menu lists every game; a game parked behind the gate
// belongs in `devPreview`, and must say so rather than being quietly
// unavailable everywhere.
GameCatalog.all.filterNot { it.available }.forEach { game ->
assertTrue("${game.id} is unavailable but not a dev preview", game.devPreview)
}
GameMenu.entries.forEach { menu ->
val held = GameCatalog.inMenu(menu, includeComingSoon = true) -
GameCatalog.inMenu(menu, includeComingSoon = false).toSet()
held.forEach { assertTrue("${it.id} hidden from production without a preview flag", it.devPreview) }
}
}
@Test
fun `a preview game opens on a dev build and nowhere else`() {
GameCatalog.all.filter { it.devPreview }.forEach { game ->
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
}
// The two games that were once previews now open everywhere.
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU).forEach { id ->
val game = GameCatalog.byId(id)!!
assertTrue("$id should open in production", game.playableIn(devBuild = false))
}
// An ordinary finished game opens everywhere.
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
}
@Test
@@ -160,6 +177,8 @@ class CatalogTest {
GameCatalog.MASTERMIND,
GameCatalog.BACKGAMMON,
GameCatalog.PIRATES,
GameCatalog.CATAN,
GameCatalog.MEOWDOKU,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -0,0 +1,43 @@
package org.prolibertate.games.game.backgammon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.engine.AiDifficulty
import org.prolibertate.games.game.engine.AiProfiles
class BackgammonAiProfileTest {
private fun playout(ai: BackgammonAi, seed: Long, steps: Int): List<BackgammonMove> {
var state = BackgammonRules.initialState(BackgammonOptions(), seed)
val chosen = mutableListOf<BackgammonMove>()
repeat(steps) {
if (BackgammonRules.isFinished(state)) return chosen
val seat = BackgammonRules.currentSeat(state) ?: return chosen
val legal = BackgammonRules.legalMoves(state, seat)
if (legal.isEmpty()) return chosen
val move = ai.chooseMove(state, seat, legal)
assertTrue("illegal move $move", move in legal)
chosen += move
state = BackgammonRules.applyMove(state, seat, move)
}
return chosen
}
@Test
fun everyLevelAndStyleReturnsLegalMoves() {
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD, AiDifficulty.MIXED)) {
for (seed in 0L..5L) {
val moves = playout(BackgammonAi(AiProfiles(difficulty, seed)), seed, 14)
assertTrue(moves.isNotEmpty())
}
}
}
@Test
fun defaultProfilePlaysAsBefore() {
val plain = playout(BackgammonAi(), 3L, 20)
val medium = playout(BackgammonAi(AiProfiles(AiDifficulty.MEDIUM, 77L)), 3L, 20)
assertEquals(plain, medium)
}
}

Some files were not shown because too many files have changed in this diff Show More