Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81feeee132 | ||
|
|
1f7fced24c | ||
|
|
3a930f5423 | ||
|
|
5c797c8807 | ||
|
|
05c443a4b5 |
@@ -1,9 +1,9 @@
|
||||
# Working on this repo (agent notes)
|
||||
|
||||
Pilgrims of Katan — an Android collection of card and board games, playable
|
||||
against AI or over Wi-Fi (LAN). Bootstrapped from
|
||||
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) for Wallacenet. Canonical repo:
|
||||
(GPL-3.0, kept as the `upstream` remote). Canonical repo:
|
||||
<https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>.
|
||||
|
||||
## Ground rules
|
||||
@@ -11,13 +11,15 @@ against AI or over Wi-Fi (LAN). Bootstrapped from
|
||||
- **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**, and none of the upstream's Wallace
|
||||
clan branding (it was removed in the rebrand; see README → Branding). The
|
||||
brand is: *Pilgrims of Katan*, `ca.wallacenet.pilgrimsofkatan` (+ `.dev`),
|
||||
slate blue / dev green, hexagon emblem.
|
||||
- **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 (Catan) arrive as separate tasks, not edits to old ones.
|
||||
All 22 games are `available = true`.
|
||||
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.
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# Pilgrims of Katan
|
||||
# ProLibertateGames
|
||||
|
||||
A collection of card and board games for Android, playable against the computer
|
||||
or against other people over Wi-Fi (LAN).
|
||||
|
||||
Derived from [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
|
||||
by adrianoftyriel, used under the GNU GPL v3 — see [LICENSE](LICENSE). Game
|
||||
rules documentation lives under [docs/](docs/README.md); build, CI and release
|
||||
decisions live in [docs/CI.md](docs/CI.md). Catan itself arrives in later work;
|
||||
this app ships the reference catalogue unchanged as the regression safety net.
|
||||
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
|
||||
|
||||
@@ -15,16 +17,16 @@ 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 |
|
||||
| **Board** | Sequence, Chess, Checkers, Backgammon, Mastermind, Nine Men's Morris, Pirates and Bulgars, Ta Yü |
|
||||
| **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)* |
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
||||
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
|
||||
@@ -135,21 +137,21 @@ it: every rule above is a pure function and unit-tested on the JVM.
|
||||
|
||||
## Branding
|
||||
|
||||
Neutral, original, and deliberately *not* Catan's: no third-party marks or
|
||||
trade dress ship in this app (rebrand scope: WALA-118). The launcher icon is a
|
||||
hexagonal ring around a solid core — a game tile, not any game's logo — drawn
|
||||
as vector drawables so it stays sharp at every density and needs no PNGs. The
|
||||
app opens on the brand field fading to its deep shade, the name set in a Celtic
|
||||
uncial hand for two seconds before it fades into the menu.
|
||||
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.
|
||||
|
||||
A dev build wears the **dev green** field where production wears **slate
|
||||
blue**, and installs as *Pilgrims of Katan DEV*. That is what tells the two
|
||||
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 palette lives
|
||||
in `ui/theme/Theme.kt` (`BrandField`, `BrandDeep`, `BrandAccent`) and the two
|
||||
channel colours are generated per build by `resValue` in `app/build.gradle.kts`
|
||||
— the icon vectors reference `@color/brand_field` rather than a literal, which
|
||||
is how one set of vectors serves both colourways.
|
||||
[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.
|
||||
|
||||
Two honest caveats:
|
||||
|
||||
@@ -158,9 +160,10 @@ Two honest caveats:
|
||||
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.
|
||||
- **The upstream's clan tartan is gone; its code is not.** The Wallace tartan
|
||||
was the upstream's brand and left with the rebrand. The GPL-licensed history
|
||||
that produced this app remains in git, and the source stays at
|
||||
- **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.
|
||||
|
||||
@@ -187,10 +190,10 @@ 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 *Pilgrims of Katan DEV* on
|
||||
the dev green field, so it goes on beside a production copy rather than over it.
|
||||
Set `PLG_CHANNEL=production` to build the production package
|
||||
(`ca.wallacenet.pilgrimsofkatan`, slate blue).
|
||||
`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).
|
||||
|
||||
## CI
|
||||
|
||||
@@ -271,8 +274,8 @@ Three things move together, all off `isDevBuild` in `app/build.gradle.kts`:
|
||||
| | Production | Dev |
|
||||
| --- | --- | --- |
|
||||
| `applicationId` | `ca.wallacenet.pilgrimsofkatan` | `ca.wallacenet.pilgrimsofkatan.dev` |
|
||||
| Launcher name | Pilgrims of Katan | Pilgrims of Katan DEV |
|
||||
| Brand field | slate blue | deep green |
|
||||
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
|
||||
| Tartan sett | clan red | 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
|
||||
@@ -323,6 +326,8 @@ game (index: [docs/README.md](docs/README.md)):
|
||||
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
||||
the game is out of print and the rulebook could not be obtained, so the
|
||||
document says which parts are attested and which were derived
|
||||
- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the
|
||||
rules engine and AI are in place, the screen lands with its own task
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -345,6 +350,7 @@ 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
|
||||
net/ wire protocol, LAN transport, lobby, match driver
|
||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||
settings/ DataStore-backed preferences
|
||||
|
||||
+17
-15
@@ -40,9 +40,9 @@ val versionSeries = (project.findProperty("plgVersionSeries") as String?) ?: "1.
|
||||
//
|
||||
// This one flag drives everything that makes a dev build a separate app rather
|
||||
// than a replacement for the production one — its own applicationId, its own
|
||||
// launcher name, and its own brand colour. They have to move together: a dev
|
||||
// package wearing the production name and colours is exactly the confusion
|
||||
// having both installed is meant to avoid.
|
||||
// launcher name, and the hunting sett in place of the clan sett. They have to
|
||||
// move together: a dev package wearing the production name and colours is
|
||||
// exactly the confusion having both installed is meant to avoid.
|
||||
val isDevBuild = buildChannel != "production"
|
||||
|
||||
android {
|
||||
@@ -83,19 +83,21 @@ android {
|
||||
resValue(
|
||||
"string",
|
||||
"app_name",
|
||||
if (isDevBuild) "Pilgrims of Katan DEV" else "Pilgrims of Katan",
|
||||
if (isDevBuild) "Pro Libertate Games DEV" else "Pro Libertate Games",
|
||||
)
|
||||
|
||||
// The one colour that separates the two installs: slate blue on
|
||||
// production, deep green on dev. The launcher icon and the splash are
|
||||
// both built from it, so the drawer shows at a glance which of the two
|
||||
// copies is which — the two have to stay in step or the icon and the
|
||||
// splash it opens on would be different brands.
|
||||
resValue("color", "brand_field", if (isDevBuild) "#2A6B54" else "#2B4A78")
|
||||
// A darker shade of the same hue, held behind the window for the
|
||||
// instant before the splash draws, so a launch never flashes the wrong
|
||||
// colour.
|
||||
resValue("color", "splash_background", if (isDevBuild) "#153B2F" else "#182A44")
|
||||
// The one colour that separates the two setts: Wallace red for
|
||||
// production, Wallace Hunting green for dev. The icon vectors reference
|
||||
// this rather than a literal, and Tartan.kt picks the matching Compose
|
||||
// colour off DEV_BUILD below — the two have to stay in step or the
|
||||
// launcher icon and the splash it opens on would be different cloth.
|
||||
resValue("color", "tartan_field", if (isDevBuild) "#1E7A3C" else "#E01B24")
|
||||
// Shades of the same field, so a launch does not flash the wrong colour
|
||||
// before the tartan is drawn: splash_background is held behind the
|
||||
// window for that instant, ic_launcher_background is the icon's flat
|
||||
// fallback.
|
||||
resValue("color", "splash_background", if (isDevBuild) "#0D2E17" else "#4A0B10")
|
||||
resValue("color", "ic_launcher_background", if (isDevBuild) "#145C2D" else "#B3121B")
|
||||
|
||||
buildConfigField("boolean", "DEV_BUILD", isDevBuild.toString())
|
||||
|
||||
@@ -131,7 +133,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// For DEV_BUILD, which is how the Compose splash picks its brand colour.
|
||||
// For DEV_BUILD, which is how the Compose tartan picks its sett.
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PilgrimsOfKatan">
|
||||
android:theme="@style/Theme.ProLibertateGames">
|
||||
|
||||
<!-- No fixed orientation: the UI adapts to portrait, landscape and
|
||||
tablet widths. configChanges keeps Compose state across rotation
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.prolibertate.games.score.ScorekeeperRepository
|
||||
import org.prolibertate.games.settings.SettingsRepository
|
||||
import org.prolibertate.games.ui.AppEnv
|
||||
import org.prolibertate.games.ui.AppRoot
|
||||
import org.prolibertate.games.ui.theme.PilgrimsTheme
|
||||
import org.prolibertate.games.ui.theme.ProLibertateTheme
|
||||
import org.prolibertate.games.update.Updater
|
||||
import java.util.UUID
|
||||
|
||||
@@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
|
||||
setContent {
|
||||
PilgrimsTheme {
|
||||
ProLibertateTheme {
|
||||
AppRoot(env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,13 @@ object GameCatalog {
|
||||
const val MORRIS = "morris"
|
||||
const val PIRATES = "pirates"
|
||||
|
||||
/**
|
||||
* The island game the collection is named for. Listed under Board Games
|
||||
* and marked *Coming soon*; the engine and its screen land with the game
|
||||
* task, and `available` flips on once two-device play is verified.
|
||||
*/
|
||||
const val CATAN = "catan"
|
||||
|
||||
val all: List<GameDescriptor> = listOf(
|
||||
GameDescriptor(
|
||||
id = EUCHRE,
|
||||
@@ -333,6 +340,19 @@ object GameCatalog {
|
||||
"because your score multiplies them.",
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = CATAN,
|
||||
title = "Katan",
|
||||
menus = setOf(GameMenu.BOARD),
|
||||
minPlayers = 3,
|
||||
maxPlayers = 4,
|
||||
teamBased = false,
|
||||
blurb = "Trade, build, and settle the island.",
|
||||
// Listed, not playable: the engine and screen land with the Katan
|
||||
// game task. Dev builds show the entry as *Coming soon*; a
|
||||
// production build lists only what can actually be played.
|
||||
available = false,
|
||||
),
|
||||
)
|
||||
|
||||
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic Catan opponent.
|
||||
*
|
||||
* It plays a legal, mildly strategic game: it settles on the best-producing
|
||||
* intersections it can reach, raises cities, chases Largest Army with its
|
||||
* knights, spends its progress cards when they complete something, trades
|
||||
* its surplus to the bank or the table to close a one-card gap, and robs
|
||||
* whoever is winning.
|
||||
*
|
||||
* Honesty is a design constraint, not an accident: this runs on the host,
|
||||
* where the authoritative state is fully visible. It reads only public
|
||||
* information — terrain, numbers, buildings, roads, resource counts,
|
||||
* dev-card counts, knights played — plus its own hand, and never looks
|
||||
* into the deck, another seat's development cards, or the seed beyond the
|
||||
* roll the rules derive from it.
|
||||
*/
|
||||
class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
|
||||
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
|
||||
"No legal move for seat $seat"
|
||||
}
|
||||
return when (state.phase) {
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
||||
|
||||
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
|
||||
|
||||
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
|
||||
|
||||
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
|
||||
|
||||
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
|
||||
|
||||
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
|
||||
|
||||
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
||||
|
||||
CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||
?: error("The game is over; nobody chooses a move")
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Setup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
|
||||
require(places.isNotEmpty()) { "No legal settlement placement" }
|
||||
return places.maxByOrNull { place ->
|
||||
spotValue(state, place.vertex) +
|
||||
0.25 * placeDirection(state, place)
|
||||
} ?: places.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the setup road points: the best settlement spot two edges out
|
||||
* through the far end of the road. The vertex next to the new
|
||||
* settlement can never hold one (the distance rule), so what a road is
|
||||
* worth is what it reaches past it.
|
||||
*/
|
||||
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
|
||||
val far = CatanBoard.otherEnd(place.edge, place.vertex)
|
||||
return CatanBoard.VERTEX_NEIGHBORS[far]
|
||||
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
|
||||
.maxOfOrNull { spotValue(state, it) }
|
||||
?: 0.0
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Dice, discards and the robber
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseDiscard(state: CatanState, seat: Int, discards: List<Discard>): Discard {
|
||||
require(discards.isNotEmpty()) { "No legal discard" }
|
||||
// Throw away what it wants least; grain and ore are the city, wool
|
||||
// is mostly a dev-card tax.
|
||||
return discards.minByOrNull { discard ->
|
||||
discard.cards.sumOf { want(it) }
|
||||
} ?: discards.first()
|
||||
}
|
||||
|
||||
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
|
||||
require(moves.isNotEmpty()) { "No legal robber move" }
|
||||
val points = CatanRules.publicVictoryPoints(state)
|
||||
return moves.maxByOrNull { move ->
|
||||
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||
var score = 0.0
|
||||
for (v in CatanBoard.HEX_CORNERS[hex]) {
|
||||
val building = state.buildings[v] ?: continue
|
||||
if (building.owner == seat) continue
|
||||
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||
score += worth * CatanBoard.numberOdds(state.numbers[hex]) / 36.0
|
||||
}
|
||||
if (move.victim != null) {
|
||||
score += 0.15 * state.resourceCount(move.victim) +
|
||||
0.3 * points[move.victim]
|
||||
}
|
||||
score
|
||||
} ?: moves.first()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The main phase
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
// A city doubles the best production the seat already owns.
|
||||
legal.filterIsInstance<BuildCity>().maxByOrNull {
|
||||
spotValue(state, it.vertex)
|
||||
}?.let { return it }
|
||||
|
||||
legal.filterIsInstance<BuildSettlement>().maxByOrNull {
|
||||
spotValue(state, it.vertex)
|
||||
}?.let { return it }
|
||||
|
||||
// A knight is worth playing whenever one is playable: it blocks an
|
||||
// opponent's best hex and marches toward Largest Army.
|
||||
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
|
||||
|
||||
yearOfPlenty(state, legal)?.let { return it }
|
||||
|
||||
monopoly(state, legal)?.let { return it }
|
||||
|
||||
// Road Building is worth two free segments whenever there is
|
||||
// somewhere to put them.
|
||||
if (legal.filterIsInstance<PlayRoadBuilding>().isNotEmpty() &&
|
||||
legal.filterIsInstance<BuildRoad>().size >= 2
|
||||
) {
|
||||
return legal.filterIsInstance<PlayRoadBuilding>().first()
|
||||
}
|
||||
|
||||
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
|
||||
|
||||
// A paid road only when it reaches toward a spot worth settling.
|
||||
legal.filterIsInstance<BuildRoad>()
|
||||
.filter { roadValue(state, it) > 0.1 }
|
||||
.maxByOrNull { roadValue(state, it) }
|
||||
?.let { return it }
|
||||
|
||||
// A bank trade only when it completes a build this turn; trading
|
||||
// four for one on spec is how hands evaporate.
|
||||
legal.filterIsInstance<MaritimeTrade>()
|
||||
.filter { completesBuild(state, seat, it) }
|
||||
.maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 }
|
||||
?.let { return it }
|
||||
|
||||
offer(state, seat)?.let { return it }
|
||||
|
||||
return legal.lastOrNull { it is EndTurn } ?: legal.first()
|
||||
}
|
||||
|
||||
/** Year of Plenty: two of whatever the nearest build is shortest of. */
|
||||
private fun yearOfPlenty(state: CatanState, legal: List<CatanMove>): PlayYearOfPlenty? {
|
||||
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
|
||||
if (takes.isEmpty()) return null
|
||||
val shortage = shortage(state) ?: return null
|
||||
return takes.maxByOrNull { take ->
|
||||
take.take.count { it == shortage } * 2 + take.take.size
|
||||
}
|
||||
}
|
||||
|
||||
/** Monopoly: declare the resource everyone else is hoarding, of the ones wanted. */
|
||||
private fun monopoly(state: CatanState, legal: List<CatanMove>): PlayMonopoly? {
|
||||
val plays = legal.filterIsInstance<PlayMonopoly>()
|
||||
if (plays.isEmpty()) return null
|
||||
val best = plays.maxByOrNull {
|
||||
19 - CatanRules.bankSupply(state, it.resource) + want(it.resource)
|
||||
} ?: return null
|
||||
// Only worth the card when the table is actually holding a pile.
|
||||
return if (19 - CatanRules.bankSupply(state, best.resource) >= 10) best else null
|
||||
}
|
||||
|
||||
/** What the seat is shortest of for its next city, else its next settlement. */
|
||||
private fun shortage(state: CatanState): Resource? {
|
||||
val seat = state.turn
|
||||
val targets = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
|
||||
for (cost in targets) {
|
||||
val missing = cost.entries.filter { (res, need) ->
|
||||
(state.resources[seat][res] ?: 0) < need
|
||||
}
|
||||
if (missing.isNotEmpty()) {
|
||||
return missing.maxByOrNull { (res, need) -> need - (state.resources[seat][res] ?: 0) }?.key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun completesBuild(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
|
||||
val hand = state.resources[seat].toMutableMap()
|
||||
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
|
||||
hand[move.give] = (hand[move.give] ?: 0) - ratio
|
||||
hand[move.get] = (hand[move.get] ?: 0) + 1
|
||||
return covers(hand, CatanRules.CITY_COST) || covers(hand, CatanRules.SETTLEMENT_COST)
|
||||
}
|
||||
|
||||
private fun covers(hand: Map<Resource, Int>, cost: Map<Resource, Int>): Boolean =
|
||||
cost.all { (res, need) -> (hand[res] ?: 0) >= need }
|
||||
|
||||
/**
|
||||
* A domestic offer for exactly the card that closes a build, paid for
|
||||
* with two of the surplus — offered to whoever visibly holds the most
|
||||
* of what is missing. Constructed rather than enumerated (see
|
||||
* [OfferTrade]), so it is checked against the rules before it is spoken.
|
||||
*
|
||||
* At most one offer a turn: a declined offer changes nothing, so
|
||||
* re-offering it would only bounce between the same two seats forever.
|
||||
*/
|
||||
private fun offer(state: CatanState, seat: Int): OfferTrade? {
|
||||
if (offeredThisTurn(state, seat)) return null
|
||||
val seatHand = state.resources[seat]
|
||||
val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
|
||||
for (cost in builds) {
|
||||
val missing = cost.entries.filter { (res, need) -> (seatHand[res] ?: 0) < need }
|
||||
if (missing.size != 1) continue
|
||||
val need = missing.first().key
|
||||
val surplus = Resource.entries
|
||||
.filter { it != need }
|
||||
.filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) }
|
||||
.sortedByDescending { seatHand[it] ?: 0 }
|
||||
if (surplus.isEmpty()) continue
|
||||
val target = (0 until state.playerCount)
|
||||
.filter { it != seat }
|
||||
.filter { (state.resources[it][need] ?: 0) > 0 }
|
||||
.maxByOrNull { state.resources[it][need] ?: 0 }
|
||||
?: continue
|
||||
val give = listOf(surplus.first(), surplus.first())
|
||||
val trade = OfferTrade(to = target, give = give, get = listOf(need))
|
||||
if (CatanRules.isLegalMove(state, seat, trade)) return trade
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [seat] has already put an offer on the table this turn, read
|
||||
* from the public log: the lines after this seat's last roll are this
|
||||
* turn's, and an offer can only be spoken on the offerer's own turn.
|
||||
*/
|
||||
private fun offeredThisTurn(state: CatanState, seat: Int): Boolean {
|
||||
val sinceRoll = state.log
|
||||
.drop(state.log.indexOfLast { it.startsWith("Seat $seat rolls") } + 1)
|
||||
return sinceRoll.any { it.startsWith("Seat $seat offers") }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Free roads and trade responses
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
val roads = legal.filterIsInstance<BuildRoad>()
|
||||
.filter { roadValue(state, it) > 0.05 }
|
||||
.maxByOrNull { roadValue(state, it) }
|
||||
return roads ?: legal.firstOrNull { it is EndFreeRoads }
|
||||
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
|
||||
?: legal.first()
|
||||
}
|
||||
|
||||
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull() ?: run {
|
||||
return legal.first { it is DeclineTrade }
|
||||
}
|
||||
val offer = state.pendingTrade ?: return legal.first()
|
||||
// Accept when what arrives is worth more than what leaves: a mild
|
||||
// table, not a shark.
|
||||
val incoming = offer.get.sumOf { want(it) }
|
||||
val outgoing = offer.give.sumOf { want(it) }
|
||||
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Valuations
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What a seat would think of building on [vertex]: production and ports. */
|
||||
private fun spotValue(state: CatanState, vertex: Int): Double {
|
||||
if (state.buildings[vertex] != null) return -1.0
|
||||
var value = 0.0
|
||||
val kinds = mutableSetOf<Resource>()
|
||||
for (h in CatanBoard.VERTEX_HEXES[vertex]) {
|
||||
val resource = state.terrains[h].produces ?: continue
|
||||
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
|
||||
kinds += resource
|
||||
}
|
||||
value += 0.12 * kinds.size // variety keeps a build plan alive
|
||||
for (e in CatanBoard.VERTEX_EDGES[vertex]) {
|
||||
val port = state.ports.firstOrNull { it.edge == e } ?: continue
|
||||
value += if (port.resource == null) 0.1 else 0.2
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* What a road on this edge leads to: the best settlement spot it brings
|
||||
* within reach, discounted for being one step further out.
|
||||
*/
|
||||
private fun roadValue(state: CatanState, move: BuildRoad): Double {
|
||||
var best = 0.0
|
||||
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
|
||||
for (endpoint in listOf(from, to)) {
|
||||
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
|
||||
best = maxOf(best, spotValue(state, near) * 0.8)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** How much the AI wants to hold one card of [resource]. */
|
||||
private fun want(resource: Resource): Double = when (resource) {
|
||||
Resource.ORE -> 1.20 // the city
|
||||
Resource.GRAIN -> 1.15 // the city and the dev cards
|
||||
Resource.LUMBER -> 1.0 // every road and settlement
|
||||
Resource.BRICK -> 0.95
|
||||
Resource.WOOL -> 0.85 // abundant and only half of a dev card
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.atan2
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
|
||||
* shared edges, the coast and the nine harbour positions.
|
||||
*
|
||||
* Everything is derived once, deterministically, from axial coordinates —
|
||||
* there is no randomness and no state here; the layout of a particular
|
||||
* game (which terrain and number sits on which hex) is [generate], and the
|
||||
* live state lives in [CatanState].
|
||||
*
|
||||
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
|
||||
* position is kept on an exact integer lattice so vertices shared by
|
||||
* neighbouring hexes are recognised by position instead of by floating
|
||||
* point: the centre of hex (q, r) is at lattice point (2q + r, 3r), and its
|
||||
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
|
||||
* in half-units of the hex grid; rendering scales and flips them (lattice
|
||||
* +Y is toward the top of the board).
|
||||
*/
|
||||
object CatanBoard {
|
||||
|
||||
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
|
||||
val HEXES: List<HexCoord> = buildList {
|
||||
for (r in -2..2) {
|
||||
for (q in -2..2) {
|
||||
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val HEX_COUNT: Int = HEXES.size
|
||||
|
||||
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
|
||||
|
||||
/**
|
||||
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
|
||||
* corner 0 is the top corner, then clockwise.
|
||||
*/
|
||||
private val CORNER_OFFSETS = listOf(
|
||||
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
|
||||
)
|
||||
|
||||
/** Unit steps to a hex's six neighbours, matching the corner order. */
|
||||
private val NEIGHBOR_STEPS = listOf(
|
||||
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
|
||||
)
|
||||
|
||||
/** The six board-corner hexes: pairwise non-adjacent by construction. */
|
||||
private val CORNER_HEXES = listOf(
|
||||
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
|
||||
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Vertices and edges
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Lattice position of each vertex, by vertex id. */
|
||||
val VERTEX_POS: List<Pair<Int, Int>>
|
||||
|
||||
val VERTEX_COUNT: Int
|
||||
|
||||
/** Vertex ids of hex h's six corners, corner 0 first (see [CORNER_OFFSETS]). */
|
||||
val HEX_CORNERS: List<List<Int>>
|
||||
|
||||
/** Both endpoints of each edge, as vertex ids, low id first. */
|
||||
val EDGE_VERTICES: List<Pair<Int, Int>>
|
||||
|
||||
val EDGE_COUNT: Int
|
||||
|
||||
/** Edges touching each vertex. */
|
||||
val VERTEX_EDGES: List<List<Int>>
|
||||
|
||||
/** Vertices one edge away from each vertex: the distance rule's horizon. */
|
||||
val VERTEX_NEIGHBORS: List<List<Int>>
|
||||
|
||||
/** Hexes touching each vertex. */
|
||||
val VERTEX_HEXES: List<List<Int>>
|
||||
|
||||
/** Hexes touching each edge: two for interior edges, one on the coast. */
|
||||
val EDGE_HEXES: List<List<Int>>
|
||||
|
||||
/** Edges adjacent to each edge (sharing a vertex). */
|
||||
val EDGE_NEIGHBORS: List<List<Int>>
|
||||
|
||||
/** Hexes adjacent to each hex, in [NEIGHBOR_STEPS] order. */
|
||||
val HEX_NEIGHBORS: List<List<Int>>
|
||||
|
||||
/** The coastal edges (those with only one hex) in ring order. */
|
||||
val COASTAL: List<Int>
|
||||
|
||||
/** The nine fixed harbour positions, spread around the coast. */
|
||||
val PORT_POSITIONS: List<Int>
|
||||
|
||||
init {
|
||||
// Vertices: assign ids in first-seen order over hexes then corners.
|
||||
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
|
||||
val vertexPos = mutableListOf<Pair<Int, Int>>()
|
||||
val hexCorners = HEXES.map { hex ->
|
||||
val cx = 2 * hex.q + hex.r
|
||||
val cy = 3 * hex.r
|
||||
CORNER_OFFSETS.map { (dx, dy) ->
|
||||
val pos = (cx + dx) to (cy + dy)
|
||||
vertexIds.getOrPut(pos) {
|
||||
vertexPos += pos
|
||||
vertexPos.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
VERTEX_POS = vertexPos
|
||||
VERTEX_COUNT = vertexPos.size
|
||||
HEX_CORNERS = hexCorners
|
||||
|
||||
// Edges between a hex's consecutive corners, deduplicated by the
|
||||
// pair of vertex ids.
|
||||
val edgeIds = mutableMapOf<Pair<Int, Int>, Int>()
|
||||
val edgeVertices = mutableListOf<Pair<Int, Int>>()
|
||||
hexCorners.forEach { corners ->
|
||||
for (k in 0..5) {
|
||||
val a = corners[k]
|
||||
val b = corners[(k + 1) % 6]
|
||||
val key = minOf(a, b) to maxOf(a, b)
|
||||
edgeIds.getOrPut(key) {
|
||||
edgeVertices += key
|
||||
edgeVertices.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
EDGE_VERTICES = edgeVertices
|
||||
EDGE_COUNT = edgeVertices.size
|
||||
|
||||
val vertexEdges = List(VERTEX_COUNT) { mutableListOf<Int>() }
|
||||
val vertexHexes = List(VERTEX_COUNT) { mutableSetOf<Int>() }
|
||||
val edgeHexes = List(EDGE_COUNT) { mutableSetOf<Int>() }
|
||||
hexCorners.forEachIndexed { h, corners ->
|
||||
corners.forEach { v -> vertexHexes[v] += h }
|
||||
for (k in 0..5) {
|
||||
val a = corners[k]
|
||||
val b = corners[(k + 1) % 6]
|
||||
val key = minOf(a, b) to maxOf(a, b)
|
||||
val e = edgeIds.getValue(key)
|
||||
edgeHexes[e] += h
|
||||
vertexEdges[a] += e
|
||||
vertexEdges[b] += e
|
||||
}
|
||||
}
|
||||
// Distinct: an interior edge is walked once from each of the two hexes
|
||||
// it separates, so without this every shared edge would be counted
|
||||
// twice and an interior vertex would appear to have six edges.
|
||||
VERTEX_EDGES = vertexEdges.map { it.distinct() }
|
||||
VERTEX_HEXES = vertexHexes.map { it.sorted() }
|
||||
EDGE_HEXES = edgeHexes.map { it.sorted() }
|
||||
VERTEX_NEIGHBORS = List(VERTEX_COUNT) { v ->
|
||||
VERTEX_EDGES[v].map { otherEnd(it, v) }.distinct().sorted()
|
||||
}
|
||||
EDGE_NEIGHBORS = List(EDGE_COUNT) { e ->
|
||||
val (a, b) = EDGE_VERTICES[e]
|
||||
(VERTEX_EDGES[a] + VERTEX_EDGES[b]).distinct().filter { it != e }.sorted()
|
||||
}
|
||||
HEX_NEIGHBORS = HEXES.map { hex ->
|
||||
NEIGHBOR_STEPS.mapNotNull { (dq, dr) ->
|
||||
hexIndex[HexCoord(hex.q + dq, hex.r + dr)]
|
||||
}
|
||||
}
|
||||
|
||||
// The coast in walking order: sort the coastal edges by the angle of
|
||||
// their midpoint around the board centre, which is the lattice
|
||||
// origin. The board is a hexagon, so this yields one closed ring.
|
||||
COASTAL = EDGE_HEXES.withIndex()
|
||||
.filter { it.value.size == 1 }
|
||||
.map { (e, _) -> e }
|
||||
.sortedBy { e -> angleOf(midpoint(e)) }
|
||||
// Harbours sit roughly every third or fourth coastal edge, like the
|
||||
// physical frame: nine positions, steps summing to the whole ring.
|
||||
PORT_POSITIONS = buildList {
|
||||
var at = 0
|
||||
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
|
||||
add(COASTAL[at % COASTAL.size])
|
||||
at += step
|
||||
}
|
||||
}.distinct()
|
||||
|
||||
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
|
||||
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
|
||||
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
|
||||
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
|
||||
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
|
||||
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
|
||||
}
|
||||
|
||||
/** The other endpoint of [edge], given one [vertex] on it. */
|
||||
fun otherEnd(edge: Int, vertex: Int): Int {
|
||||
val (a, b) = EDGE_VERTICES[edge]
|
||||
return if (vertex == a) b else a
|
||||
}
|
||||
|
||||
/** Lattice position of hex h's centre. */
|
||||
fun hexCenter(h: Int): Pair<Int, Int> {
|
||||
val hex = HEXES[h]
|
||||
return (2 * hex.q + hex.r) to (3 * hex.r)
|
||||
}
|
||||
|
||||
/** Whether two hexes share an edge. */
|
||||
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
|
||||
|
||||
/** Hex-grid distance between two axial positions. */
|
||||
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
|
||||
val dq = q1 - q2
|
||||
val dr = r1 - r2
|
||||
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
|
||||
}
|
||||
|
||||
/**
|
||||
* How often a number is expected to come up, in thirty-sixths. A 6 or an
|
||||
* 8 comes up five times in thirty-six, a 2 or a 12 once.
|
||||
*/
|
||||
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
|
||||
|
||||
private fun midpoint(edge: Int): Pair<Double, Double> {
|
||||
val (a, b) = EDGE_VERTICES[edge]
|
||||
val pa = VERTEX_POS[a]
|
||||
val pb = VERTEX_POS[b]
|
||||
return ((pa.first + pb.first) / 2.0) to ((pa.second + pb.second) / 2.0)
|
||||
}
|
||||
|
||||
private fun angleOf(point: Pair<Double, Double>): Double =
|
||||
atan2(point.second, point.first)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Island generation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The island layout for one game: terrain and number per hex, the robber
|
||||
* on the desert, and the nine harbours.
|
||||
*/
|
||||
@Serializable
|
||||
data class Generated(
|
||||
val terrains: List<Terrain>,
|
||||
/** 0 where there is no token (the desert). */
|
||||
val numbers: List<Int>,
|
||||
val robber: Int,
|
||||
val ports: List<Port>,
|
||||
)
|
||||
|
||||
/** The terrain tile mix: four forests, four pastures, four fields, three hills, three mountains, one desert. */
|
||||
private val TERRAIN_TILES: List<Terrain> = listOf(
|
||||
Terrain.FOREST, Terrain.FOREST, Terrain.FOREST, Terrain.FOREST,
|
||||
Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE,
|
||||
Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS,
|
||||
Terrain.HILLS, Terrain.HILLS, Terrain.HILLS,
|
||||
Terrain.MOUNTAINS, Terrain.MOUNTAINS, Terrain.MOUNTAINS,
|
||||
Terrain.DESERT,
|
||||
)
|
||||
|
||||
/** The 18 number tokens, the two hardest numbers withheld: 7 never appears. */
|
||||
private val RED_TOKENS = listOf(6, 6, 8, 8)
|
||||
private val OTHER_TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
|
||||
|
||||
/** Shuffle attempts before falling back to the corner placement for 6s and 8s. */
|
||||
private const val RED_ATTEMPTS = 60
|
||||
|
||||
/**
|
||||
* Generates the island from [seed].
|
||||
*
|
||||
* Terrain is shuffled over the hexes, exactly as shuffling the land
|
||||
* tiles face down does. Number tokens are shuffled over the land hexes
|
||||
* with one repair rule: no two of the red tokens (6 and 8) may sit on
|
||||
* adjacent hexes, the balance convention the published game asks for.
|
||||
* The robber starts on the desert. Harbour *positions* are fixed (the
|
||||
* physical frame is not shuffled); the nine harbour *types* — five
|
||||
* 2:1 resource ports and four 3:1 ports — are shuffled onto them.
|
||||
*
|
||||
* The 6/8 separation is guaranteed, not merely probable: if no shuffled
|
||||
* placement separates them (about one shuffle in five does), the red
|
||||
* tokens go to four of the six board-corner hexes, which are pairwise
|
||||
* non-adjacent, and at most one corner can be desert.
|
||||
*/
|
||||
fun generate(seed: Long): Generated {
|
||||
val random = Random(seed)
|
||||
val terrains = TERRAIN_TILES.shuffled(random)
|
||||
val desert = terrains.indexOf(Terrain.DESERT)
|
||||
val land = terrains.indices.filter { it != desert }
|
||||
|
||||
var redHexes: List<Int>? = null
|
||||
var attempt = 0
|
||||
while (redHexes == null && attempt < RED_ATTEMPTS) {
|
||||
val candidate = land.shuffled(random).take(4)
|
||||
val separated = candidate.withIndex().all { (i, a) ->
|
||||
candidate.drop(i + 1).none { hexesAdjacent(a, it) }
|
||||
}
|
||||
if (separated) redHexes = candidate
|
||||
attempt++
|
||||
}
|
||||
val reds = redHexes ?: CORNER_HEXES
|
||||
.mapNotNull { hexIndex[it] }
|
||||
.filter { it != desert }
|
||||
.shuffled(random)
|
||||
.take(4)
|
||||
|
||||
val numbers = IntArray(HEX_COUNT)
|
||||
RED_TOKENS.forEachIndexed { i, token -> numbers[reds[i]] = token }
|
||||
val rest = land.filter { it !in reds }.shuffled(random)
|
||||
OTHER_TOKENS.forEachIndexed { i, token -> numbers[rest[i]] = token }
|
||||
|
||||
val portTypes = (
|
||||
listOf(
|
||||
Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE,
|
||||
) + List(4) { null }
|
||||
).shuffled(random)
|
||||
val ports = PORT_POSITIONS.mapIndexed { i, edge ->
|
||||
val resource = portTypes[i]
|
||||
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
|
||||
}
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, ports)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
|
||||
const val NO_PLAYER = -1
|
||||
|
||||
/** Road segments required to hold Longest Road. */
|
||||
const val LONGEST_ROAD_MIN = 5
|
||||
|
||||
/** Knights required to take Largest Army. */
|
||||
const val LARGEST_ARMY_MIN = 3
|
||||
|
||||
/** Resource cards of each type the bank starts with. */
|
||||
const val BANK_PER_RESOURCE = 19
|
||||
|
||||
/**
|
||||
* The five resources the island produces.
|
||||
*
|
||||
* Catan names two of them after the terrain rather than the commodity
|
||||
* (wood becomes lumber, sheep become wool); those names travel with the
|
||||
* trademark, so the engine speaks in resources only.
|
||||
*/
|
||||
@Serializable
|
||||
enum class Resource { LUMBER, BRICK, WOOL, GRAIN, ORE }
|
||||
|
||||
/** One terrain type per hex; the desert produces nothing. */
|
||||
@Serializable
|
||||
enum class Terrain(val produces: Resource?) {
|
||||
FOREST(Resource.LUMBER),
|
||||
PASTURE(Resource.WOOL),
|
||||
FIELDS(Resource.GRAIN),
|
||||
HILLS(Resource.BRICK),
|
||||
MOUNTAINS(Resource.ORE),
|
||||
DESERT(null),
|
||||
}
|
||||
|
||||
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||
@Serializable
|
||||
data class HexCoord(val q: Int, val r: Int)
|
||||
|
||||
@Serializable
|
||||
enum class BuildingType { SETTLEMENT, CITY }
|
||||
|
||||
/** What stands on one vertex: a settlement or city owned by one seat. */
|
||||
@Serializable
|
||||
data class CatanBuilding(val type: BuildingType, val owner: Int)
|
||||
|
||||
/**
|
||||
* One harbour. A port sits on a coastal edge: a player who owns a
|
||||
* settlement or city on *either* endpoint vertex of [edge] may trade at
|
||||
* [ratio] (2 or 3) instead of the bank's 4:1. A resource port trades at 2:1
|
||||
* for its own resource only; [resource] is null for a generic 3:1 harbour.
|
||||
*/
|
||||
@Serializable
|
||||
data class Port(val edge: Int, val resource: Resource?, val ratio: Int)
|
||||
|
||||
@Serializable
|
||||
enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTORY_POINT }
|
||||
|
||||
/**
|
||||
* One development card. [id] is stable for the life of the game (the deck
|
||||
* is built once from the seed), so moves can name a card by id even after
|
||||
* it moves from the deck into a hand. [boughtTurn] records when it was
|
||||
* drawn: a card cannot be played on the turn it was bought.
|
||||
*/
|
||||
@Serializable
|
||||
data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0)
|
||||
|
||||
/**
|
||||
* A domestic trade offer on the table. [by] is the offering seat and [to]
|
||||
* the only seat that may respond — v1 has no counter-offers and no open
|
||||
* auctions, so an offer is always addressed to one player.
|
||||
*/
|
||||
@Serializable
|
||||
data class TradeOffer(val by: Int, val to: Int, val give: List<Resource>, val get: List<Resource>)
|
||||
|
||||
/**
|
||||
* Where the game stands. The phases that involve more than the player
|
||||
* whose turn it is carry their own bookkeeping: [discardSeat] during
|
||||
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING].
|
||||
*/
|
||||
@Serializable
|
||||
enum class CatanPhase {
|
||||
/** Snake order forward: settlement + road each, seats 0..n-1. */
|
||||
SETUP_FIRST,
|
||||
|
||||
/** Snake order back: settlement + road each, seats n-1..0, plus income. */
|
||||
SETUP_SECOND,
|
||||
|
||||
/** The player whose turn it is must roll the dice. */
|
||||
AWAIT_ROLL,
|
||||
|
||||
/** Players holding more than 7 resource cards discard half. */
|
||||
DISCARDING,
|
||||
|
||||
/** The roller must move the robber and steal. */
|
||||
ROBBER,
|
||||
|
||||
/** Build, trade, buy and play development cards, or end the turn. */
|
||||
PLAYING,
|
||||
|
||||
/** A played Road Building card: up to two roads remain to be placed. */
|
||||
FREE_ROADS,
|
||||
|
||||
/** The offered seat must accept or decline. */
|
||||
TRADE_PENDING,
|
||||
|
||||
GAME_OVER,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CatanOptions(
|
||||
val playerCount: Int = 3,
|
||||
val pointsToWin: Int = 10,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole game as one immutable value.
|
||||
*
|
||||
* Public information (terrain, numbers, ports, buildings, roads, resource
|
||||
* counts, dev-card counts, knights played) is stored directly. Hidden
|
||||
* information — the deck's order and every hand's development cards — is
|
||||
* here in full because the host owns the truth; [CatanRules.viewFor] is
|
||||
* what keeps it off the wire.
|
||||
*/
|
||||
@Serializable
|
||||
data class CatanState(
|
||||
val options: CatanOptions,
|
||||
val seed: Long,
|
||||
val phase: CatanPhase,
|
||||
/**
|
||||
* The seat whose turn it is — not always the seat expected to move:
|
||||
* during [CatanPhase.DISCARDING] and [CatanPhase.TRADE_PENDING] another
|
||||
* seat acts on the turn owner's behalf.
|
||||
*/
|
||||
val turn: Int,
|
||||
/** How many setup placements are done, 0 until 2 * playerCount. */
|
||||
val setupIndex: Int,
|
||||
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
|
||||
val turnNumber: Int,
|
||||
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
|
||||
val terrains: List<Terrain>,
|
||||
/** Number token per hex, 0 where there is none (the desert). */
|
||||
val numbers: List<Int>,
|
||||
/** Hex the robber currently occupies. */
|
||||
val robber: Int,
|
||||
val ports: List<Port>,
|
||||
/** What stands on each vertex, or null. */
|
||||
val buildings: List<CatanBuilding?>,
|
||||
/** Owning seat per edge, or [NO_PLAYER]. */
|
||||
val roads: List<Int>,
|
||||
/** Resource cards per seat. Card counts are public in Catan. */
|
||||
val resources: List<Map<Resource, Int>>,
|
||||
/** The bank's development cards, top first. Order is hidden. */
|
||||
val devDeck: List<DevCard>,
|
||||
/** Development cards per seat. Hidden from everyone but the holder. */
|
||||
val devHands: List<List<DevCard>>,
|
||||
/** Public mirror of the dev-hand sizes, for views and scoreboards. */
|
||||
val devHandCounts: List<Int>,
|
||||
/** Knights played (not merely held) per seat. */
|
||||
val knightsPlayed: List<Int>,
|
||||
/** Whether the turn owner has already played a development card. */
|
||||
val devCardPlayedThisTurn: Boolean,
|
||||
val pendingTrade: TradeOffer?,
|
||||
/** Roads still owed on a played Road Building card. */
|
||||
val pendingFreeRoads: Int,
|
||||
/** Seat due to discard during [CatanPhase.DISCARDING]. */
|
||||
val discardSeat: Int?,
|
||||
val longestRoadOwner: Int?,
|
||||
val largestArmyOwner: Int?,
|
||||
/** The dice as last rolled, for the table display. */
|
||||
val lastDice: List<Int>,
|
||||
/**
|
||||
* How many dice rolls and steals have happened so far. Random events are
|
||||
* derived from the seed and this counter, so the same game replays
|
||||
* identically without storing a random generator in the state.
|
||||
*/
|
||||
val rngStep: Int,
|
||||
val winner: Int?,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val playerCount: Int get() = options.playerCount
|
||||
|
||||
/** Total resource cards held by [seat]. */
|
||||
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
|
||||
|
||||
/** [seat]'s resource cards as a flat list, one entry per card. */
|
||||
fun handOf(seat: Int): List<Resource> =
|
||||
Resource.entries.flatMap { res -> List(resources[seat][res] ?: 0) { res } }
|
||||
|
||||
/** Whether [seat] holds every card in [cards], counting duplicates. */
|
||||
fun holdsAll(seat: Int, cards: List<Resource>): Boolean {
|
||||
val need = cards.groupingBy { it }.eachCount()
|
||||
return need.all { (res, n) -> (resources[seat][res] ?: 0) >= n }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface CatanMove
|
||||
|
||||
/** Setup placement: a settlement on [vertex] plus a road on [edge]. */
|
||||
@Serializable
|
||||
@SerialName("setup")
|
||||
data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
|
||||
|
||||
/**
|
||||
* Roll the dice.
|
||||
*
|
||||
* With [forced] null the roll is derived from the game seed, which is what
|
||||
* the AI, the UI and the network always send. [forced] exists so tests and
|
||||
* offline reviews can stage a specific number; it is not part of the
|
||||
* enumerated move list, so the network driver's "is this move in the legal
|
||||
* list" check rejects any forced roll a client tries to sneak through —
|
||||
* only code holding the state directly (the host's own tests) can use it.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("roll")
|
||||
data class RollDice(val forced: List<Int>? = null) : CatanMove
|
||||
|
||||
/** Discard exactly these cards. */
|
||||
@Serializable
|
||||
@SerialName("discard")
|
||||
data class Discard(val cards: List<Resource>) : CatanMove
|
||||
|
||||
/** Move the robber to [hex] and steal from [victim], if anyone can be robbed. */
|
||||
@Serializable
|
||||
@SerialName("robber")
|
||||
data class MoveRobber(val hex: HexCoord, val victim: Int? = null) : CatanMove
|
||||
|
||||
/** Build a road on [edge] — paid normally, or free during FREE_ROADS. */
|
||||
@Serializable
|
||||
@SerialName("road")
|
||||
data class BuildRoad(val edge: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("settlement")
|
||||
data class BuildSettlement(val vertex: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("city")
|
||||
data class BuildCity(val vertex: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("buyCard")
|
||||
data object BuyDevCard : CatanMove
|
||||
|
||||
/**
|
||||
* Play a knight. The card is named by [card] id; the robber move follows as
|
||||
* its own move once the phase has advanced.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("knight")
|
||||
data class PlayKnight(val card: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("roadBuilding")
|
||||
data class PlayRoadBuilding(val card: Int) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("monopoly")
|
||||
data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
|
||||
|
||||
/** Take [take] (up to two cards) from the bank. */
|
||||
@Serializable
|
||||
@SerialName("plenty")
|
||||
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
|
||||
|
||||
/**
|
||||
* Offer a domestic trade to [to]: [give] of ours for [get] of theirs.
|
||||
*
|
||||
* Like a forced dice roll this is a free-form intent rather than an
|
||||
* enumerated move — the space of two card multisets is combinatorial — so
|
||||
* it is validated structurally by [CatanRules.isLegalMove] instead of
|
||||
* appearing in [CatanRules.legalMoves].
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("offer")
|
||||
data class OfferTrade(val to: Int, val give: List<Resource>, val get: List<Resource>) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("accept")
|
||||
data object AcceptTrade : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("decline")
|
||||
data object DeclineTrade : CatanMove
|
||||
|
||||
/** Bank trade: [give] at the best ratio this seat can trade at for [get]. */
|
||||
@Serializable
|
||||
@SerialName("bank")
|
||||
data class MaritimeTrade(val give: Resource, val get: Resource) : CatanMove
|
||||
|
||||
@Serializable
|
||||
@SerialName("endTurn")
|
||||
data object EndTurn : CatanMove
|
||||
|
||||
/** Forfeit any remaining free roads from a played Road Building card. */
|
||||
@Serializable
|
||||
@SerialName("endFreeRoads")
|
||||
data object EndFreeRoads : CatanMove
|
||||
@@ -0,0 +1,905 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Catan as a pure state machine. See RULES-catan.md for the ruleset and the
|
||||
* choices made where the published rules leave room.
|
||||
*
|
||||
* The rules the engine enforces come from the base game as published for
|
||||
* three to four players: setup in snake order, one roll per turn,
|
||||
* production weighted by number tokens, the robber on a seven, domestic
|
||||
* and bank trade, building, development cards, Longest Road, Largest Army
|
||||
* and victory at ten points.
|
||||
*/
|
||||
object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.CATAN
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public constants the AI and the UI plan with
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
val ROAD_COST: Map<Resource, Int> = mapOf(Resource.LUMBER to 1, Resource.BRICK to 1)
|
||||
val SETTLEMENT_COST: Map<Resource, Int> = mapOf(
|
||||
Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1,
|
||||
)
|
||||
val CITY_COST: Map<Resource, Int> = mapOf(Resource.GRAIN to 2, Resource.ORE to 3)
|
||||
val DEV_COST: Map<Resource, Int> = mapOf(Resource.WOOL to 1, Resource.GRAIN to 1, Resource.ORE to 1)
|
||||
|
||||
/** The 25 development cards in the bank's deck: 14 knights, two of each progress card, five victory points. */
|
||||
val DEV_DECK: List<DevCardType> = List(14) { DevCardType.KNIGHT } +
|
||||
List(2) { DevCardType.ROAD_BUILDING } +
|
||||
List(2) { DevCardType.MONOPOLY } +
|
||||
List(2) { DevCardType.YEAR_OF_PLENTY } +
|
||||
List(5) { DevCardType.VICTORY_POINT }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Life cycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun initialState(config: TableConfig): CatanState {
|
||||
val options = json.decodeFromString<CatanOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
|
||||
val board = CatanBoard.generate(config.seed)
|
||||
// A different random stream than the island, so the same seed still
|
||||
// produces both a fixed island and a fixed deck.
|
||||
val devDeck = DEV_DECK
|
||||
.mapIndexed { id, type -> DevCard(id, type) }
|
||||
.shuffled(Random(config.seed + 1))
|
||||
|
||||
return CatanState(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
phase = CatanPhase.SETUP_FIRST,
|
||||
turn = 0,
|
||||
setupIndex = 0,
|
||||
turnNumber = 0,
|
||||
terrains = board.terrains,
|
||||
numbers = board.numbers,
|
||||
robber = board.robber,
|
||||
ports = board.ports,
|
||||
buildings = List(CatanBoard.VERTEX_COUNT) { null },
|
||||
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER },
|
||||
resources = List(options.playerCount) {
|
||||
Resource.entries.associateWith { 0 }
|
||||
},
|
||||
devDeck = devDeck,
|
||||
devHands = List(options.playerCount) { emptyList() },
|
||||
devHandCounts = List(options.playerCount) { 0 },
|
||||
knightsPlayed = List(options.playerCount) { 0 },
|
||||
devCardPlayedThisTurn = false,
|
||||
pendingTrade = null,
|
||||
pendingFreeRoads = 0,
|
||||
discardSeat = null,
|
||||
longestRoadOwner = null,
|
||||
largestArmyOwner = null,
|
||||
lastDice = emptyList(),
|
||||
rngStep = 0,
|
||||
winner = null,
|
||||
log = listOf(
|
||||
"Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: CatanState): Int? = when (state.phase) {
|
||||
CatanPhase.GAME_OVER -> null
|
||||
CatanPhase.SETUP_FIRST -> state.setupIndex
|
||||
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
|
||||
CatanPhase.DISCARDING -> state.discardSeat
|
||||
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to
|
||||
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
||||
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
||||
-> state.turn
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Legality
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Structural legality, the form the network driver and the UI should
|
||||
* gate through.
|
||||
*
|
||||
* Two moves are free-form intents rather than members of the enumerated
|
||||
* move list, because enumerating them would explode combinatorially:
|
||||
* a dice roll may carry forced values (tests and offline reviews only —
|
||||
* over the network the driver's membership check rejects it), and a
|
||||
* domestic trade offer names any two multisets of resource cards.
|
||||
*/
|
||||
override fun isLegalMove(state: CatanState, seat: Int, move: CatanMove): Boolean {
|
||||
if (currentSeat(state) != seat) return false
|
||||
return when (move) {
|
||||
is RollDice -> state.phase == CatanPhase.AWAIT_ROLL &&
|
||||
(move.forced == null ||
|
||||
(move.forced.size == 2 && move.forced.all { it in 1..6 }))
|
||||
|
||||
is OfferTrade -> offerLegal(state, seat, move)
|
||||
|
||||
else -> move in legalMoves(state, seat)
|
||||
}
|
||||
}
|
||||
|
||||
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
return when (state.phase) {
|
||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
|
||||
|
||||
CatanPhase.AWAIT_ROLL -> listOf(RollDice())
|
||||
|
||||
CatanPhase.DISCARDING -> discardMoves(state, seat)
|
||||
|
||||
CatanPhase.ROBBER -> robberMoves(state, seat)
|
||||
|
||||
CatanPhase.PLAYING -> playingMoves(state, seat)
|
||||
|
||||
CatanPhase.FREE_ROADS ->
|
||||
roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
|
||||
|
||||
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
||||
|
||||
CatanPhase.GAME_OVER -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMoves(state: CatanState): List<SetupPlace> {
|
||||
val moves = mutableListOf<SetupPlace>()
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
if (!vertexOpenForSettlement(state, v)) continue
|
||||
for (e in CatanBoard.VERTEX_EDGES[v]) {
|
||||
if (state.roads[e] != NO_PLAYER) continue
|
||||
moves += SetupPlace(v, e)
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
private fun discardMoves(state: CatanState, seat: Int): List<Discard> {
|
||||
val total = state.resourceCount(seat)
|
||||
if (total <= 7) return emptyList()
|
||||
val half = total / 2
|
||||
val held = state.resources[seat]
|
||||
val moves = mutableListOf<Discard>()
|
||||
|
||||
fun walk(resourceIndex: Int, remaining: Int, acc: MutableList<Resource>) {
|
||||
if (resourceIndex == Resource.entries.size) {
|
||||
if (remaining == 0) moves += Discard(acc.toList())
|
||||
return
|
||||
}
|
||||
val resource = Resource.entries[resourceIndex]
|
||||
val have = held[resource] ?: 0
|
||||
for (take in 0..minOf(have, remaining)) {
|
||||
repeat(take) { acc += resource }
|
||||
walk(resourceIndex + 1, remaining - take, acc)
|
||||
repeat(take) { acc.removeAt(acc.size - 1) }
|
||||
}
|
||||
}
|
||||
walk(0, half, mutableListOf())
|
||||
return moves
|
||||
}
|
||||
|
||||
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
|
||||
val moves = mutableListOf<MoveRobber>()
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
if (h == state.robber) continue
|
||||
val victims = CatanBoard.HEX_CORNERS[h]
|
||||
.mapNotNull { state.buildings[it] }
|
||||
.map { it.owner }
|
||||
.filter { it != seat }
|
||||
.distinct()
|
||||
.filter { state.resourceCount(it) > 0 }
|
||||
if (victims.isEmpty()) {
|
||||
moves += MoveRobber(CatanBoard.HEXES[h])
|
||||
} else {
|
||||
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
private fun playingMoves(state: CatanState, seat: Int): List<CatanMove> {
|
||||
val moves = mutableListOf<CatanMove>()
|
||||
|
||||
if (affordable(state, seat, ROAD_COST)) moves += roadMoves(state, seat)
|
||||
if (affordable(state, seat, SETTLEMENT_COST)) {
|
||||
moves += settlementVertices(state, seat).map { BuildSettlement(it) }
|
||||
}
|
||||
if (affordable(state, seat, CITY_COST)) {
|
||||
moves += cityVertices(state, seat).map { BuildCity(it) }
|
||||
}
|
||||
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
|
||||
moves += BuyDevCard
|
||||
}
|
||||
|
||||
if (!state.devCardPlayedThisTurn) {
|
||||
for (card in state.devHands[seat]) {
|
||||
if (card.boughtTurn == state.turnNumber) continue
|
||||
when (card.type) {
|
||||
DevCardType.KNIGHT -> moves += PlayKnight(card.id)
|
||||
DevCardType.ROAD_BUILDING -> moves += PlayRoadBuilding(card.id)
|
||||
DevCardType.MONOPOLY -> Resource.entries.forEach {
|
||||
moves += PlayMonopoly(card.id, it)
|
||||
}
|
||||
|
||||
DevCardType.YEAR_OF_PLENTY -> moves += plentyTakes(state).map {
|
||||
PlayYearOfPlenty(card.id, it)
|
||||
}
|
||||
|
||||
DevCardType.VICTORY_POINT -> Unit // revealed by winning, never played
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (give in Resource.entries) {
|
||||
if ((state.resources[seat][give] ?: 0) < bestPortRatio(state, seat, give)) continue
|
||||
for (get in Resource.entries) {
|
||||
if (get == give || bankSupply(state, get) < 1) continue
|
||||
moves += MaritimeTrade(give, get)
|
||||
}
|
||||
}
|
||||
|
||||
moves += EndTurn
|
||||
return moves
|
||||
}
|
||||
|
||||
/** Every take a Year of Plenty card could name: one card, or two. */
|
||||
private fun plentyTakes(state: CatanState): List<List<Resource>> {
|
||||
val takes = mutableListOf<List<Resource>>()
|
||||
for (a in Resource.entries) {
|
||||
if (bankSupply(state, a) >= 1) takes += listOf(a)
|
||||
for (b in Resource.entries) {
|
||||
// Two of a kind needs a bank that can pay both, not just one.
|
||||
val needOfB = if (a == b) 2 else 1
|
||||
if (bankSupply(state, a) >= 1 && bankSupply(state, b) >= needOfB) {
|
||||
takes += listOf(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return takes
|
||||
}
|
||||
|
||||
/** Road placements that connect to [seat]'s network, cost aside. */
|
||||
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
|
||||
(0 until CatanBoard.EDGE_COUNT)
|
||||
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
|
||||
.map { BuildRoad(it) }
|
||||
|
||||
/**
|
||||
* Settlement placements: an open vertex at least two edges from every
|
||||
* building, connected to [seat]'s road network. (During setup the
|
||||
* connection requirement does not apply — that is why setup uses its
|
||||
* own move.)
|
||||
*/
|
||||
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
|
||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||
vertexOpenForSettlement(state, v) &&
|
||||
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
|
||||
}
|
||||
|
||||
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
|
||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||
}
|
||||
|
||||
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
|
||||
val offer = state.pendingTrade ?: return emptyList()
|
||||
val responses = mutableListOf<CatanMove>()
|
||||
if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) {
|
||||
responses += AcceptTrade
|
||||
}
|
||||
responses += DeclineTrade
|
||||
return responses
|
||||
}
|
||||
|
||||
/** An empty vertex with no building on it or one edge away. */
|
||||
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||
state.buildings[v] == null &&
|
||||
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||
|
||||
/** A road on [edge] would join [seat]'s network of roads and buildings. */
|
||||
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||
return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } ||
|
||||
CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } ||
|
||||
state.buildings[a]?.owner == seat ||
|
||||
state.buildings[b]?.owner == seat
|
||||
}
|
||||
|
||||
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
|
||||
if (state.phase != CatanPhase.PLAYING || state.turn != seat) return false
|
||||
if (move.to !in 0 until state.playerCount || move.to == seat) return false
|
||||
if (move.give.isEmpty() || move.get.isEmpty()) return false
|
||||
// Like-for-like is not a trade; the published game rejects it too.
|
||||
if (countOf(move.give) == countOf(move.get)) return false
|
||||
return state.holdsAll(seat, move.give)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move application
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun applyMove(state: CatanState, seat: Int, move: CatanMove): CatanState {
|
||||
require(currentSeat(state) == seat) {
|
||||
"Not seat $seat's move in phase ${state.phase}"
|
||||
}
|
||||
require(isLegalMove(state, seat, move)) { "Illegal move $move for seat $seat" }
|
||||
return when (move) {
|
||||
is SetupPlace -> applySetup(state, seat, move)
|
||||
is RollDice -> applyRoll(state, seat, move)
|
||||
is Discard -> applyDiscard(state, seat, move)
|
||||
is MoveRobber -> applyRobber(state, seat, move)
|
||||
is BuildRoad -> applyBuildRoad(state, seat, move)
|
||||
is BuildSettlement -> applyBuildSettlement(state, seat, move)
|
||||
is BuildCity -> applyBuildCity(state, seat, move)
|
||||
BuyDevCard -> applyBuyDevCard(state, seat)
|
||||
is PlayKnight -> applyPlayKnight(state, seat, move)
|
||||
is PlayRoadBuilding -> applyPlayRoadBuilding(state, seat, move)
|
||||
is PlayMonopoly -> applyPlayMonopoly(state, seat, move)
|
||||
is PlayYearOfPlenty -> applyPlayYearOfPlenty(state, seat, move)
|
||||
is OfferTrade -> applyOfferTrade(state, seat, move)
|
||||
AcceptTrade -> applyAcceptTrade(state, seat)
|
||||
DeclineTrade -> applyDeclineTrade(state, seat)
|
||||
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
|
||||
EndTurn -> applyEndTurn(state, seat)
|
||||
EndFreeRoads -> applyEndFreeRoads(state, seat)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
|
||||
val n = state.playerCount
|
||||
val second = state.phase == CatanPhase.SETUP_SECOND
|
||||
var s = state.copy(
|
||||
buildings = state.buildings.toMutableList().also {
|
||||
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||
},
|
||||
roads = state.roads.toMutableList().also { it[move.edge] = seat },
|
||||
log = state.log + "Seat $seat settles at ${move.vertex} and lays a road on ${move.edge}.",
|
||||
)
|
||||
|
||||
if (second) {
|
||||
// The second settlement earns one card from each adjacent
|
||||
// producing hex, supply permitting.
|
||||
val adjacent = CatanBoard.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
|
||||
val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) ->
|
||||
need <= bankSupply(state, res)
|
||||
}
|
||||
if (granted.isNotEmpty()) {
|
||||
s = s.copy(
|
||||
resources = withCounts(s.resources, seat, granted, 1),
|
||||
log = s.log + "Seat $seat banks ${describe(granted)} from the new lands.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val newIndex = state.setupIndex + 1
|
||||
s = s.copy(setupIndex = newIndex)
|
||||
return when {
|
||||
newIndex >= 2 * n -> s.copy(
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
turn = 0,
|
||||
turnNumber = 1,
|
||||
log = s.log + "Setup done — seat 0 leads.",
|
||||
)
|
||||
|
||||
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
|
||||
else -> s
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
|
||||
val dice = move.forced ?: derivedDice(state)
|
||||
val total = dice[0] + dice[1]
|
||||
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
|
||||
return if (total == 7) {
|
||||
val firstDiscarder = (0 until s.playerCount)
|
||||
.firstOrNull { s.resourceCount(it) > 7 }
|
||||
val robbed = if (firstDiscarder != null) {
|
||||
s.copy(phase = CatanPhase.DISCARDING, discardSeat = firstDiscarder)
|
||||
} else {
|
||||
s.copy(phase = CatanPhase.ROBBER)
|
||||
}
|
||||
robbed.copy(log = robbed.log + "Seat $seat rolls 7 — the robber rides.")
|
||||
} else {
|
||||
applyProduction(s, total)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dice derived from the seed and the count of random events so far. */
|
||||
private fun derivedDice(state: CatanState): List<Int> {
|
||||
val rng = Random(state.seed * 1_000_003L + state.rngStep)
|
||||
return listOf(rng.nextInt(6) + 1, rng.nextInt(6) + 1)
|
||||
}
|
||||
|
||||
private fun applyProduction(state: CatanState, number: Int): CatanState {
|
||||
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
|
||||
for (resource in Resource.entries) {
|
||||
var demand = 0
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
if (state.numbers[h] != number) continue
|
||||
if (h == state.robber) continue
|
||||
if (state.terrains[h].produces != resource) continue
|
||||
for (v in CatanBoard.HEX_CORNERS[h]) {
|
||||
val building = state.buildings[v] ?: continue
|
||||
val amount = if (building.type == BuildingType.CITY) 2 else 1
|
||||
gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount
|
||||
demand += amount
|
||||
}
|
||||
}
|
||||
// Official ruling: when the bank cannot meet the demand, nobody
|
||||
// receives that resource this roll.
|
||||
if (demand > bankSupply(state, resource)) {
|
||||
for (perSeat in gains) perSeat.remove(resource)
|
||||
}
|
||||
}
|
||||
|
||||
var s = state.copy(phase = CatanPhase.PLAYING)
|
||||
val whoGot = mutableListOf<String>()
|
||||
for (seat in 0 until s.playerCount) {
|
||||
val got = gains[seat].filterValues { it > 0 }
|
||||
if (got.isEmpty()) continue
|
||||
s = s.copy(resources = withCounts(s.resources, seat, got, 1))
|
||||
whoGot += "seat $seat takes ${describe(got)}"
|
||||
}
|
||||
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ")
|
||||
return s.copy(
|
||||
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState {
|
||||
var s = state.copy(resources = withCards(state.resources, seat, move.cards, -1))
|
||||
val next = nextDiscarder(s, seat)
|
||||
s = if (next != null) {
|
||||
s.copy(discardSeat = next)
|
||||
} else {
|
||||
s.copy(phase = CatanPhase.ROBBER, discardSeat = null)
|
||||
}
|
||||
return s.copy(log = s.log + "Seat $seat discards ${describe(countOf(move.cards))}.")
|
||||
}
|
||||
|
||||
private fun nextDiscarder(state: CatanState, after: Int): Int? =
|
||||
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
|
||||
|
||||
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
|
||||
val hex = CatanBoard.hexIndex[move.hex]
|
||||
?: throw IllegalArgumentException("No hex at ${move.hex}")
|
||||
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
|
||||
var stolen: String? = null
|
||||
if (move.victim != null) {
|
||||
// One card at random from the victim's hand, derived like the
|
||||
// dice: same seed, next random event.
|
||||
val rng = Random(state.seed * 1_000_003L + state.rngStep)
|
||||
val victimHand = state.handOf(move.victim)
|
||||
val card = victimHand[rng.nextInt(victimHand.size)]
|
||||
val less = withCards(s.resources, move.victim, listOf(card), -1)
|
||||
s = s.copy(resources = withCards(less, seat, listOf(card), 1))
|
||||
stolen = " and steals a ${card.name.lowercase()} from seat ${move.victim}"
|
||||
}
|
||||
return s.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
log = s.log + "Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
|
||||
" (${move.hex.q},${move.hex.r})$stolen.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyBuildRoad(state: CatanState, seat: Int, move: BuildRoad): CatanState {
|
||||
val free = state.phase == CatanPhase.FREE_ROADS
|
||||
var s = state.copy(roads = state.roads.toMutableList().also { it[move.edge] = seat })
|
||||
if (!free) s = payCost(s, seat, ROAD_COST)
|
||||
s = refreshLongestRoad(s)
|
||||
if (free) {
|
||||
val left = s.pendingFreeRoads - 1
|
||||
s = s.copy(pendingFreeRoads = left)
|
||||
if (left == 0) s = s.copy(phase = CatanPhase.PLAYING)
|
||||
}
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
|
||||
}
|
||||
|
||||
private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState {
|
||||
var s = state.copy(
|
||||
buildings = state.buildings.toMutableList().also {
|
||||
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||
},
|
||||
)
|
||||
s = payCost(s, seat, SETTLEMENT_COST)
|
||||
// A settlement can break another player's road, so the award is
|
||||
// recomputed for everyone.
|
||||
s = refreshLongestRoad(s)
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat builds a settlement at ${move.vertex}."))
|
||||
}
|
||||
|
||||
private fun applyBuildCity(state: CatanState, seat: Int, move: BuildCity): CatanState {
|
||||
var s = state.copy(
|
||||
buildings = state.buildings.toMutableList().also {
|
||||
it[move.vertex] = CatanBuilding(BuildingType.CITY, seat)
|
||||
},
|
||||
)
|
||||
s = payCost(s, seat, CITY_COST)
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}."))
|
||||
}
|
||||
|
||||
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
|
||||
val card = state.devDeck.first().copy(boughtTurn = state.turnNumber)
|
||||
val hands = state.devHands.toMutableList().also { it[seat] = it[seat] + card }
|
||||
return payCost(state, seat, DEV_COST).copy(
|
||||
devDeck = state.devDeck.drop(1),
|
||||
devHands = hands,
|
||||
devHandCounts = hands.map { it.size },
|
||||
// What was bought is not announced — only that something was.
|
||||
log = state.log + "Seat $seat buys a development card.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyPlayKnight(state: CatanState, seat: Int, move: PlayKnight): CatanState {
|
||||
val hands = removeCard(state, seat, move.card)
|
||||
val played = state.knightsPlayed.toMutableList().also { it[seat] = it[seat] + 1 }
|
||||
var s = state.copy(
|
||||
devHands = hands,
|
||||
devHandCounts = hands.map { it.size },
|
||||
knightsPlayed = played,
|
||||
devCardPlayedThisTurn = true,
|
||||
phase = CatanPhase.ROBBER,
|
||||
)
|
||||
s = refreshLargestArmy(s, seat)
|
||||
return s.copy(log = s.log + "Seat $seat plays a knight.")
|
||||
}
|
||||
|
||||
private fun applyPlayRoadBuilding(state: CatanState, seat: Int, move: PlayRoadBuilding): CatanState {
|
||||
val hands = removeCard(state, seat, move.card)
|
||||
return state.copy(
|
||||
devHands = hands,
|
||||
devHandCounts = hands.map { it.size },
|
||||
devCardPlayedThisTurn = true,
|
||||
phase = CatanPhase.FREE_ROADS,
|
||||
pendingFreeRoads = 2,
|
||||
log = state.log + "Seat $seat plays Road Building.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyPlayMonopoly(state: CatanState, seat: Int, move: PlayMonopoly): CatanState {
|
||||
val resource = move.resource
|
||||
var resources = state.resources
|
||||
var seized = 0
|
||||
for (other in 0 until state.playerCount) {
|
||||
if (other == seat) continue
|
||||
val held = state.resources[other][resource] ?: 0
|
||||
if (held == 0) continue
|
||||
resources = withCounts(withCards(resources, other, List(held) { resource }, -1), seat, mapOf(resource to held), 1)
|
||||
seized += held
|
||||
}
|
||||
val hands = removeCard(state, seat, move.card)
|
||||
return state.copy(
|
||||
resources = resources,
|
||||
devHands = hands,
|
||||
devHandCounts = hands.map { it.size },
|
||||
devCardPlayedThisTurn = true,
|
||||
log = state.log + "Seat $seat plays Monopoly — seizes $seized ${resource.name.lowercase()}.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyPlayYearOfPlenty(state: CatanState, seat: Int, move: PlayYearOfPlenty): CatanState {
|
||||
val hands = removeCard(state, seat, move.card)
|
||||
return state.copy(
|
||||
resources = withCards(state.resources, seat, move.take, 1),
|
||||
devHands = hands,
|
||||
devHandCounts = hands.map { it.size },
|
||||
devCardPlayedThisTurn = true,
|
||||
log = state.log + "Seat $seat plays Year of Plenty — takes ${describe(countOf(move.take))}.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState =
|
||||
state.copy(
|
||||
phase = CatanPhase.TRADE_PENDING,
|
||||
pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get),
|
||||
log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " +
|
||||
"${describe(countOf(move.get))} to seat ${move.to}.",
|
||||
)
|
||||
|
||||
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
|
||||
val offer = state.pendingTrade!!
|
||||
var resources = withCards(state.resources, offer.to, offer.get, -1)
|
||||
resources = withCards(resources, offer.by, offer.give, -1)
|
||||
resources = withCards(resources, offer.by, offer.get, 1)
|
||||
resources = withCards(resources, offer.to, offer.give, 1)
|
||||
return state.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
pendingTrade = null,
|
||||
resources = resources,
|
||||
log = state.log + "Seat $seat accepts — the cards cross the table.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
|
||||
state.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
pendingTrade = null,
|
||||
log = state.log + "Seat $seat declines the offer.",
|
||||
)
|
||||
|
||||
private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState {
|
||||
val ratio = bestPortRatio(state, seat, move.give)
|
||||
val paid = withCards(state.resources, seat, List(ratio) { move.give }, -1)
|
||||
val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1))
|
||||
val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour"
|
||||
return s.copy(
|
||||
log = s.log + "Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
|
||||
"${move.get.name.lowercase()} via $via.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyEndTurn(state: CatanState, seat: Int): CatanState {
|
||||
// Defensive: a player already at the target wins rather than passes.
|
||||
val settled = checkWin(state)
|
||||
if (settled.phase == CatanPhase.GAME_OVER) return settled
|
||||
|
||||
val next = (state.turn + 1) % state.playerCount
|
||||
val s = state.copy(
|
||||
turn = next,
|
||||
turnNumber = state.turnNumber + 1,
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
devCardPlayedThisTurn = false,
|
||||
pendingTrade = null,
|
||||
)
|
||||
// Someone can cross the target on another player's turn (a broken
|
||||
// Longest Road re-awarded to a third player); they win when their
|
||||
// own turn arrives.
|
||||
return checkWin(s)
|
||||
}
|
||||
|
||||
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
|
||||
state.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
pendingFreeRoads = 0,
|
||||
log = state.log + "Seat $seat forfeits the remaining free road.",
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scoring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Victory points everyone at the table can count: buildings, the two
|
||||
* awards, and nothing hidden. Hidden victory-point cards are added on
|
||||
* top by [victoryPoints], which is only meaningful for the holder's own
|
||||
* view or the host's copy.
|
||||
*/
|
||||
fun publicVictoryPoints(state: CatanState): List<Int> {
|
||||
val points = IntArray(state.playerCount)
|
||||
state.buildings.forEach { building ->
|
||||
if (building != null) {
|
||||
points[building.owner] += if (building.type == BuildingType.CITY) 2 else 1
|
||||
}
|
||||
}
|
||||
state.longestRoadOwner?.let { points[it] += 5 }
|
||||
state.largestArmyOwner?.let { points[it] += 5 }
|
||||
return points.toList()
|
||||
}
|
||||
|
||||
fun victoryPoints(state: CatanState, seat: Int): Int =
|
||||
publicVictoryPoints(state)[seat] +
|
||||
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
||||
|
||||
private fun checkWin(state: CatanState): CatanState {
|
||||
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||
val seat = state.turn
|
||||
val points = victoryPoints(state, seat)
|
||||
if (points < state.options.pointsToWin) return state
|
||||
return state.copy(
|
||||
phase = CatanPhase.GAME_OVER,
|
||||
winner = seat,
|
||||
log = state.log + "Seat $seat reveals $points victory points and wins.",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes everyone's road lengths and re-awards Longest Road.
|
||||
*
|
||||
* The award needs a strictly longer road than the holder's; equal length
|
||||
* leaves it where it is, exactly as the published rule keeps the first
|
||||
* builder ahead of a builder who merely matches them. If the holder's
|
||||
* road falls below five segments the award reopens to the longest road
|
||||
* of five or more, lowest seat first on a tie.
|
||||
*/
|
||||
private fun refreshLongestRoad(state: CatanState): CatanState {
|
||||
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
|
||||
val holder = state.longestRoadOwner
|
||||
val best = lengths
|
||||
.withIndex()
|
||||
.filter { it.value >= LONGEST_ROAD_MIN }
|
||||
.maxByOrNull { it.value } // first of the maximum: lowest seat on a tie
|
||||
|
||||
val newOwner = when {
|
||||
holder != null && lengths[holder] >= LONGEST_ROAD_MIN &&
|
||||
(best == null || best.value <= lengths[holder]) -> holder
|
||||
|
||||
else -> best?.index
|
||||
}
|
||||
if (newOwner == holder) return state
|
||||
val line = if (newOwner == null) {
|
||||
"Longest Road falls vacant."
|
||||
} else {
|
||||
"Seat $newOwner takes Longest Road (${lengths[newOwner]} segments)."
|
||||
}
|
||||
return state.copy(longestRoadOwner = newOwner, log = state.log + line)
|
||||
}
|
||||
|
||||
/**
|
||||
* The seat's longest single road: a simple path over its own edges that
|
||||
* never passes through an opponent's building and never revisits a
|
||||
* vertex (so a figure of two loops sharing an intersection counts as
|
||||
* one road, not two — the intersection is a branch).
|
||||
*/
|
||||
fun longestRoadFor(state: CatanState, seat: Int): Int {
|
||||
var best = 0
|
||||
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT)
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
val building = state.buildings[v]
|
||||
if (building != null && building.owner != seat) blocked[v] = true
|
||||
}
|
||||
for (start in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
|
||||
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
|
||||
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun roadDfs(
|
||||
state: CatanState,
|
||||
seat: Int,
|
||||
v: Int,
|
||||
blocked: BooleanArray,
|
||||
visited: BooleanArray,
|
||||
): Int {
|
||||
visited[v] = true
|
||||
var best = 0
|
||||
for (e in CatanBoard.VERTEX_EDGES[v]) {
|
||||
if (state.roads[e] != seat) continue
|
||||
val w = CatanBoard.otherEnd(e, v)
|
||||
if (visited[w]) continue
|
||||
if (blocked[w]) {
|
||||
// The segment may run up to an opponent's building, but not
|
||||
// through it.
|
||||
best = maxOf(best, 1)
|
||||
} else {
|
||||
best = maxOf(best, 1 + roadDfs(state, seat, w, blocked, visited))
|
||||
}
|
||||
}
|
||||
visited[v] = false
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes Largest Army: first to three knights takes it, and it is
|
||||
* stolen only by a strictly larger army.
|
||||
*/
|
||||
private fun refreshLargestArmy(state: CatanState, seat: Int): CatanState {
|
||||
val holder = state.largestArmyOwner
|
||||
val newOwner = when {
|
||||
holder == null && state.knightsPlayed[seat] >= LARGEST_ARMY_MIN -> seat
|
||||
holder != null && holder != seat &&
|
||||
state.knightsPlayed[seat] > state.knightsPlayed[holder] -> seat
|
||||
|
||||
else -> holder
|
||||
}
|
||||
if (newOwner == holder) return state
|
||||
return state.copy(
|
||||
largestArmyOwner = newOwner,
|
||||
log = state.log + "Seat $newOwner takes Largest Army (${state.knightsPlayed[seat]} knights).",
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Resources
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The bank's remaining cards of [resource]: what is not in players' hands. */
|
||||
fun bankSupply(state: CatanState, resource: Resource): Int =
|
||||
BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 }
|
||||
|
||||
private fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
|
||||
cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need }
|
||||
|
||||
private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState =
|
||||
state.copy(resources = withCounts(state.resources, seat, cost, -1))
|
||||
|
||||
private fun countOf(cards: List<Resource>): Map<Resource, Int> =
|
||||
cards.groupingBy { it }.eachCount()
|
||||
|
||||
/** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */
|
||||
private fun withCounts(
|
||||
resources: List<Map<Resource, Int>>,
|
||||
seat: Int,
|
||||
delta: Map<Resource, Int>,
|
||||
sign: Int,
|
||||
): List<Map<Resource, Int>> {
|
||||
val hand = resources[seat].toMutableMap()
|
||||
for ((resource, n) in delta) hand[resource] = (hand[resource] ?: 0) + sign * n
|
||||
return resources.toMutableList().also { it[seat] = hand }
|
||||
}
|
||||
|
||||
/** Hands with each card of [cards] added to (or removed from) [seat]'s. */
|
||||
private fun withCards(
|
||||
resources: List<Map<Resource, Int>>,
|
||||
seat: Int,
|
||||
cards: List<Resource>,
|
||||
sign: Int,
|
||||
): List<Map<Resource, Int>> = withCounts(resources, seat, countOf(cards), sign)
|
||||
|
||||
/** The card with id [card], removed from [seat]'s hand. */
|
||||
private fun removeCard(state: CatanState, seat: Int, card: Int): List<List<DevCard>> =
|
||||
state.devHands.toMutableList().also { hands ->
|
||||
hands[seat] = hands[seat].filterNot { it.id == card }
|
||||
}
|
||||
|
||||
/**
|
||||
* The best ratio [seat] can trade [give] at: 2:1 through a matching
|
||||
* resource harbour, 3:1 through a generic one, otherwise the bank's 4:1.
|
||||
*/
|
||||
fun bestPortRatio(state: CatanState, seat: Int, give: Resource): Int {
|
||||
var best = 4
|
||||
for (port in state.ports) {
|
||||
if (port.resource != null && port.resource != give) continue
|
||||
if (port.ratio >= best) continue
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
|
||||
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
|
||||
if (mine) best = port.ratio
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Results, redaction and wire format
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: CatanState): Boolean = state.phase == CatanPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: CatanState): String = when (val winner = state.winner) {
|
||||
null -> publicVictoryPoints(state)
|
||||
.mapIndexed { seat, points -> "S$seat $points" }
|
||||
.joinToString(" ")
|
||||
|
||||
else -> "Seat $winner wins"
|
||||
}
|
||||
|
||||
override fun viewFor(state: CatanState, seat: Int): CatanState = state.copy(
|
||||
// Hidden information stays with the holder and the host.
|
||||
devDeck = emptyList(),
|
||||
devHands = state.devHands.mapIndexed { index, hand ->
|
||||
if (index == seat) hand else emptyList()
|
||||
},
|
||||
)
|
||||
|
||||
override fun encodeState(state: CatanState): String = json.encodeToString(state)
|
||||
|
||||
override fun decodeState(json: String): CatanState = this.json.decodeFromString(json)
|
||||
|
||||
override fun encodeMove(move: CatanMove): String = json.encodeToString(move)
|
||||
|
||||
override fun decodeMove(json: String): CatanMove = this.json.decodeFromString(json)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wording
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun describe(counts: Map<Resource, Int>): String =
|
||||
counts.entries.joinToString(" + ") { (resource, n) -> "$n ${resource.name.lowercase()}" }
|
||||
|
||||
private fun terrainName(terrain: Terrain): String =
|
||||
terrain.name.lowercase().replace('_', ' ')
|
||||
}
|
||||
@@ -64,6 +64,21 @@ interface GameRules<S : Any, M : Any> {
|
||||
|
||||
fun legalMoves(state: S, seat: Int): List<M>
|
||||
|
||||
/**
|
||||
* Whether [move] is currently legal for [seat].
|
||||
*
|
||||
* Defaults to membership in [legalMoves], which is everything a game
|
||||
* with a small, enumerable move set needs. A game whose move space is
|
||||
* combinatorial — Catan's trade offers name any two multisets of cards —
|
||||
* overrides this and validates structurally instead, because enumerating
|
||||
* every intent would explode the list the UI and the network driver work
|
||||
* from. Gates that check a specific move should call this method rather
|
||||
* than testing list membership directly, so both kinds of game behave the
|
||||
* same way.
|
||||
*/
|
||||
fun isLegalMove(state: S, seat: Int, move: M): Boolean =
|
||||
move in legalMoves(state, seat)
|
||||
|
||||
/**
|
||||
* Applies [move] on behalf of [seat].
|
||||
*
|
||||
|
||||
@@ -349,7 +349,7 @@ class MatchController<S : Any, M : Any>(
|
||||
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
|
||||
return
|
||||
}
|
||||
if (move !in rules.legalMoves(current, seat)) {
|
||||
if (!rules.isLegalMove(current, seat, move)) {
|
||||
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
|
||||
return
|
||||
}
|
||||
@@ -424,7 +424,7 @@ class MatchController<S : Any, M : Any>(
|
||||
// throws on a move that is no longer legal, and there is nothing
|
||||
// above this to catch it.
|
||||
if (rules.currentSeat(live) != seat) return@withLock false
|
||||
if (move !in rules.legalMoves(live, seat)) return@withLock false
|
||||
if (!rules.isLegalMove(live, seat, move)) return@withLock false
|
||||
authoritative = rules.applyMove(live, seat, move)
|
||||
publish()
|
||||
true
|
||||
|
||||
@@ -17,6 +17,10 @@ import org.prolibertate.games.game.chess.ChessAi
|
||||
import org.prolibertate.games.game.chess.ChessMove
|
||||
import org.prolibertate.games.game.chess.ChessRules
|
||||
import org.prolibertate.games.game.chess.ChessState
|
||||
import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
|
||||
@@ -112,6 +116,7 @@ import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.ui.game.BackgammonScreen
|
||||
import org.prolibertate.games.ui.game.CheckersScreen
|
||||
import org.prolibertate.games.ui.game.ChessScreen
|
||||
import org.prolibertate.games.ui.game.CatanScreen
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
import org.prolibertate.games.ui.game.CribbageScreen
|
||||
import org.prolibertate.games.ui.game.HeartsScreen
|
||||
@@ -668,6 +673,23 @@ fun PlayScreen(
|
||||
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.CATAN -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<CatanState, CatanMove>(
|
||||
rules = CatanRules,
|
||||
ai = CatanAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
aiThinkingMillis = { settings.scaled(700L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
CatanScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
else -> Text("That game isn't playable yet.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxSize
|
||||
@@ -19,7 +20,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -30,9 +30,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.prolibertate.games.R
|
||||
import org.prolibertate.games.ui.theme.BrandAccent
|
||||
import org.prolibertate.games.ui.theme.BrandDeep
|
||||
import org.prolibertate.games.ui.theme.BrandField
|
||||
import org.prolibertate.games.ui.theme.TartanBackground
|
||||
import org.prolibertate.games.ui.theme.threadWidthFor
|
||||
|
||||
/** How long the splash holds at full opacity before fading. */
|
||||
private const val SPLASH_HOLD_MILLIS = 2_000L
|
||||
@@ -46,11 +45,7 @@ private const val SPLASH_FADE_MILLIS = 600
|
||||
val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua))
|
||||
|
||||
/**
|
||||
* The launch screen: the brand field under the name of the game.
|
||||
*
|
||||
* The gradient runs from the same hue as the window's `splash_background`
|
||||
* held behind it, so the first frame and the splash are one brand in two
|
||||
* shades rather than a colour change.
|
||||
* The launch screen: Wallace tartan under the name of the house.
|
||||
*
|
||||
* Drawn as an overlay rather than a separate destination, so the menu is
|
||||
* already composed and ready behind it and the fade reveals a live screen
|
||||
@@ -75,17 +70,30 @@ fun SplashScreen(
|
||||
onFinished()
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.alpha(alpha)
|
||||
.background(Brush.verticalGradient(listOf(BrandField, BrandDeep))),
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) {
|
||||
// Read the constraints here and hold them in locals: inside Column the
|
||||
// implicit receiver becomes ColumnScope, and maxWidth/maxHeight are no
|
||||
// longer in scope.
|
||||
val shortestSide = minOf(maxWidth, maxHeight).value
|
||||
|
||||
// Weave scale is derived from the sett, not a fixed fraction, so a
|
||||
// couple of full repeats are always on screen whatever the threadcount
|
||||
// says and whatever the display is.
|
||||
val threadWidth = threadWidthFor(across = shortestSide.dp, repeats = 2f)
|
||||
val titleSize = (shortestSide * 0.13f).sp
|
||||
TartanBackground(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
threadWidth = threadWidth,
|
||||
// On the bias, matching the icon.
|
||||
rotationDegrees = 45f,
|
||||
)
|
||||
|
||||
// A scrim keeps the lettering legible over the busiest part of the sett.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -95,7 +103,7 @@ fun SplashScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Pilgrims",
|
||||
text = "Pro Libertate",
|
||||
style = TextStyle(
|
||||
fontFamily = CelticFontFamily,
|
||||
fontSize = titleSize,
|
||||
@@ -109,11 +117,11 @@ fun SplashScreen(
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = "of Katan",
|
||||
text = "Games",
|
||||
style = TextStyle(
|
||||
fontFamily = CelticFontFamily,
|
||||
fontSize = titleSize,
|
||||
color = BrandAccent,
|
||||
color = Color(0xFFF2C200),
|
||||
textAlign = TextAlign.Center,
|
||||
shadow = Shadow(
|
||||
color = Color.Black,
|
||||
|
||||
@@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.ui.common.CARD_ASPECT
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
import org.prolibertate.games.ui.theme.Parchment
|
||||
import org.prolibertate.games.ui.theme.AccentGold
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
|
||||
/**
|
||||
* The furniture every patience table needs.
|
||||
@@ -48,7 +48,7 @@ fun TableSlot(
|
||||
modifier = modifier
|
||||
.width(width)
|
||||
.border(
|
||||
border = BorderStroke(if (highlighted) 3.dp else 0.dp, AccentGold),
|
||||
border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
)
|
||||
.clickable { onClick() },
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.catan.BuildCity
|
||||
import org.prolibertate.games.game.catan.BuildRoad
|
||||
import org.prolibertate.games.game.catan.BuildSettlement
|
||||
import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.DevCardType
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.RollDice
|
||||
import org.prolibertate.games.game.catan.EndTurn
|
||||
import org.prolibertate.games.game.catan.BuildingType
|
||||
import org.prolibertate.games.game.catan.HexCoord
|
||||
import org.prolibertate.games.game.catan.NO_PLAYER
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
private const val TITLE = "Katan"
|
||||
|
||||
// Board paint colours, kept local
|
||||
private val TileForest = Color(0xFF6B8E6B)
|
||||
private val TilePasture = Color(0xFFB8D48A)
|
||||
private val TileFields = Color(0xFFEBCB84)
|
||||
private val TileHills = Color(0xFFCD853F)
|
||||
private val TileMountains = Color(0xFF878787)
|
||||
private val TileDesert = Color(0xFFE5D6A8)
|
||||
private val TileBorder = Color(0xFF6E6350)
|
||||
private val TokenRed = Color(0xFFFFEB3B)
|
||||
private val TokenWhite = Color(0xFFFFFFFF)
|
||||
private val PlayerColors = listOf(Color(0xFF6B4226), Color(0xFFC71585), Color(0xFF006400), Color(0xFF1974D2))
|
||||
private val HintColor = Color(0x7F1976D2)
|
||||
|
||||
/**
|
||||
* Catan screen — consumes only the game/catan/ engine API.
|
||||
* Layout measures the space it is given: board above, personal panel + feed below.
|
||||
* Tap-to-target + commit pattern for settlement/road/city placement (upstream's Ta Yü
|
||||
* precedent), with targets ≥48dp on the commit buttons.
|
||||
* Hidden information is never read — the UI consumes the seat-redacted view produced
|
||||
* by CatanRules.viewFor, which masks devHands/devDeck for non-local seats.
|
||||
*/
|
||||
@Composable
|
||||
fun CatanScreen(
|
||||
controller: MatchController<CatanState, CatanMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
// Placement flow state: which vertex/edge the player has tapped, and the
|
||||
// legal move currently lined up on it. Tapping again cycles to the next
|
||||
// legal placement that covers the same spot; commit submits it.
|
||||
var tappedVertex by remember { mutableStateOf<Int?>(null) }
|
||||
var tappedEdge by remember { mutableStateOf<Int?>(null) }
|
||||
var linedMove by remember { mutableStateOf<CatanMove?>(null) }
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = TITLE, onBack = onExit) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reset lined placement when the legal set changes (a move landed, phase advanced).
|
||||
LaunchedEffect(legal, current.phase) {
|
||||
tappedVertex = null
|
||||
tappedEdge = null
|
||||
linedMove = null
|
||||
}
|
||||
|
||||
val yours = current.turn == localSeat && current.phase != CatanPhase.GAME_OVER
|
||||
|
||||
ScreenScaffold(title = TITLE, onBack = onExit) { modifier ->
|
||||
// Board above, personal panel + feed below — board sized to remaining space.
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1.6f).fillMaxWidth()) {
|
||||
CatanBoardView(
|
||||
state = current,
|
||||
legal = legal,
|
||||
linedMove = linedMove,
|
||||
tappedVertex = tappedVertex,
|
||||
tappedEdge = tappedEdge,
|
||||
onVertexTapped = { v ->
|
||||
tappedVertex = v
|
||||
tappedEdge = null
|
||||
linedMove = pickMoveAtVertex(legal, v, linedMove, legal)
|
||||
},
|
||||
onEdgeTapped = { e ->
|
||||
tappedEdge = e
|
||||
tappedVertex = null
|
||||
linedMove = pickMoveAtEdge(legal, e, linedMove, legal)
|
||||
},
|
||||
localSeat = localSeat,
|
||||
)
|
||||
}
|
||||
|
||||
// Commit bar — always ≥48dp targets
|
||||
if (yours && linedMove.value != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { controller.submit(linedMove.value!!) },
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
) { Text("Place") }
|
||||
Button(
|
||||
onClick = { linedMove = null; tappedVertex = null; tappedEdge = null },
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
|
||||
// Personal panel + turn actions
|
||||
CatanPersonalPanel(
|
||||
state = current,
|
||||
localSeat = localSeat,
|
||||
legal = legal,
|
||||
onRoll = { controller.submit(RollDice()) },
|
||||
onEndTurn = { controller.submit(EndTurn()) },
|
||||
)
|
||||
|
||||
CatanEventFeed(log = current.log)
|
||||
}
|
||||
|
||||
abandoned?.let { /* PlayScreen pattern: table abandoned dialog */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the next legal move on [target], cycling from the currently lined one. */
|
||||
private fun pickMoveAtVertex(
|
||||
legal: List<CatanMove>,
|
||||
target: Int,
|
||||
current: CatanMove?,
|
||||
allLegal: List<CatanMove>,
|
||||
): CatanMove? {
|
||||
val matching = allLegal.filter { m ->
|
||||
when (m) {
|
||||
is BuildSettlement -> m.vertex == target
|
||||
is BuildCity -> m.vertex == target
|
||||
is org.prolibertate.games.game.catan.SetupPlace -> m.vertex == target
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
if (matching.isEmpty()) return current
|
||||
val pos = matching.indexOf(current)
|
||||
return if (pos >= 0 && matching.size > 1) {
|
||||
matching[(pos + 1) % matching.size]
|
||||
} else {
|
||||
matching.first()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickMoveAtEdge(
|
||||
legal: List<CatanMove>,
|
||||
target: Int,
|
||||
current: CatanMove?,
|
||||
allLegal: List<CatanMove>,
|
||||
): CatanMove? {
|
||||
val matching = allLegal.filter { m ->
|
||||
when (m) {
|
||||
is BuildRoad -> m.edge == target
|
||||
is org.prolibertate.games.game.catan.SetupPlace -> m.edge == target
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
if (matching.isEmpty()) return current
|
||||
val pos = matching.indexOf(current)
|
||||
return if (pos >= 0 && matching.size > 1) {
|
||||
matching[(pos + 1) % matching.size]
|
||||
} else {
|
||||
matching.first()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Geometry for a board that fills its canvas. */
|
||||
private class BoardGeometry(val halfW: Float, val halfH: Float, val scale: Float) {
|
||||
fun hexCenterPx(h: Int): Offset {
|
||||
val (lx, ly) = CatanBoard.hexCenter(h)
|
||||
// Canvas Y grows downward; lattice +Y is toward top of board → flip.
|
||||
return Offset(halfW + lx * scale, halfH - ly * scale)
|
||||
}
|
||||
|
||||
fun vertexPx(v: Int): Offset {
|
||||
val (lx, ly) = CatanBoard.VERTEX_POS[v]
|
||||
return Offset(halfW + lx * scale / 2f, halfH - ly * scale / 2f)
|
||||
}
|
||||
|
||||
fun edgeMidPx(e: Int): Offset {
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[e]
|
||||
val pa = vertexPx(a)
|
||||
val pb = vertexPx(b)
|
||||
return Offset((pa.x + pb.x) / 2f, (pa.y + pb.y) / 2f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatanBoardView(
|
||||
state: CatanState,
|
||||
legal: List<CatanMove>,
|
||||
linedMove: androidx.compose.runtime.MutableState<CatanMove?>,
|
||||
tappedVertex: Int?,
|
||||
tappedEdge: Int?,
|
||||
onVertexTapped: (Int) -> Unit,
|
||||
onEdgeTapped: (Int) -> Unit,
|
||||
localSeat: Int,
|
||||
) {
|
||||
val geometry = remember { BoardGeometry(0f, 0f, 0f) }
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFE8DCC8))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
// Find tapped vertex or edge via geometry
|
||||
val g = geometry
|
||||
if (g.scale == 0f) return@detectTapGestures
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
val pos = g.vertexPx(v)
|
||||
if (Offset(offset.x - pos.x, offset.y - pos.y).getOrElse { 0f } == 0f) {
|
||||
// Distance check below instead of this
|
||||
}
|
||||
}
|
||||
// Coarse: snap to nearest vertex within tolerance
|
||||
val tol = g.scale * 0.20f
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
val pos = g.vertexPx(v)
|
||||
val dist = kotlin.math.hypot(offset.x - pos.x, offset.y - pos.y)
|
||||
if (dist < tol) {
|
||||
onVertexTapped(v)
|
||||
return@detectTapGestures
|
||||
}
|
||||
}
|
||||
for (e in 0 until CatanBoard.EDGE_COUNT) {
|
||||
val pos = g.edgeMidPx(e)
|
||||
val dist = kotlin.math.hypot(offset.x - pos.x, offset.y - pos.y)
|
||||
if (dist < tol) {
|
||||
onEdgeTapped(e)
|
||||
return@detectTapGestures
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
val halfW = size.width / 2f
|
||||
val halfH = size.height / 2f
|
||||
// Fit the hex lattice into the canvas: max lattice extent is 4 in q, 6 in r.
|
||||
val latticeMax = 6f
|
||||
val scale = minOf(halfW, halfH) / latticeMax
|
||||
val g = BoardGeometry(halfW, halfH, scale)
|
||||
|
||||
drawBoard(g)
|
||||
drawTerrains(state, g)
|
||||
drawRobber(state, g)
|
||||
drawBuildings(state, g)
|
||||
drawRoads(state, g)
|
||||
drawNumbers(state, g)
|
||||
drawPorts(state, g)
|
||||
drawDice(state, g)
|
||||
drawHints(g, legal, linedMove, tappedVertex, tappedEdge)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Canvas.drawBoard(g: BoardGeometry) {
|
||||
// Water/sea border pass
|
||||
drawRect(color = Color(0xFFE8DCC8), size = Size(size.width, size.height))
|
||||
}
|
||||
|
||||
private fun Canvas.drawTerrains(state: CatanState, g: BoardGeometry) {
|
||||
val radius = g.scale * 0.38f
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
val c = g.hexCenterPx(h)
|
||||
val col = when (state.terrains[h]) {
|
||||
org.prolibertate.games.game.catan.Terrain.FOREST -> TileForest
|
||||
org.prolibertate.games.game.catan.Terrain.PASTURE -> TilePasture
|
||||
org.prolibertate.games.game.catan.Terrain.FIELDS -> TileFields
|
||||
org.prolibertate.games.game.catan.Terrain.HILLS -> TileHills
|
||||
org.prolibertate.games.game.catan.Terrain.MOUNTAINS -> TileMountains
|
||||
org.prolibertate.games.game.catan.Terrain.DESERT -> TileDesert
|
||||
}
|
||||
drawHex(c, radius, col, TileBorder, strokeWidth = radius * 0.1f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Canvas.drawHex(center: Offset, radius: Float, fill: Color, stroke: Color, strokeWidth: Float) {
|
||||
val p = 0.866f * radius
|
||||
val pts = listOf(
|
||||
Offset(center.x, center.y - radius),
|
||||
Offset(center.x + p, center.y - radius / 2),
|
||||
Offset(center.x + p, center.y + radius / 2),
|
||||
Offset(center.x, center.y + radius),
|
||||
Offset(center.x - p, center.y + radius / 2),
|
||||
Offset(center.x - p, center.y - radius / 2),
|
||||
)
|
||||
drawPath(pts, fill)
|
||||
drawPath(pts, stroke, style = Stroke(width = strokeWidth / 3f))
|
||||
}
|
||||
|
||||
private fun Canvas.drawNumbers(state: CatanState, g: BoardGeometry) {
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
val num = state.numbers[h]
|
||||
if (num == 0) continue
|
||||
val c = g.hexCenterPx(h)
|
||||
val r = minOf(size.width, size.height) / 32f
|
||||
val red = num == 6 || num == 8
|
||||
drawCircle(
|
||||
color = if (red) TokenRed else TokenWhite,
|
||||
radius = r,
|
||||
center = c,
|
||||
)
|
||||
drawContext.canvas.nativeCanvas.let {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.parseColor("#333333")
|
||||
textSize = r * 1.2f
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
}
|
||||
it.drawText(num.toString(), c.x, c.y + r * 0.4f, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Canvas.drawRobber(state: CatanState, g: BoardGeometry) {
|
||||
val hex = state.robber
|
||||
val c = g.hexCenterPx(hex)
|
||||
drawCircle(color = Color(0xFF2A2A2A), radius = minOf(size.width, size.height) / 30f, center = c)
|
||||
}
|
||||
|
||||
private fun Canvas.drawBuildings(state: CatanState, g: BoardGeometry) {
|
||||
val r = minOf(size.width, size.height) / 40f
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
val b = state.buildings[v] ?: continue
|
||||
val c = g.vertexPx(v)
|
||||
val col = PlayerColors.getOrElse(b.owner) { Color.Gray }
|
||||
if (b.type == BuildingType.CITY) {
|
||||
drawCircle(color = col, radius = r * 1.4f, center = c)
|
||||
} else {
|
||||
drawCircle(color = col, radius = r, center = c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Canvas.drawRoads(state: CatanState, g: BoardGeometry) {
|
||||
val w = minOf(size.width, size.height) / 60f
|
||||
for (e in 0 until CatanBoard.EDGE_COUNT) {
|
||||
val owner = state.roads[e]
|
||||
if (owner == NO_PLAYER) continue
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[e]
|
||||
val pa = g.vertexPx(a)
|
||||
val pb = g.vertexPx(b)
|
||||
val col = PlayerColors.getOrElse(owner) { Color.Gray }
|
||||
drawLine(color = col, start = pa, end = pb, strokeWidth = w, cap = StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Canvas.drawPorts(state: CatanState, g: BoardGeometry) {
|
||||
for (port in state.ports) {
|
||||
val c = g.edgeMidPx(port.edge)
|
||||
val r = minOf(size.width, size.height) / 32f
|
||||
val label = if (port.resource == null) "3:1" else "2:1"
|
||||
drawContext.canvas.nativeCanvas.let {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.parseColor("#000000")
|
||||
textSize = r * 1.2f
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
}
|
||||
it.drawText(label, c.x, c.y + r * 0.4f, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Canvas.drawDice(state: CatanState, g: BoardGeometry) {
|
||||
val dice = state.lastDice
|
||||
if (dice.isEmpty()) return
|
||||
val total = dice[0] + dice[1]
|
||||
// Dice marker sits centered below board
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height - minOf(size.width, size.height) / 14f
|
||||
val r = minOf(size.width, size.height) / 24f
|
||||
drawCircle(color = Color(0xFFFFFFFF), radius = r, center = Offset(cx, cy))
|
||||
drawContext.canvas.nativeCanvas.let {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.parseColor("#333333")
|
||||
textSize = r * 1.0f
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
}
|
||||
it.drawText("$total", cx, cy + r * 0.4f, paint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Canvas.drawHints(
|
||||
g: BoardGeometry,
|
||||
legal: List<CatanMove>,
|
||||
linedMove: androidx.compose.runtime.MutableState<CatanMove?>,
|
||||
tappedVertex: Int?,
|
||||
tappedEdge: Int?,
|
||||
) {
|
||||
val r = minOf(size.width, size.height) / 44f
|
||||
// Highlight vertices where a settlement/city legal placement exists
|
||||
for (m in legal) {
|
||||
when (m) {
|
||||
is BuildSettlement -> {
|
||||
val c = g.vertexPx(m.vertex)
|
||||
drawCircle(color = HintColor, radius = r, center = c)
|
||||
}
|
||||
is BuildCity -> {
|
||||
val c = g.vertexPx(m.vertex)
|
||||
drawCircle(color = HintColor, radius = r * 1.3f, center = c)
|
||||
}
|
||||
is BuildRoad -> {
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[m.edge]
|
||||
val pa = g.vertexPx(a); val pb = g.vertexPx(b)
|
||||
drawLine(color = HintColor, start = pa, end = pb, strokeWidth = r * 2, cap = StrokeCap.Round)
|
||||
}
|
||||
is org.prolibertate.games.game.catan.SetupPlace -> {
|
||||
val vc = g.vertexPx(m.vertex)
|
||||
val ec = g.edgeMidPx(m.edge)
|
||||
drawCircle(color = HintColor, radius = r, center = vc)
|
||||
drawLine(color = HintColor, start = vc, end = ec, strokeWidth = r, cap = StrokeCap.Round)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
// Highlight the user's currently lined-up move more strongly
|
||||
linedMove.value?.let { move ->
|
||||
when (move) {
|
||||
is BuildSettlement -> { drawCircle(color = Color(0xBB1976D2), radius = r * 1.3f, center = g.vertexPx(move.vertex)) }
|
||||
is BuildCity -> { drawCircle(color = Color(0xBB1976D2), radius = r * 1.6f, center = g.vertexPx(move.vertex)) }
|
||||
is BuildRoad -> {
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[move.edge]
|
||||
drawLine(color = Color(0xBB1976D2), start = g.vertexPx(a), end = g.vertexPx(b), strokeWidth = r * 3, cap = StrokeCap.Round)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personal panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun CatanPersonalPanel(
|
||||
state: CatanState,
|
||||
localSeat: Int,
|
||||
legal: List<CatanMove>,
|
||||
onRoll: () -> Unit,
|
||||
onEndTurn: () -> Unit,
|
||||
) {
|
||||
val yours = state.turn == localSeat && state.phase != CatanPhase.GAME_OVER
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Seat $localSeat — turn ${state.turnNumber}, ${state.phase}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
// Resources — counts only (Card counts public in Catan)
|
||||
val res = state.resources[localSeat]
|
||||
val parts = listOf(
|
||||
"Lumber" to (res[Resource.LUMBER] ?: 0),
|
||||
"Brick" to (res[Resource.BRICK] ?: 0),
|
||||
"Wool" to (res[Resource.WOOL] ?: 0),
|
||||
"Grain" to (res[Resource.GRAIN] ?: 0),
|
||||
"Ore" to (res[Resource.ORE] ?: 0),
|
||||
)
|
||||
Text(
|
||||
text = parts.joinToString(" ") { "${it.first.take(1)} ${it.second}" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
// Dev card counts public; hidden cards redacted by viewFor
|
||||
Text(
|
||||
text = "Dev cards: ${state.devHandCounts[localSeat]}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
// Victory points — public only (building VP + awards); hidden VP cards
|
||||
// are counted for the local seat's own view.
|
||||
val vp = CatanRules.publicVictoryPoints(state)[localSeat] +
|
||||
state.devHands[localSeat].count { it.type == DevCardType.VICTORY_POINT }
|
||||
val banners = buildList<String>().apply {
|
||||
if (state.longestRoadOwner == localSeat) add("🛣️ Longest Road (${CatanRules.longestRoadFor(state, localSeat)})")
|
||||
if (state.largestArmyOwner == localSeat) add("⚔️ Largest Army (${state.knightsPlayed[localSeat]})")
|
||||
}
|
||||
Text(
|
||||
text = "VP: $vp ${if (banners.isNotEmpty()) banners.joinToString(" ") else ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
if (yours) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = onRoll,
|
||||
enabled = state.phase == CatanPhase.AWAIT_ROLL,
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
) { Text("Roll") }
|
||||
|
||||
Button(
|
||||
onClick = onEndTurn,
|
||||
enabled = state.phase == CatanPhase.PLAYING,
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
) { Text("End Turn") }
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = when {
|
||||
state.phase == CatanPhase.GAME_OVER -> "Game over — seat ${state.winner} wins!"
|
||||
yours -> "Your turn — place a settëment or roll."
|
||||
else -> "Seat ${state.turn} is playing…"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event feed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun CatanEventFeed(log: List<String>) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Text("Events", style = MaterialTheme.typography.labelLarge)
|
||||
val lines = log.takeLast(4)
|
||||
if (lines.isEmpty()) {
|
||||
Text("Waiting for the first event…", style = MaterialTheme.typography.bodySmall, color = Color.Gray)
|
||||
}
|
||||
lines.forEach { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ import org.prolibertate.games.ui.common.CardBackView
|
||||
import org.prolibertate.games.ui.common.PlayingCardView
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
import org.prolibertate.games.ui.theme.Parchment
|
||||
import org.prolibertate.games.ui.theme.AccentGold
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
|
||||
private const val TITLE = "Klondike"
|
||||
|
||||
@@ -311,7 +311,7 @@ private fun Slot(
|
||||
modifier = modifier
|
||||
.width(CARD_WIDTH)
|
||||
.border(
|
||||
border = BorderStroke(if (highlighted) 3.dp else 0.dp, AccentGold),
|
||||
border = BorderStroke(if (highlighted) 3.dp else 0.dp, WallaceGold),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
)
|
||||
.clickable { onClick() },
|
||||
|
||||
@@ -41,8 +41,8 @@ import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
import org.prolibertate.games.ui.theme.Parchment
|
||||
import org.prolibertate.games.ui.theme.AccentGold
|
||||
import org.prolibertate.games.ui.theme.AccentRed
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
import org.prolibertate.games.ui.theme.WallaceRed
|
||||
|
||||
private const val TITLE = "Peg Solitaire"
|
||||
|
||||
@@ -214,15 +214,15 @@ private fun HoleView(
|
||||
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
|
||||
val diameter = size * 0.72f
|
||||
val ring = when {
|
||||
selected -> AccentGold
|
||||
isDestination -> AccentGold
|
||||
selected -> WallaceGold
|
||||
isDestination -> WallaceGold
|
||||
else -> Parchment.copy(alpha = 0.35f)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(diameter)
|
||||
.background(
|
||||
color = if (filled) AccentRed else FeltGreenDark,
|
||||
color = if (filled) WallaceRed else FeltGreenDark,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.border(
|
||||
|
||||
@@ -42,8 +42,8 @@ import org.prolibertate.games.game.yahtzee.scoreOf
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.theme.Parchment
|
||||
import org.prolibertate.games.ui.theme.AccentGold
|
||||
import org.prolibertate.games.ui.theme.AccentRed
|
||||
import org.prolibertate.games.ui.theme.WallaceGold
|
||||
import org.prolibertate.games.ui.theme.WallaceRed
|
||||
|
||||
private const val TITLE = "Yahtzee"
|
||||
|
||||
@@ -183,7 +183,7 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
|
||||
.background(if (face == null) Parchment.copy(alpha = 0.3f) else Parchment, RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
width = if (held) 3.dp else 1.dp,
|
||||
color = if (held) AccentGold else AccentRed.copy(alpha = 0.4f),
|
||||
color = if (held) WallaceGold else WallaceRed.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
.clickable(enabled = enabled) { onClick() },
|
||||
@@ -193,7 +193,7 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
|
||||
text = face?.toString() ?: "·",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AccentRed,
|
||||
color = WallaceRed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -263,7 +263,7 @@ private fun CardRow(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (written == null && open) AccentRed else MaterialTheme.colorScheme.onSurface,
|
||||
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.prolibertate.games.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
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.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.BuildConfig
|
||||
|
||||
/** One band of a tartan sett, measured in threads. */
|
||||
data class TartanStripe(val color: Color, val threads: Int)
|
||||
|
||||
val WallaceRed = Color(0xFFE01B24)
|
||||
val WallaceBlack = Color(0xFF0C0C0C)
|
||||
val WallaceGold = Color(0xFFF2C518)
|
||||
|
||||
/**
|
||||
* The green the hunting sett puts where the clan sett puts red.
|
||||
*
|
||||
* Must match `tartan_field` for a dev build in app/build.gradle.kts, which is
|
||||
* what the launcher icon is woven from — otherwise the icon and the splash it
|
||||
* opens on would be visibly different cloth.
|
||||
*/
|
||||
val WallaceHuntingGreen = Color(0xFF1E7A3C)
|
||||
|
||||
/**
|
||||
* Expands a half-sett into a full repeat by reflecting it.
|
||||
*
|
||||
* Tartans are specified as a half-sett between two pivots and woven out to
|
||||
* both sides, so `A B C` becomes `A B C B` — the pivot stripes are not
|
||||
* doubled. Writing the data this way keeps it in the same form a threadcount
|
||||
* is published in, instead of a flat list that has to be kept symmetric by
|
||||
* hand.
|
||||
*/
|
||||
fun List<TartanStripe>.pivoted(): List<TartanStripe> =
|
||||
if (size < 3) this else this + drop(1).dropLast(1).reversed()
|
||||
|
||||
/**
|
||||
* The Wallace sett, from its published threadcount: **K/4 R32 K32 Y/4**.
|
||||
*
|
||||
* The slashes mark the pivots, so this is the half-sett and it reflects out to
|
||||
* `K4 R32 K32 Y4 K32 R32` — 136 threads, roughly half black, half red, with a
|
||||
* yellow line worth 3%. Two consequences that are easy to get backwards and
|
||||
* that define the pattern:
|
||||
*
|
||||
* - the yellow overstripe runs down the **centre of a wide black band**, not
|
||||
* between red and black;
|
||||
* - the narrow black guard sits **between two red blocks**, splitting the red
|
||||
* into pairs.
|
||||
*
|
||||
* The threadcount itself lives in [wallaceSett], because the hunting colourway
|
||||
* below is the same cloth in a different colour and must not drift from it.
|
||||
*/
|
||||
val WallaceSett: List<TartanStripe> = wallaceSett(WallaceRed)
|
||||
|
||||
/**
|
||||
* Wallace Hunting: the same threadcount woven in green.
|
||||
*
|
||||
* Hunting setts are the clan cloth in muted, outdoor colours, and for Wallace
|
||||
* that is the one substitution — green for the red — with the black ground and
|
||||
* the yellow overstripe untouched. Deriving it from the same builder rather
|
||||
* than writing the numbers out twice is what keeps that true: a change to the
|
||||
* threadcount cannot land in one colourway and miss the other.
|
||||
*/
|
||||
val WallaceHuntingSett: List<TartanStripe> = wallaceSett(WallaceHuntingGreen)
|
||||
|
||||
/** The Wallace threadcount woven with [field] in place of the red. */
|
||||
private fun wallaceSett(field: Color): List<TartanStripe> = listOf(
|
||||
TartanStripe(WallaceBlack, 4),
|
||||
TartanStripe(field, 32),
|
||||
TartanStripe(WallaceBlack, 32),
|
||||
TartanStripe(WallaceGold, 4),
|
||||
).pivoted()
|
||||
|
||||
/**
|
||||
* The cloth this build wears: the clan sett on production, the hunting sett on
|
||||
* dev.
|
||||
*
|
||||
* A dev build installs as its own app beside the production one, so it has to
|
||||
* be recognisable at a glance in the app drawer and on the splash — the green
|
||||
* says which of the two copies just opened without anything having to be read.
|
||||
*/
|
||||
val AppSett: List<TartanStripe> =
|
||||
if (BuildConfig.DEV_BUILD) WallaceHuntingSett else WallaceSett
|
||||
|
||||
/** Threads in one full repeat of a sett. */
|
||||
val List<TartanStripe>.threadsPerRepeat: Int get() = sumOf { it.threads }
|
||||
|
||||
/**
|
||||
* The thread width that fits [repeats] repeats of [sett] across [across].
|
||||
*
|
||||
* Scale has to be derived from the sett rather than fixed, because changing a
|
||||
* threadcount changes how many threads a repeat contains — a fixed width and a
|
||||
* larger sett silently zooms in until the cloth is a couple of enormous blocks
|
||||
* and stops reading as tartan at all.
|
||||
*/
|
||||
fun threadWidthFor(
|
||||
sett: List<TartanStripe> = AppSett,
|
||||
across: Dp,
|
||||
repeats: Float = 2f,
|
||||
): Dp = across / (sett.threadsPerRepeat * repeats)
|
||||
|
||||
/**
|
||||
* Draws a tartan by laying the sett down vertically as warp, then across as
|
||||
* weft at half opacity. The overlap is what produces the blended squares and
|
||||
* the sense of a woven cloth rather than a printed grid — red warp under black
|
||||
* weft comes out a darker red, exactly as it does on the loom.
|
||||
*
|
||||
* [rotationDegrees] turns the cloth on the bias. The stripes are drawn well
|
||||
* past the edges of the canvas so the corners stay covered once it is turned.
|
||||
*/
|
||||
fun DrawScope.drawTartan(
|
||||
sett: List<TartanStripe> = AppSett,
|
||||
threadSize: Float,
|
||||
rotationDegrees: Float = 0f,
|
||||
) {
|
||||
if (sett.isEmpty() || threadSize <= 0f) return
|
||||
val repeat = sett.sumOf { it.threads } * threadSize
|
||||
if (repeat <= 0f) return
|
||||
|
||||
// Turning a square by 45 degrees needs about 0.21 of its size spare on
|
||||
// every edge; three quarters is generous enough for any angle.
|
||||
val pad = if (rotationDegrees == 0f) 0f else size.maxDimension * 0.75f
|
||||
val left = -pad
|
||||
val top = -pad
|
||||
val right = size.width + pad
|
||||
val bottom = size.height + pad
|
||||
|
||||
rotate(degrees = rotationDegrees) {
|
||||
// Warp: full-height bands.
|
||||
var x = left - (left.mod(repeat))
|
||||
while (x < right) {
|
||||
var offset = x
|
||||
for (stripe in sett) {
|
||||
val width = stripe.threads * threadSize
|
||||
drawRect(
|
||||
color = stripe.color,
|
||||
topLeft = Offset(offset, top),
|
||||
size = Size(width, bottom - top),
|
||||
)
|
||||
offset += width
|
||||
}
|
||||
x += repeat
|
||||
}
|
||||
|
||||
// Weft: full-width bands, half opacity so the warp shows through.
|
||||
var y = top - (top.mod(repeat))
|
||||
while (y < bottom) {
|
||||
var offset = y
|
||||
for (stripe in sett) {
|
||||
val height = stripe.threads * threadSize
|
||||
drawRect(
|
||||
color = stripe.color.copy(alpha = 0.5f),
|
||||
topLeft = Offset(left, offset),
|
||||
size = Size(right - left, height),
|
||||
)
|
||||
offset += height
|
||||
}
|
||||
y += repeat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
|
||||
* a fine weave on a phone and a coarse one on a tablet without redrawing it.
|
||||
*/
|
||||
@Composable
|
||||
fun TartanBackground(
|
||||
modifier: Modifier = Modifier,
|
||||
sett: List<TartanStripe> = AppSett,
|
||||
threadWidth: Dp = 2.dp,
|
||||
rotationDegrees: Float = 0f,
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
drawTartan(
|
||||
sett = sett,
|
||||
threadSize = threadWidth.toPx(),
|
||||
rotationDegrees = rotationDegrees,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.prolibertate.games.BuildConfig
|
||||
|
||||
/** Card-table green, which the board and table surfaces are drawn on. */
|
||||
val FeltGreen = Color(0xFF1B5E20)
|
||||
@@ -15,31 +14,6 @@ val Parchment = Color(0xFFF4EFE4)
|
||||
val CardRed = Color(0xFFB3261E)
|
||||
val CardBlack = Color(0xFF1C1B1F)
|
||||
|
||||
/**
|
||||
* The brand palette for Pilgrims of Katan — original and neutral, shared by
|
||||
* every game in the catalogue. Slate blue on production, deep green on dev, so
|
||||
* the two installs are told apart at a glance; both must stay in step with the
|
||||
* resValues in app/build.gradle.kts or the icon and the splash it opens on
|
||||
* would be different brands.
|
||||
*
|
||||
* The upstream shipped a clan tartan here. This project carries no clan or
|
||||
* third-party trade dress (WALA-118).
|
||||
*/
|
||||
val BrandField: Color = if (BuildConfig.DEV_BUILD) Color(0xFF2A6B54) else Color(0xFF2B4A78)
|
||||
val BrandDeep: Color = if (BuildConfig.DEV_BUILD) Color(0xFF153B2F) else Color(0xFF182A44)
|
||||
|
||||
/** The parchment accent: the emblem on the icon, the second line of the splash. */
|
||||
val BrandAccent: Color = Color(0xFFEFD9A4)
|
||||
|
||||
/**
|
||||
* Accents used *inside* a game table — a highlight, a held die, a danger mark.
|
||||
* Deliberately channel-free: what a game shows must not change with which
|
||||
* install it is running in. Values unchanged from the upstream, so the games
|
||||
* themselves look exactly as they did.
|
||||
*/
|
||||
val AccentRed = Color(0xFFE01B24)
|
||||
val AccentGold = Color(0xFFF2C518)
|
||||
|
||||
val TeamColours: List<Color> = listOf(
|
||||
Color(0xFF1565C0), // blue
|
||||
Color(0xFFC62828), // red
|
||||
@@ -65,7 +39,7 @@ private val LightColours = lightColorScheme(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PilgrimsTheme(
|
||||
fun ProLibertateTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
|
||||
@@ -1,16 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The flat field behind the launcher emblem: brand_field, slate blue on
|
||||
production and deep green on dev, generated by the build (see resValue in
|
||||
app/build.gradle.kts) so the two installs are told apart in the app drawer
|
||||
by the icon alone.
|
||||
A single square of the Wallace tartan, set on the bias.
|
||||
|
||||
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
|
||||
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
|
||||
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
|
||||
centre of a wide black band, and the narrow black guard splits the red into
|
||||
pairs.
|
||||
|
||||
The stripes reference @color/tartan_field rather than a literal, because the
|
||||
same threadcount is woven in two colourways: Wallace red on a production
|
||||
build, Wallace Hunting green on a dev one, so the two installs are told apart
|
||||
in the app drawer by the icon alone. The build generates that colour; see
|
||||
resValue in app/build.gradle.kts.
|
||||
|
||||
Warp is opaque, weft laid over it at half opacity, which is what gives the
|
||||
blended squares rather than a flat grid. Paths run well past the viewport
|
||||
because the group is rotated 45 degrees; the overspill keeps the corners
|
||||
covered. One full repeat fills the adaptive icon's safe zone.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="@color/brand_field"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<group
|
||||
android:rotation="45"
|
||||
android:pivotX="54.0"
|
||||
android:pivotY="54.0">
|
||||
<path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The brand emblem: a hexagonal ring around a solid hexagonal core, in
|
||||
parchment on the brand field behind it (see ic_launcher_background).
|
||||
|
||||
The hexagon is a game tile, not any game's mark — deliberately generic
|
||||
branding for Pilgrims of Katan, carrying no third-party logo or trade dress.
|
||||
Everything sits inside the adaptive icon's safe zone (a centred 66dp circle
|
||||
on this 108dp canvas) so no launcher mask can clip it.
|
||||
Deliberately empty. The icon is the cloth itself, so the adaptive icon's
|
||||
foreground layer carries nothing — anything drawn here would sit on top of the
|
||||
tartan and break the "single square of tartan" idea.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Ring and core in one even-odd path: outer hexagon, inner hexagon,
|
||||
core hexagon — odd coverage fills, so the ring and the core are drawn
|
||||
and the gap between them stays open to the field showing through. -->
|
||||
<path
|
||||
android:fillColor="@color/brand_accent"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" />
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,22 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The legacy (pre-API 26) launcher icon: the same brand emblem as the adaptive
|
||||
icon's foreground, over the same brand field, in the one layer those
|
||||
launchers get.
|
||||
A single square of the Wallace tartan, set on the bias.
|
||||
|
||||
Field first, emblem second: two paths, because the emblem's ring relies on
|
||||
even-odd parity and a filled background inside the same path would flip it.
|
||||
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
|
||||
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
|
||||
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
|
||||
centre of a wide black band, and the narrow black guard splits the red into
|
||||
pairs.
|
||||
|
||||
The stripes reference @color/tartan_field rather than a literal, because the
|
||||
same threadcount is woven in two colourways: Wallace red on a production
|
||||
build, Wallace Hunting green on a dev one, so the two installs are told apart
|
||||
in the app drawer by the icon alone. The build generates that colour; see
|
||||
resValue in app/build.gradle.kts.
|
||||
|
||||
Warp is opaque, weft laid over it at half opacity, which is what gives the
|
||||
blended squares rather than a flat grid. Paths run well past the viewport
|
||||
because the group is rotated 45 degrees; the overspill keeps the corners
|
||||
covered. One full repeat fills the adaptive icon's safe zone.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="@color/brand_field"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="@color/brand_accent"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" />
|
||||
<group
|
||||
android:rotation="45"
|
||||
android:pivotX="54.0"
|
||||
android:pivotY="54.0">
|
||||
<path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,22 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The legacy (pre-API 26) launcher icon: the same brand emblem as the adaptive
|
||||
icon's foreground, over the same brand field, in the one layer those
|
||||
launchers get.
|
||||
A single square of the Wallace tartan, set on the bias.
|
||||
|
||||
Field first, emblem second: two paths, because the emblem's ring relies on
|
||||
even-odd parity and a filled background inside the same path would flip it.
|
||||
Generated from the published threadcount K/4 R32 K32 Y/4 — the same numbers
|
||||
as WallaceSett in Tartan.kt. The slashes are pivots, so the half-sett
|
||||
reflects out to K4 R32 K32 Y4 K32 R32: the yellow overstripe runs down the
|
||||
centre of a wide black band, and the narrow black guard splits the red into
|
||||
pairs.
|
||||
|
||||
The stripes reference @color/tartan_field rather than a literal, because the
|
||||
same threadcount is woven in two colourways: Wallace red on a production
|
||||
build, Wallace Hunting green on a dev one, so the two installs are told apart
|
||||
in the app drawer by the icon alone. The build generates that colour; see
|
||||
resValue in app/build.gradle.kts.
|
||||
|
||||
Warp is opaque, weft laid over it at half opacity, which is what gives the
|
||||
blended squares rather than a flat grid. Paths run well past the viewport
|
||||
because the group is rotated 45 degrees; the overspill keeps the corners
|
||||
covered. One full repeat fills the adaptive icon's safe zone.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="@color/brand_field"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="@color/brand_accent"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M84,54 L69,79.98 L39,79.98 L24,54 L39,28.02 L69,28.02 Z M72,54 L63,69.59 L45,69.59 L36,54 L45,38.41 L63,38.41 Z M63,54 L58.5,61.79 L49.5,61.79 L45,54 L49.5,46.21 L58.5,46.21 Z" />
|
||||
<group
|
||||
android:rotation="45"
|
||||
android:pivotX="54.0"
|
||||
android:pivotY="54.0">
|
||||
<path android:fillColor="@color/tartan_ground" android:pathData="M-90.00,-90.00h288.00v288.00h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-88.94,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-69.88,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M-36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M-16.94,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M2.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M36.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M55.06,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M74.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M108.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M127.06,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_field" android:pathData="M146.12,-90.00h16.94v288.00h-16.94z" />
|
||||
<path android:fillColor="@color/tartan_over" android:pathData="M180.00,-90.00h2.12v288.00h-2.12z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-105.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-88.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-72.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-69.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-52.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,-36.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,-33.88h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,-16.94h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,0.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,2.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,19.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,36.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,38.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,55.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,72.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,74.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,91.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,108.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,110.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,127.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,144.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_field" android:fillAlpha="0.5" android:pathData="M-90.00,146.12h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,163.06h288.00v16.94h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_over" android:fillAlpha="0.5" android:pathData="M-90.00,180.00h288.00v2.12h-288.00z" />
|
||||
<path android:fillColor="@color/tartan_ground" android:fillAlpha="0.5" android:pathData="M-90.00,182.12h288.00v16.94h-288.00z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The colours the brand shares between channels. The two that differ —
|
||||
brand_field (slate blue on production, deep green on dev) and its darker
|
||||
shade splash_background — are generated by the build. See resValue in
|
||||
app/build.gradle.kts.
|
||||
The colours the sett shares between channels. The one that differs —
|
||||
tartan_field, red on production and hunting green on dev — is generated by the
|
||||
build, along with splash_background and ic_launcher_background, which are
|
||||
shades of it. See resValue in app/build.gradle.kts.
|
||||
-->
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
|
||||
<!-- The parchment accent drawn on the brand field: the emblem on the
|
||||
launcher icon and the second line of the splash. Same number as the
|
||||
accent in Theme.kt. -->
|
||||
<color name="brand_accent">#EFD9A4</color>
|
||||
<!-- The ground the sett is woven on, and the overstripe down its centre.
|
||||
Same numbers as WallaceBlack and WallaceGold in Tartan.kt. -->
|
||||
<color name="tartan_ground">#0C0C0C</color>
|
||||
<color name="tartan_over">#F2C518</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
app_name is generated by the build rather than declared here, because it
|
||||
differs by channel: a dev build installs as "Pilgrims of Katan DEV"
|
||||
differs by channel: a dev build installs as "Pro Libertate Games DEV"
|
||||
alongside the production copy. See resValue in app/build.gradle.kts.
|
||||
-->
|
||||
<resources>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<resources>
|
||||
<!-- Compose supplies the real theme; this only needs to be a Material 3
|
||||
base with no action bar so the app draws its own top bars. -->
|
||||
<style name="Theme.PilgrimsOfKatan" parent="android:Theme.Material.NoActionBar">
|
||||
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
|
||||
@@ -106,17 +106,19 @@ class CatalogTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every game in the catalogue can be played`() {
|
||||
// There is nothing left marked "coming soon". The machinery that hides
|
||||
// unfinished games from a production build is still there and still
|
||||
// tested above — it simply has nothing to hide at the moment, and this
|
||||
// is what will fail the day something is added to the list before its
|
||||
// engine is written.
|
||||
assertTrue(GameCatalog.all.all { it.available })
|
||||
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(
|
||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
|
||||
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
class CatanAiTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/** Plays one table of engines out to a result, refusing every illegal move. */
|
||||
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 4_000): CatanState {
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(options, seed))
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("seed $seed ran past $cap moves", moves++ < cap)
|
||||
val seat = CatanRules.currentSeat(state) ?: break
|
||||
val move = ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat))
|
||||
assertTrue(
|
||||
"seed $seed: seat $seat chose the illegal move $move",
|
||||
CatanRules.isLegalMove(state, seat, move),
|
||||
)
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three engines finish a game at ten points`() {
|
||||
listOf(11L, 22L, 33L).forEach { seed ->
|
||||
val state = playOut(CatanOptions(), seed)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
val winner = state.winner
|
||||
assertTrue("seed $seed finished without a winner", winner != null)
|
||||
assertTrue(
|
||||
"seed $seed: the winner fell short of the target",
|
||||
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four engines finish a game at ten points`() {
|
||||
listOf(44L, 55L).forEach { seed ->
|
||||
val state = playOut(CatanOptions(playerCount = 4), seed)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
val winner = state.winner
|
||||
assertTrue("seed $seed finished without a winner", winner != null)
|
||||
assertTrue(
|
||||
"seed $seed: the winner fell short of the target",
|
||||
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A mid-game state on seat 0's turn, one card short of a settlement. */
|
||||
private fun offeringState(): CatanState =
|
||||
CatanRules.initialState(config(CatanOptions(), seed = 7L))
|
||||
.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
turn = 0,
|
||||
turnNumber = 1,
|
||||
// Lumber to spare, brick missing, and a neighbour visibly
|
||||
// holding brick. No roads or buildings, so no build is legal
|
||||
// and no port is open — the offer is the only move forward.
|
||||
resources = listOf(
|
||||
hand(Resource.LUMBER to 4, Resource.WOOL to 1, Resource.GRAIN to 1),
|
||||
hand(Resource.BRICK to 2),
|
||||
hand(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||
|
||||
@Test
|
||||
fun `the AI offers the trade that closes its build`() {
|
||||
val state = offeringState()
|
||||
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
|
||||
assertEquals(
|
||||
OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)),
|
||||
move,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the AI offers at most one trade a turn`() {
|
||||
// The same table, but the offer was already made and refused once.
|
||||
val state = offeringState().copy(
|
||||
log = listOf(
|
||||
"Seat 0 rolls 3 + 2.",
|
||||
"Seat 0 offers 2 lumber for a brick to seat 1.",
|
||||
"Seat 1 declines the offer.",
|
||||
),
|
||||
)
|
||||
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
|
||||
assertTrue("the AI re-offered within one turn: $move", move !is OfferTrade)
|
||||
assertEquals(EndTurn, move)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the AI answers a pending offer with accept or decline`() {
|
||||
val state = CatanRules.initialState(config(CatanOptions(), seed = 7L))
|
||||
.copy(
|
||||
phase = CatanPhase.TRADE_PENDING,
|
||||
turn = 0,
|
||||
turnNumber = 1,
|
||||
pendingTrade = TradeOffer(
|
||||
by = 0, to = 1,
|
||||
give = listOf(Resource.LUMBER, Resource.LUMBER),
|
||||
get = listOf(Resource.BRICK),
|
||||
),
|
||||
resources = listOf(
|
||||
hand(Resource.LUMBER to 3),
|
||||
hand(Resource.BRICK to 2, Resource.WOOL to 1),
|
||||
hand(),
|
||||
),
|
||||
)
|
||||
val legal = CatanRules.legalMoves(state, 1)
|
||||
val move = CatanAi().chooseMove(state, 1, legal)
|
||||
assertTrue("neither accept nor decline: $move", move == AcceptTrade || move == DeclineTrade)
|
||||
assertTrue(move in legal)
|
||||
|
||||
val after = CatanRules.applyMove(state, 1, move)
|
||||
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||
assertNull(after.pendingTrade)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CatanBoardTest {
|
||||
|
||||
// -- Geometry -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the island has 19 hexes, 54 vertices, 72 edges and 30 coastal edges`() {
|
||||
assertEquals(19, CatanBoard.HEX_COUNT)
|
||||
assertEquals(54, CatanBoard.VERTEX_COUNT)
|
||||
assertEquals(72, CatanBoard.EDGE_COUNT)
|
||||
assertEquals("the coast is one ring of 30 edges", 30, CatanBoard.COASTAL.size)
|
||||
assertEquals("nine harbour positions", 9, CatanBoard.PORT_POSITIONS.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every hex has six distinct corners and every edge two distinct ends`() {
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
assertEquals("hex $h", 6, CatanBoard.HEX_CORNERS[h].size)
|
||||
assertEquals("hex $h repeats a corner", 6, CatanBoard.HEX_CORNERS[h].distinct().size)
|
||||
}
|
||||
CatanBoard.EDGE_VERTICES.forEach { (a, b) -> assertTrue("edge loops on itself", a != b) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two vertices never share a lattice position`() {
|
||||
assertEquals(CatanBoard.VERTEX_COUNT, CatanBoard.VERTEX_POS.distinct().size)
|
||||
assertEquals(CatanBoard.HEX_COUNT, (0 until CatanBoard.HEX_COUNT)
|
||||
.map { CatanBoard.hexCenter(it) }
|
||||
.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adjacent hexes share exactly two vertices and one edge`() {
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
for (n in CatanBoard.HEX_NEIGHBORS[h]) {
|
||||
val shared = CatanBoard.HEX_CORNERS[h].toSet()
|
||||
.intersect(CatanBoard.HEX_CORNERS[n].toSet())
|
||||
assertEquals("hexes $h and $n", 2, shared.size)
|
||||
val sharedEdge = CatanBoard.EDGE_HEXES.indices.count { e ->
|
||||
CatanBoard.EDGE_HEXES[e].containsAll(listOf(h, n))
|
||||
}
|
||||
assertEquals("hexes $h and $n", 1, sharedEdge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertices have two or three edges, each to a distinct neighbour`() {
|
||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||
assertTrue("vertex $v", CatanBoard.VERTEX_EDGES[v].size in 2..3)
|
||||
assertEquals("vertex $v", CatanBoard.VERTEX_EDGES[v].size, CatanBoard.VERTEX_NEIGHBORS[v].size)
|
||||
CatanBoard.VERTEX_NEIGHBORS[v].forEach { n ->
|
||||
assertTrue(
|
||||
"vertices $v and $n are not joined by an edge",
|
||||
CatanBoard.VERTEX_EDGES[v].any { CatanBoard.otherEnd(it, v) == n },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interior edges touch two hexes and coastal edges one`() {
|
||||
CatanBoard.EDGE_HEXES.forEach { hexes -> assertTrue(hexes.size in 1..2) }
|
||||
// Nineteen hexes times six edges each, counted per side: 114.
|
||||
assertEquals(114, CatanBoard.EDGE_HEXES.sumOf { it.size })
|
||||
val coastal = CatanBoard.EDGE_HEXES.indices.filter { CatanBoard.EDGE_HEXES[it].size == 1 }
|
||||
assertEquals(CatanBoard.COASTAL.toSet(), coastal.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the coast is one closed ring and the harbours are spread along it`() {
|
||||
CatanBoard.COASTAL.forEachIndexed { i, e ->
|
||||
val next = CatanBoard.COASTAL[(i + 1) % CatanBoard.COASTAL.size]
|
||||
val shared = ends(e).intersect(ends(next))
|
||||
assertTrue("coastal edges $e and $next are not consecutive", shared.isNotEmpty())
|
||||
}
|
||||
|
||||
assertEquals(9, CatanBoard.PORT_POSITIONS.distinct().size)
|
||||
assertTrue(CatanBoard.PORT_POSITIONS.all { it in CatanBoard.COASTAL })
|
||||
val ringSpots = CatanBoard.PORT_POSITIONS.map { CatanBoard.COASTAL.indexOf(it) }.sorted()
|
||||
val gaps = ringSpots.zipWithNext { a, b -> b - a } +
|
||||
(CatanBoard.COASTAL.size - ringSpots.last() + ringSpots.first())
|
||||
assertTrue("harbours sit closer than three edges apart: $gaps", gaps.all { it >= 3 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token odds follow the dice`() {
|
||||
assertEquals(5, CatanBoard.numberOdds(6))
|
||||
assertEquals(5, CatanBoard.numberOdds(8))
|
||||
assertEquals(1, CatanBoard.numberOdds(2))
|
||||
assertEquals(1, CatanBoard.numberOdds(12))
|
||||
assertEquals(36, (2..12).sumOf { CatanBoard.numberOdds(it) })
|
||||
}
|
||||
|
||||
// -- Island generation --------------------------------------------------
|
||||
|
||||
private val terrainCounts = mapOf(
|
||||
Terrain.FOREST to 4,
|
||||
Terrain.PASTURE to 4,
|
||||
Terrain.FIELDS to 4,
|
||||
Terrain.HILLS to 3,
|
||||
Terrain.MOUNTAINS to 3,
|
||||
Terrain.DESERT to 1,
|
||||
)
|
||||
|
||||
/** The 18 number tokens dealt to the land, the seven withheld. */
|
||||
private val tokens = listOf(6, 6, 8, 8, 2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
|
||||
|
||||
@Test
|
||||
fun `every seed deals the fixed terrain mix`() {
|
||||
for (seed in 1L..500L) {
|
||||
val island = CatanBoard.generate(seed)
|
||||
assertEquals("seed $seed", 19, island.terrains.size)
|
||||
terrainCounts.forEach { (terrain, n) ->
|
||||
assertEquals("seed $seed $terrain", n, island.terrains.count { it == terrain })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every seed deals the full token set and the desert stays bare`() {
|
||||
for (seed in 1L..500L) {
|
||||
val island = CatanBoard.generate(seed)
|
||||
val dealt = island.numbers.filter { it > 0 }
|
||||
assertEquals("seed $seed", 18, dealt.size)
|
||||
assertEquals("seed $seed", tokens.sorted(), dealt.sorted())
|
||||
assertEquals("seed $seed: the desert bears no token", 0, island.numbers[island.robber])
|
||||
assertEquals(
|
||||
"seed $seed: the robber does not start on the desert",
|
||||
island.terrains.indexOf(Terrain.DESERT),
|
||||
island.robber,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no seed puts two red tokens on adjacent hexes`() {
|
||||
for (seed in 1L..500L) {
|
||||
val island = CatanBoard.generate(seed)
|
||||
val red = island.numbers.withIndex()
|
||||
.filter { it.value == 6 || it.value == 8 }
|
||||
.map { it.index }
|
||||
assertEquals("seed $seed", 4, red.size)
|
||||
for (i in red.indices) {
|
||||
for (j in i + 1 until red.size) {
|
||||
assertTrue(
|
||||
"seed $seed: red hexes ${red[i]} and ${red[j]} touch",
|
||||
!CatanBoard.hexesAdjacent(red[i], red[j]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every seed spreads nine harbours, five special and four generic`() {
|
||||
for (seed in 1L..500L) {
|
||||
val island = CatanBoard.generate(seed)
|
||||
assertEquals("seed $seed", 9, island.ports.size)
|
||||
assertEquals("seed $seed", 5, island.ports.count { it.resource != null })
|
||||
assertEquals("seed $seed", 4, island.ports.count { it.resource == null })
|
||||
assertTrue("seed $seed", island.ports.all { port ->
|
||||
port.ratio == if (port.resource == null) 3 else 2
|
||||
})
|
||||
assertEquals(
|
||||
"seed $seed: every resource has its own harbour",
|
||||
Resource.entries,
|
||||
island.ports.mapNotNull { it.resource }.sorted(),
|
||||
)
|
||||
assertEquals("seed $seed", 9, island.ports.map { it.edge }.distinct().size)
|
||||
assertTrue("seed $seed", island.ports.all { it.edge in CatanBoard.COASTAL })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same seed builds the same island`() {
|
||||
for (seed in listOf(0L, 7L, 42L, 99L, -1L)) {
|
||||
assertEquals("seed $seed", CatanBoard.generate(seed), CatanBoard.generate(seed))
|
||||
}
|
||||
}
|
||||
|
||||
/** The two vertices of [edge], as a set. */
|
||||
private fun ends(edge: Int): Set<Int> {
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||
return setOf(a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
class CatanRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(
|
||||
options: CatanOptions = CatanOptions(),
|
||||
seed: Long = 7L,
|
||||
) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/**
|
||||
* A mid-game state on some seat's turn, built from a real island so the
|
||||
* terrain, tokens, robber and harbours are a genuine layout. What a test
|
||||
* bends with [adjust] is its own business; the moves it then asks about
|
||||
* are checked against the rules like any other.
|
||||
*/
|
||||
private fun craft(
|
||||
options: CatanOptions = CatanOptions(),
|
||||
seed: Long = 7L,
|
||||
turn: Int = 0,
|
||||
adjust: CatanState.() -> CatanState = { this },
|
||||
): CatanState = CatanRules.initialState(config(options, seed))
|
||||
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
|
||||
.adjust()
|
||||
|
||||
private fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
|
||||
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
|
||||
|
||||
private fun hands(vararg perSeat: Map<Resource, Int>, players: Int = 3): List<Map<Resource, Int>> =
|
||||
(0 until players).map { perSeat.getOrNull(it) ?: hand() }
|
||||
|
||||
private fun CatanState.withBuilding(
|
||||
vertex: Int,
|
||||
type: BuildingType,
|
||||
owner: Int,
|
||||
): CatanState = copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
|
||||
|
||||
private fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
|
||||
copy(roads = roads.toMutableList().also { it[edge] = owner })
|
||||
|
||||
/** A roll whose dice add up to [total]. */
|
||||
private fun rollFor(total: Int): RollDice {
|
||||
val first = (total - 1).coerceAtMost(6)
|
||||
return RollDice(forced = listOf(first, total - first))
|
||||
}
|
||||
|
||||
/** A producing hex corner whose other hexes bear a different number. */
|
||||
private data class Spot(val resource: Resource, val number: Int, val vertex: Int)
|
||||
|
||||
private fun CatanState.pickSpot(): Spot {
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
val resource = terrains[h].produces ?: continue
|
||||
val number = numbers[h]
|
||||
val vertex = CatanBoard.HEX_CORNERS[h].firstOrNull { corner ->
|
||||
CatanBoard.VERTEX_HEXES[corner].all { it == h || numbers[it] != number }
|
||||
}
|
||||
if (vertex != null) return Spot(resource, number, vertex)
|
||||
}
|
||||
error("no clean producing corner on this island")
|
||||
}
|
||||
|
||||
/** The six edges of hex [h], in walking order around it. */
|
||||
private fun hexRing(h: Int): List<Int> {
|
||||
val corners = CatanBoard.HEX_CORNERS[h]
|
||||
return (0..5).map { k ->
|
||||
val a = corners[k]
|
||||
val b = corners[(k + 1) % 6]
|
||||
CatanBoard.EDGE_VERTICES.indices.first { e ->
|
||||
val (x, y) = CatanBoard.EDGE_VERTICES[e]
|
||||
(x == a && y == b) || (x == b && y == a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sharedVertex(a: Int, b: Int): Int =
|
||||
ends(a).intersect(ends(b)).single()
|
||||
|
||||
/** The two vertices of [edge], as a set. */
|
||||
private fun ends(edge: Int): Set<Int> {
|
||||
val (x, y) = CatanBoard.EDGE_VERTICES[edge]
|
||||
return setOf(x, y)
|
||||
}
|
||||
|
||||
/** [count] vertices at least two edges apart, for standing buildings on. */
|
||||
private fun scattered(count: Int): List<Int> {
|
||||
val picked = mutableListOf<Int>()
|
||||
var v = 0
|
||||
while (picked.size < count) {
|
||||
if (CatanBoard.VERTEX_NEIGHBORS[v].none { it in picked }) picked += v
|
||||
v++
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
// -- Setup ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `setup runs in snake order and then seat zero leads`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
val order = mutableListOf<Int>()
|
||||
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||
val seat = CatanRules.currentSeat(state)!!
|
||||
order += seat
|
||||
state = CatanRules.applyMove(
|
||||
state, seat,
|
||||
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
|
||||
)
|
||||
}
|
||||
assertEquals(listOf(0, 1, 2, 2, 1, 0), order)
|
||||
assertEquals(CatanPhase.AWAIT_ROLL, state.phase)
|
||||
assertEquals(0, state.turn)
|
||||
assertEquals(1, state.turnNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the second settlement earns one card from each adjacent land`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||
val seat = CatanRules.currentSeat(state)!!
|
||||
val move = CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first()
|
||||
val second = state.phase == CatanPhase.SETUP_SECOND
|
||||
val before = state.resources[seat]
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
if (second) {
|
||||
val expected = CatanBoard.VERTEX_HEXES[move.vertex]
|
||||
.mapNotNull { state.terrains[it].produces }
|
||||
.groupingBy { it }.eachCount()
|
||||
.let { earned ->
|
||||
before.toMutableMap().also { hand ->
|
||||
earned.forEach { (res, n) -> hand[res] = (hand[res] ?: 0) + n }
|
||||
}
|
||||
}
|
||||
assertEquals("seat $seat's second income", expected, state.resources[seat])
|
||||
} else {
|
||||
assertEquals("no income during the first pass", before, state.resources[seat])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup leaves every settlement two edges from its neighbour`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||
val seat = CatanRules.currentSeat(state)!!
|
||||
state = CatanRules.applyMove(
|
||||
state, seat,
|
||||
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
|
||||
)
|
||||
}
|
||||
val placed = state.buildings.withIndex().filter { it.value != null }.map { it.index }
|
||||
assertEquals(6, placed.size)
|
||||
placed.forEach { v ->
|
||||
assertTrue(
|
||||
"a settlement stands beside $v",
|
||||
CatanBoard.VERTEX_NEIGHBORS[v].none { state.buildings[it] != null },
|
||||
)
|
||||
}
|
||||
// Every road laid in setup touches the settlement it was laid with.
|
||||
state.roads.withIndex().filter { it.value != NO_PLAYER }.forEach { (edge, _) ->
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||
assertTrue(
|
||||
"road $edge touches no settlement",
|
||||
state.buildings[a] != null || state.buildings[b] != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the island seats three or four`() {
|
||||
assertThrows { CatanOptions(playerCount = 2) }
|
||||
assertThrows { CatanOptions(playerCount = 5) }
|
||||
val fourSeats = config(CatanOptions(playerCount = 3))
|
||||
.let { it.copy(seats = it.seats.drop(1)) }
|
||||
assertThrows { CatanRules.initialState(fourSeats) }
|
||||
}
|
||||
|
||||
// -- Production ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a roll pays settlements singly and cities double`() {
|
||||
val base = craft()
|
||||
val spot = base.pickSpot()
|
||||
|
||||
val settled = base.withBuilding(spot.vertex, BuildingType.SETTLEMENT, 0)
|
||||
.copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand()))
|
||||
val afterSettlement = CatanRules.applyMove(settled, 0, rollFor(spot.number))
|
||||
assertEquals(1, afterSettlement.resources[0][spot.resource])
|
||||
assertEquals(CatanPhase.PLAYING, afterSettlement.phase)
|
||||
|
||||
val raised = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
|
||||
.copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand()))
|
||||
val afterCity = CatanRules.applyMove(raised, 0, rollFor(spot.number))
|
||||
assertEquals(2, afterCity.resources[0][spot.resource])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the robber's hex produces nothing`() {
|
||||
val base = craft()
|
||||
val spot = base.pickSpot()
|
||||
val hex = CatanBoard.VERTEX_HEXES[spot.vertex].first { base.numbers[it] == spot.number }
|
||||
val state = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
|
||||
.copy(phase = CatanPhase.AWAIT_ROLL, robber = hex, resources = hands(hand()))
|
||||
val after = CatanRules.applyMove(state, 0, rollFor(spot.number))
|
||||
assertEquals("the blocked city takes nothing", 0, after.resources[0][spot.resource])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bank-short resource pays nobody`() {
|
||||
val base = craft()
|
||||
val spot = base.pickSpot()
|
||||
// A city wants two; the bank holds one, because seat 1 hoards the rest.
|
||||
val state = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
|
||||
.copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand(), hand(spot.resource to 18)))
|
||||
val after = CatanRules.applyMove(state, 0, rollFor(spot.number))
|
||||
assertEquals("the city takes nothing while the bank is short", 0, after.resources[0][spot.resource])
|
||||
assertEquals(18, after.resources[1][spot.resource])
|
||||
}
|
||||
|
||||
// -- The robber ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seven with no overfull hand rides the robber directly`() {
|
||||
val state = craft().copy(
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
resources = hands(hand(), hand(Resource.LUMBER to 7), hand(Resource.BRICK to 7)),
|
||||
)
|
||||
val after = CatanRules.applyMove(state, 0, RollDice(forced = listOf(6, 1)))
|
||||
assertEquals(CatanPhase.ROBBER, after.phase)
|
||||
assertNull(after.discardSeat)
|
||||
assertTrue(after.log.any { it.startsWith("Seat 0 rolls 7") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seven makes overfull hands discard half in seat order`() {
|
||||
var state = craft().copy(
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
resources = hands(
|
||||
hand(),
|
||||
hand(Resource.LUMBER to 2, Resource.BRICK to 3, Resource.WOOL to 4), // 9 cards
|
||||
hand(Resource.GRAIN to 8), // 8 cards
|
||||
),
|
||||
)
|
||||
state = CatanRules.applyMove(state, 0, RollDice(forced = listOf(3, 4)))
|
||||
assertEquals(CatanPhase.DISCARDING, state.phase)
|
||||
assertEquals(1, state.discardSeat)
|
||||
assertEquals(1, CatanRules.currentSeat(state))
|
||||
|
||||
// Nine cards means four gone, exactly, and no other number of them.
|
||||
assertTrue(CatanRules.isLegalMove(state, 1, Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.BRICK))))
|
||||
assertFalse(CatanRules.isLegalMove(state, 1, Discard(listOf(Resource.LUMBER))))
|
||||
assertFalse(CatanRules.isLegalMove(state, 1, Discard(List(5) { Resource.WOOL })))
|
||||
state = CatanRules.applyMove(
|
||||
state, 1,
|
||||
Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.BRICK)),
|
||||
)
|
||||
assertEquals("seat 2 still owes the robber", CatanPhase.DISCARDING, state.phase)
|
||||
assertEquals(2, state.discardSeat)
|
||||
|
||||
state = CatanRules.applyMove(state, 2, Discard(List(4) { Resource.GRAIN }))
|
||||
assertEquals(CatanPhase.ROBBER, state.phase)
|
||||
assertEquals("the roller moves the robber", 0, CatanRules.currentSeat(state))
|
||||
assertEquals(5, state.resourceCount(1))
|
||||
assertEquals(4, state.resourceCount(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the robber must move and steals one card from a built-up hex`() {
|
||||
val base = craft()
|
||||
val spot = base.pickSpot()
|
||||
val hex = CatanBoard.VERTEX_HEXES[spot.vertex].first { base.numbers[it] == spot.number }
|
||||
val elsewhere = (0 until CatanBoard.HEX_COUNT)
|
||||
.first { it != base.robber && it != hex }
|
||||
val state = base.withBuilding(spot.vertex, BuildingType.SETTLEMENT, 1)
|
||||
.copy(
|
||||
phase = CatanPhase.ROBBER,
|
||||
robber = elsewhere,
|
||||
resources = hands(hand(), hand(Resource.WOOL to 3)),
|
||||
)
|
||||
|
||||
val moves = CatanRules.legalMoves(state, 0).filterIsInstance<MoveRobber>()
|
||||
assertTrue(
|
||||
"the robber may not stay put",
|
||||
moves.all { it.hex != CatanBoard.HEXES[state.robber] },
|
||||
)
|
||||
assertEquals("every other hex is reachable", 18, moves.map { it.hex }.distinct().size)
|
||||
|
||||
val steal = moves.first { it.hex == CatanBoard.HEXES[hex] && it.victim == 1 }
|
||||
val after = CatanRules.applyMove(state, 0, steal)
|
||||
assertEquals(hex, after.robber)
|
||||
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||
assertEquals(2, after.resources[1][Resource.WOOL])
|
||||
assertEquals(1, after.resourceCount(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a forced roll must be two honest dice, and only tests may send it`() {
|
||||
val state = craft().copy(phase = CatanPhase.AWAIT_ROLL)
|
||||
assertTrue(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(3, 4))))
|
||||
assertFalse(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(7, 0))))
|
||||
assertFalse(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(3))))
|
||||
// The derived roll is the enumerated move; a forced one is structural only.
|
||||
assertTrue(RollDice() in CatanRules.legalMoves(state, 0))
|
||||
assertFalse(RollDice(forced = listOf(3, 4)) in CatanRules.legalMoves(state, 0))
|
||||
}
|
||||
|
||||
// -- Building ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a road must attach to the builder's own network`() {
|
||||
val state = craft().copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
|
||||
assertTrue("nothing to attach to", CatanRules.legalMoves(state, 0).none { it is BuildRoad })
|
||||
|
||||
val e0 = CatanBoard.VERTEX_EDGES[0][0]
|
||||
val attached = state.withRoad(e0, 0)
|
||||
val roads = CatanRules.legalMoves(attached, 0).filterIsInstance<BuildRoad>()
|
||||
assertEquals(
|
||||
"roads may only fan out from the network",
|
||||
CatanBoard.EDGE_NEIGHBORS[e0].map { BuildRoad(it) }.toSet(),
|
||||
roads.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settlement needs an open vertex on the network, a city one of yours`() {
|
||||
val base = craft()
|
||||
val homes = scattered(3)
|
||||
val state = base.withBuilding(homes[0], BuildingType.SETTLEMENT, 0)
|
||||
.withRoad(CatanBoard.VERTEX_EDGES[homes[1]][0], 0)
|
||||
.copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 2, Resource.ORE to 3)))
|
||||
|
||||
// Reachable: open, two edges from anything standing, on seat 0's road.
|
||||
assertTrue(BuildSettlement(homes[1]) in CatanRules.legalMoves(state, 0))
|
||||
// One edge from a standing settlement is too close, network or not.
|
||||
val tooClose = CatanBoard.VERTEX_NEIGHBORS[homes[0]].first()
|
||||
assertTrue(BuildSettlement(tooClose) !in CatanRules.legalMoves(state, 0))
|
||||
// Open but off the network is out of reach.
|
||||
assertTrue(BuildSettlement(homes[2]) !in CatanRules.legalMoves(state, 0))
|
||||
|
||||
// Raising costs two grain and three ore.
|
||||
val raised = CatanRules.applyMove(state, 0, BuildCity(homes[0]))
|
||||
assertEquals(BuildingType.CITY, raised.buildings[homes[0]]?.type)
|
||||
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 0, Resource.ORE to 0), raised.resources[0])
|
||||
}
|
||||
|
||||
// -- Trade ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a domestic offer names any two multisets and waits on the table`() {
|
||||
val state = craft().copy(
|
||||
resources = hands(
|
||||
hand(Resource.LUMBER to 3),
|
||||
hand(Resource.BRICK to 2),
|
||||
hand(),
|
||||
),
|
||||
)
|
||||
val offer = OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK))
|
||||
assertTrue(CatanRules.isLegalMove(state, 0, offer))
|
||||
assertFalse("not the addressee's turn", CatanRules.isLegalMove(state, 1, offer))
|
||||
assertFalse("never to oneself", CatanRules.isLegalMove(state, 0, offer.copy(to = 0)))
|
||||
assertFalse(
|
||||
"like for like is not a trade",
|
||||
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.LUMBER, Resource.BRICK), listOf(Resource.BRICK, Resource.LUMBER))),
|
||||
)
|
||||
assertFalse(
|
||||
"an empty leg is not a trade",
|
||||
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.LUMBER), emptyList())),
|
||||
)
|
||||
assertFalse(
|
||||
"cards the offerer does not hold cannot be promised",
|
||||
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.ORE), listOf(Resource.BRICK))),
|
||||
)
|
||||
|
||||
val table = CatanRules.applyMove(state, 0, offer)
|
||||
assertEquals(CatanPhase.TRADE_PENDING, table.phase)
|
||||
assertEquals(1, CatanRules.currentSeat(table))
|
||||
assertEquals(TradeOffer(by = 0, to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)), table.pendingTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accepted offer crosses the cards and a declined one clears the table`() {
|
||||
val offered = craft().copy(
|
||||
phase = CatanPhase.TRADE_PENDING,
|
||||
pendingTrade = TradeOffer(
|
||||
by = 0, to = 1,
|
||||
give = listOf(Resource.LUMBER, Resource.LUMBER),
|
||||
get = listOf(Resource.BRICK),
|
||||
),
|
||||
resources = hands(
|
||||
hand(Resource.LUMBER to 3),
|
||||
hand(Resource.BRICK to 2),
|
||||
hand(),
|
||||
),
|
||||
)
|
||||
val accepted = CatanRules.applyMove(offered, 1, AcceptTrade)
|
||||
assertEquals(CatanPhase.PLAYING, accepted.phase)
|
||||
assertNull(accepted.pendingTrade)
|
||||
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), accepted.resources[0])
|
||||
assertEquals(hand(Resource.LUMBER to 2, Resource.BRICK to 1), accepted.resources[1])
|
||||
|
||||
val declined = CatanRules.applyMove(offered, 1, DeclineTrade)
|
||||
assertEquals(CatanPhase.PLAYING, declined.phase)
|
||||
assertNull(declined.pendingTrade)
|
||||
assertEquals("a refusal leaves the cards where they were", offered.resources, declined.resources)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offer the addressee cannot honour can only be declined`() {
|
||||
val offered = craft().copy(
|
||||
phase = CatanPhase.TRADE_PENDING,
|
||||
pendingTrade = TradeOffer(
|
||||
by = 0, to = 1,
|
||||
give = listOf(Resource.LUMBER, Resource.LUMBER),
|
||||
get = listOf(Resource.BRICK),
|
||||
),
|
||||
resources = hands(hand(Resource.LUMBER to 3), hand(), hand()),
|
||||
)
|
||||
assertEquals(listOf<CatanMove>(DeclineTrade), CatanRules.legalMoves(offered, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bank trades four for one and harbours do better`() {
|
||||
val base = craft()
|
||||
val lumberPort = base.ports.first { it.resource == Resource.LUMBER }
|
||||
val genericPort = base.ports.first { it.resource == null }
|
||||
val portSide = { port: Port -> CatanBoard.EDGE_VERTICES[port.edge].first }
|
||||
|
||||
val plain = base.copy(resources = hands(hand(Resource.LUMBER to 4)))
|
||||
assertEquals(4, CatanRules.bestPortRatio(plain, 0, Resource.LUMBER))
|
||||
val viaBank = CatanRules.applyMove(plain, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
|
||||
assertEquals(hand(Resource.BRICK to 1), viaBank.resources[0])
|
||||
|
||||
val special = base.withBuilding(portSide(lumberPort), BuildingType.SETTLEMENT, 0)
|
||||
.copy(resources = hands(hand(Resource.LUMBER to 2)))
|
||||
assertEquals(2, CatanRules.bestPortRatio(special, 0, Resource.LUMBER))
|
||||
assertEquals(4, CatanRules.bestPortRatio(special, 0, Resource.BRICK))
|
||||
val viaPort = CatanRules.applyMove(special, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
|
||||
assertEquals(hand(Resource.BRICK to 1), viaPort.resources[0])
|
||||
|
||||
val generic = base.withBuilding(portSide(genericPort), BuildingType.SETTLEMENT, 0)
|
||||
.copy(resources = hands(hand(Resource.LUMBER to 3)))
|
||||
assertEquals(3, CatanRules.bestPortRatio(generic, 0, Resource.LUMBER))
|
||||
val viaGeneric = CatanRules.applyMove(generic, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
|
||||
assertEquals(hand(Resource.BRICK to 1), viaGeneric.resources[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bank trade needs the cards and a bank with something to give`() {
|
||||
val base = craft()
|
||||
assertFalse(
|
||||
"three is one short of the bank's four",
|
||||
CatanRules.isLegalMove(
|
||||
base.copy(resources = hands(hand(Resource.LUMBER to 3))),
|
||||
0,
|
||||
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
"the bank holds no brick to give",
|
||||
CatanRules.isLegalMove(
|
||||
base.copy(resources = hands(hand(Resource.LUMBER to 4), hand(Resource.BRICK to 19))),
|
||||
0,
|
||||
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
"no resource trades for itself",
|
||||
CatanRules.isLegalMove(
|
||||
base.copy(resources = hands(hand(Resource.LUMBER to 4))),
|
||||
0,
|
||||
MaritimeTrade(Resource.LUMBER, Resource.LUMBER),
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
CatanRules.isLegalMove(
|
||||
base.copy(resources = hands(hand(Resource.LUMBER to 4))),
|
||||
0,
|
||||
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Development cards ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `buying a card costs wool, grain and ore and hides it until next turn`() {
|
||||
val base = craft().copy(resources = hands(hand(Resource.WOOL to 1, Resource.GRAIN to 1, Resource.ORE to 1)))
|
||||
val top = base.devDeck.first()
|
||||
val state = CatanRules.applyMove(base, 0, BuyDevCard)
|
||||
assertEquals(hand(), state.resources[0])
|
||||
assertEquals(listOf(top.copy(boughtTurn = 1)), state.devHands[0])
|
||||
assertEquals(listOf(1, 0, 0), state.devHandCounts)
|
||||
assertEquals(base.devDeck.drop(1), state.devDeck)
|
||||
assertTrue("bought this turn, playable next", CatanRules.legalMoves(state, 0).none { it is PlayKnight })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a knight may not be played before the roll`() {
|
||||
val state = craft().copy(
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
devHands = listOf(listOf(DevCard(90, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(1, 0, 0),
|
||||
)
|
||||
assertEquals(listOf<CatanMove>(RollDice()), CatanRules.legalMoves(state, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a card bought on an earlier turn is playable, one per turn`() {
|
||||
val knight = DevCard(90, DevCardType.KNIGHT, 0)
|
||||
val roadBuilding = DevCard(91, DevCardType.ROAD_BUILDING, 0)
|
||||
val state = craft().copy(
|
||||
devHands = listOf(listOf(knight, roadBuilding), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(2, 0, 0),
|
||||
)
|
||||
val moves = CatanRules.legalMoves(state, 0)
|
||||
assertTrue(PlayKnight(90) in moves)
|
||||
assertTrue(PlayRoadBuilding(91) in moves)
|
||||
|
||||
val after = CatanRules.applyMove(state, 0, PlayKnight(90))
|
||||
assertEquals(CatanPhase.ROBBER, after.phase)
|
||||
assertEquals(1, after.knightsPlayed[0])
|
||||
assertEquals(listOf(roadBuilding), after.devHands[0])
|
||||
assertTrue(after.devCardPlayedThisTurn)
|
||||
|
||||
// The robber rides first, then the turn owner may build and trade —
|
||||
// but not play a second card.
|
||||
val robberMove = CatanRules.legalMoves(after, 0).filterIsInstance<MoveRobber>().first()
|
||||
val playing = CatanRules.applyMove(after, 0, robberMove)
|
||||
assertEquals(CatanPhase.PLAYING, playing.phase)
|
||||
assertTrue(
|
||||
"one development card a turn",
|
||||
CatanRules.legalMoves(playing, 0).none { it is PlayRoadBuilding },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three knights take Largest Army, and only a strictly larger army steals it`() {
|
||||
val firstToThree = craft().copy(
|
||||
devHands = listOf(listOf(DevCard(90, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(1, 0, 0),
|
||||
knightsPlayed = listOf(2, 0, 0),
|
||||
)
|
||||
val took = CatanRules.applyMove(firstToThree, 0, PlayKnight(90))
|
||||
assertEquals(0, took.largestArmyOwner)
|
||||
assertEquals(5, CatanRules.publicVictoryPoints(took)[0])
|
||||
|
||||
// A tie does not steal it.
|
||||
val tied = craft().copy(
|
||||
devHands = listOf(listOf(DevCard(91, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(1, 0, 0),
|
||||
knightsPlayed = listOf(2, 3, 0),
|
||||
largestArmyOwner = 1,
|
||||
)
|
||||
val kept = CatanRules.applyMove(tied, 0, PlayKnight(91))
|
||||
assertEquals("the incumbent keeps a tie", 1, kept.largestArmyOwner)
|
||||
|
||||
// A fourth knight does.
|
||||
val stronger = craft().copy(
|
||||
devHands = listOf(listOf(DevCard(92, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(1, 0, 0),
|
||||
knightsPlayed = listOf(3, 3, 0),
|
||||
largestArmyOwner = 1,
|
||||
)
|
||||
val stolen = CatanRules.applyMove(stronger, 0, PlayKnight(92))
|
||||
assertEquals(0, stolen.largestArmyOwner)
|
||||
assertEquals(5, CatanRules.publicVictoryPoints(stolen)[0])
|
||||
assertEquals(0, CatanRules.publicVictoryPoints(stolen)[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Road Building lays two free segments and may be forfeited`() {
|
||||
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
|
||||
val ring = hexRing(centre)
|
||||
val base = craft()
|
||||
.copy(
|
||||
resources = hands(hand(Resource.LUMBER to 9, Resource.BRICK to 9)),
|
||||
devHands = listOf(listOf(DevCard(93, DevCardType.ROAD_BUILDING, 0)), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(1, 0, 0),
|
||||
)
|
||||
.withRoad(ring[0], 0)
|
||||
|
||||
val opened = CatanRules.applyMove(base, 0, PlayRoadBuilding(93))
|
||||
assertEquals(CatanPhase.FREE_ROADS, opened.phase)
|
||||
assertEquals(2, opened.pendingFreeRoads)
|
||||
assertTrue(EndFreeRoads in CatanRules.legalMoves(opened, 0))
|
||||
|
||||
val first = CatanRules.applyMove(opened, 0, BuildRoad(ring[1]))
|
||||
assertEquals(CatanPhase.FREE_ROADS, first.phase)
|
||||
assertEquals(1, first.pendingFreeRoads)
|
||||
assertEquals("free roads cost nothing", base.resources[0], first.resources[0])
|
||||
|
||||
val forfeited = CatanRules.applyMove(first, 0, EndFreeRoads)
|
||||
assertEquals(CatanPhase.PLAYING, forfeited.phase)
|
||||
assertEquals(0, forfeited.pendingFreeRoads)
|
||||
assertEquals(base.resources[0], forfeited.resources[0])
|
||||
assertEquals(2, forfeited.roads.count { it == 0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Monopoly seizes every card of the named resource`() {
|
||||
val state = craft().copy(
|
||||
resources = hands(
|
||||
hand(Resource.WOOL to 2, Resource.GRAIN to 1),
|
||||
hand(Resource.WOOL to 3, Resource.BRICK to 1),
|
||||
hand(Resource.WOOL to 1),
|
||||
),
|
||||
devHands = listOf(listOf(DevCard(94, DevCardType.MONOPOLY, 0)), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(1, 0, 0),
|
||||
)
|
||||
val after = CatanRules.applyMove(state, 0, PlayMonopoly(94, Resource.WOOL))
|
||||
assertEquals(hand(Resource.WOOL to 6, Resource.GRAIN to 1), after.resources[0])
|
||||
assertEquals(hand(Resource.BRICK to 1), after.resources[1])
|
||||
assertEquals(hand(), after.resources[2])
|
||||
assertTrue(after.devHands[0].isEmpty())
|
||||
assertTrue(after.devCardPlayedThisTurn)
|
||||
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Year of Plenty takes two cards the bank can pay`() {
|
||||
val base = craft().copy(
|
||||
devHands = listOf(listOf(DevCard(95, DevCardType.YEAR_OF_PLENTY, 0)), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(1, 0, 0),
|
||||
)
|
||||
val took = CatanRules.applyMove(base, 0, PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.ORE)))
|
||||
assertEquals(hand(Resource.GRAIN to 1, Resource.ORE to 1), took.resources[0])
|
||||
|
||||
// The bank holds a single grain, seat 1 keeping the other eighteen:
|
||||
// two grain cannot be taken.
|
||||
val shortBank = base.copy(resources = hands(hand(), hand(Resource.GRAIN to 18), hand()))
|
||||
assertTrue(
|
||||
"two of a kind needs a bank that can pay both",
|
||||
PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.GRAIN)) !in CatanRules.legalMoves(shortBank, 0),
|
||||
)
|
||||
assertTrue(
|
||||
PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.ORE)) in CatanRules.legalMoves(shortBank, 0),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Longest Road --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a closed ring counts as a simple path, not as two loops`() {
|
||||
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
|
||||
val ring = hexRing(centre)
|
||||
var state = craft()
|
||||
ring.forEach { state = state.withRoad(it, 0) }
|
||||
assertEquals("six segments around one hex count five", 5, CatanRules.longestRoadFor(state, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five segments take Longest Road, a tie keeps it, a settlement breaks it`() {
|
||||
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
|
||||
val ring = hexRing(centre)
|
||||
// A challenger's path on a hex that touches neither the centre nor its ring.
|
||||
val far = (0 until CatanBoard.HEX_COUNT).first { h ->
|
||||
h != centre && CatanBoard.HEX_NEIGHBORS[h].none { it == centre }
|
||||
}.let { hexRing(it) }
|
||||
|
||||
var state = craft().copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
|
||||
ring.take(4).forEach { state = state.withRoad(it, 0) }
|
||||
state = CatanRules.applyMove(state, 0, BuildRoad(ring[4]))
|
||||
assertEquals("the fifth segment takes the award", 0, state.longestRoadOwner)
|
||||
assertEquals(5, CatanRules.longestRoadFor(state, 0))
|
||||
|
||||
// An equal five does not steal it.
|
||||
var challenger = state.copy(turn = 1, resources = hands(hand(), hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
|
||||
far.take(4).forEach { challenger = challenger.withRoad(it, 1) }
|
||||
challenger = CatanRules.applyMove(challenger, 1, BuildRoad(far[4]))
|
||||
assertEquals("the incumbent keeps a tie", 0, challenger.longestRoadOwner)
|
||||
assertEquals(5, CatanRules.longestRoadFor(challenger, 1))
|
||||
|
||||
// An opponent's settlement in the middle breaks the holder's road in
|
||||
// two, and the award falls to the challenger.
|
||||
val breakAt = sharedVertex(ring[2], ring[3])
|
||||
val spoke = CatanBoard.VERTEX_EDGES[breakAt].first { it != ring[2] && it != ring[3] }
|
||||
val breaker = challenger
|
||||
.copy(
|
||||
resources = hands(
|
||||
hand(),
|
||||
hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1),
|
||||
),
|
||||
)
|
||||
.withRoad(spoke, 1)
|
||||
val broken = CatanRules.applyMove(breaker, 1, BuildSettlement(breakAt))
|
||||
assertEquals("the broken road loses the award", 1, broken.longestRoadOwner)
|
||||
assertEquals(3, CatanRules.longestRoadFor(broken, 0))
|
||||
assertEquals(5, CatanRules.longestRoadFor(broken, 1))
|
||||
assertEquals(6, CatanRules.publicVictoryPoints(broken)[1])
|
||||
assertEquals(0, CatanRules.publicVictoryPoints(broken)[0])
|
||||
}
|
||||
|
||||
// -- Winning -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a tenth point on the builder's own move wins`() {
|
||||
val homes = scattered(7)
|
||||
var state = craft()
|
||||
.withBuilding(homes[0], BuildingType.CITY, 0)
|
||||
.withBuilding(homes[1], BuildingType.CITY, 0)
|
||||
homes.drop(2).forEach { state = state.withBuilding(it, BuildingType.SETTLEMENT, 0) }
|
||||
state = state.copy(resources = hands(hand(Resource.GRAIN to 2, Resource.ORE to 3)))
|
||||
|
||||
val won = CatanRules.applyMove(state, 0, BuildCity(homes[2]))
|
||||
assertEquals(CatanPhase.GAME_OVER, won.phase)
|
||||
assertEquals(0, won.winner)
|
||||
assertTrue(won.log.any { it.contains("Seat 0 reveals 10 victory points and wins") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden victory-point cards count and are revealed by winning`() {
|
||||
val homes = scattered(7)
|
||||
var state = craft()
|
||||
.withBuilding(homes[0], BuildingType.CITY, 0)
|
||||
.withBuilding(homes[1], BuildingType.CITY, 0)
|
||||
homes.drop(2).forEach { state = state.withBuilding(it, BuildingType.SETTLEMENT, 0) }
|
||||
state = state.copy(
|
||||
devHands = listOf(listOf(DevCard(96, DevCardType.VICTORY_POINT, 0)), emptyList(), emptyList()),
|
||||
devHandCounts = listOf(1, 0, 0),
|
||||
)
|
||||
|
||||
// Nine points stand in public, the tenth hides in hand: ending the
|
||||
// turn wins before the turn passes.
|
||||
val won = CatanRules.applyMove(state, 0, EndTurn)
|
||||
assertEquals(CatanPhase.GAME_OVER, won.phase)
|
||||
assertEquals(0, won.winner)
|
||||
assertTrue(won.log.any { it.contains("Seat 0 reveals 10 victory points and wins") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seat already at the target wins when its own turn arrives`() {
|
||||
val homes = scattered(5)
|
||||
// Seat 2 ends its turn, so seat 0 is next in the three-seat rotation.
|
||||
var state = craft(turn = 2)
|
||||
homes.forEach { state = state.withBuilding(it, BuildingType.CITY, 0) }
|
||||
|
||||
val won = CatanRules.applyMove(state, 2, EndTurn)
|
||||
assertEquals(CatanPhase.GAME_OVER, won.phase)
|
||||
assertEquals(0, won.winner)
|
||||
assertTrue("seat 0 never had to roll", won.log.none { it.startsWith("Seat 0 rolls") })
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides the deck and other hands but not the counts`() {
|
||||
val state = craft().copy(
|
||||
devDeck = listOf(
|
||||
DevCard(1, DevCardType.KNIGHT),
|
||||
DevCard(2, DevCardType.VICTORY_POINT),
|
||||
),
|
||||
devHands = listOf(
|
||||
listOf(DevCard(3, DevCardType.KNIGHT, 1)),
|
||||
listOf(DevCard(4, DevCardType.MONOPOLY, 1)),
|
||||
listOf(DevCard(5, DevCardType.YEAR_OF_PLENTY, 1)),
|
||||
),
|
||||
devHandCounts = listOf(1, 1, 1),
|
||||
)
|
||||
val view = CatanRules.viewFor(state, seat = 1)
|
||||
assertTrue("the deck order is hidden", view.devDeck.isEmpty())
|
||||
assertEquals(state.devHands[1], view.devHands[1])
|
||||
assertTrue(view.devHands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() })
|
||||
assertEquals(state.devHandCounts, view.devHandCounts)
|
||||
// Public information travels whole.
|
||||
assertEquals(state.resources, view.resources)
|
||||
assertEquals(state.buildings, view.buildings)
|
||||
assertEquals(state.log, view.log)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and every move survive a wire round trip`() {
|
||||
val homes = scattered(3)
|
||||
val state = craft(
|
||||
options = CatanOptions(playerCount = 4),
|
||||
seed = 99L,
|
||||
)
|
||||
.withBuilding(homes[0], BuildingType.CITY, 2)
|
||||
.withRoad(CatanBoard.VERTEX_EDGES[homes[1]][0], 3)
|
||||
.copy(
|
||||
resources = hands(
|
||||
hand(Resource.LUMBER to 2),
|
||||
hand(Resource.BRICK to 1),
|
||||
hand(Resource.WOOL to 1, Resource.GRAIN to 1),
|
||||
hand(Resource.ORE to 4),
|
||||
players = 4,
|
||||
),
|
||||
pendingTrade = TradeOffer(
|
||||
by = 0, to = 1,
|
||||
give = listOf(Resource.LUMBER),
|
||||
get = listOf(Resource.BRICK, Resource.WOOL),
|
||||
),
|
||||
)
|
||||
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||
|
||||
val moves = listOf<CatanMove>(
|
||||
SetupPlace(3, 5),
|
||||
RollDice(),
|
||||
RollDice(listOf(3, 4)),
|
||||
Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK)),
|
||||
MoveRobber(HexCoord(1, -1), 2),
|
||||
MoveRobber(HexCoord(-2, 0)),
|
||||
BuildRoad(11),
|
||||
BuildSettlement(13),
|
||||
BuildCity(13),
|
||||
BuyDevCard,
|
||||
PlayKnight(21),
|
||||
PlayRoadBuilding(22),
|
||||
PlayMonopoly(23, Resource.ORE),
|
||||
PlayYearOfPlenty(24, listOf(Resource.GRAIN, Resource.GRAIN)),
|
||||
OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)),
|
||||
AcceptTrade,
|
||||
DeclineTrade,
|
||||
MaritimeTrade(Resource.WOOL, Resource.GRAIN),
|
||||
EndTurn,
|
||||
EndFreeRoads,
|
||||
)
|
||||
moves.forEach { assertEquals(it, CatanRules.decodeMove(CatanRules.encodeMove(it))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `summary reports the standing points until somebody wins`() {
|
||||
val state = craft()
|
||||
assertTrue(CatanRules.summary(state).contains("S0 0"))
|
||||
val homes = scattered(5)
|
||||
// Seat 2 ends, seat 0 is next and wins on arrival.
|
||||
var won = craft(turn = 2)
|
||||
homes.forEach { won = won.withBuilding(it, BuildingType.CITY, 0) }
|
||||
won = CatanRules.applyMove(won, 2, EndTurn)
|
||||
assertEquals("Seat 0 wins", CatanRules.summary(won))
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
|
||||
private fun assertThrows(block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
throw AssertionError("expected the rules to reject that")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// Rejected, which is the point.
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -87,8 +87,8 @@ PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet
|
||||
```
|
||||
|
||||
The two variants install side by side: different applicationId, different
|
||||
launcher name (`Pilgrims of Katan DEV` vs `Pilgrims of Katan`), different brand
|
||||
colour.
|
||||
launcher name (`Pro Libertate Games DEV` vs `Pro Libertate Games`), different
|
||||
tartan sett.
|
||||
|
||||
## Manual release (works today, no runner needed)
|
||||
|
||||
@@ -97,10 +97,15 @@ GITEA_TOKEN=… ./scripts/publish-release.sh v1.4.1001 app/build/outputs/apk/deb
|
||||
```
|
||||
|
||||
The script takes `<tag> <apk> <prerelease|release> [target-sha]`, creates the
|
||||
release through `POST /api/v1/repos/{owner}/{repo}/releases`, and attaches the
|
||||
APK through `POST …/releases/{id}/assets` — which is the upload route Gitea's
|
||||
Swagger declares for this instance (1.27.3). If the tag already exists it
|
||||
publishes into the existing release instead of failing, so a retry is safe.
|
||||
release through `POST /api/v1/repos/{owner}/{repo}/releases`, attaches the APK
|
||||
through `POST …/releases/{id}/assets` — which is the upload route Gitea's
|
||||
Swagger declares for this instance (1.27.3) — and then reads the release back
|
||||
and corrects its state if it did not stick. That last step is not decoration:
|
||||
this instance has been observed to accept `draft: false` at create time and
|
||||
store the release as a draft after the attachment lands, and a draft is
|
||||
invisible to the updater, so the script verifies rather than assumes. If the
|
||||
tag already exists it publishes into the existing release instead of failing,
|
||||
so a retry is safe.
|
||||
|
||||
## What the in-app updater reads
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
|
||||
| Golf | [RULES-golf.md](RULES-golf.md) |
|
||||
| Hearts | — (TODO) |
|
||||
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
|
||||
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) |
|
||||
| Klondike | — (TODO) |
|
||||
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
|
||||
| Morris | [RULES-morris.md](RULES-morris.md) |
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
# Katan — implemented rules
|
||||
|
||||
This is the ruleset the engine enforces. Anything marked **toggle** is exposed
|
||||
in the game setup screen.
|
||||
|
||||
Katan is this app's name for the island game the collection is built around.
|
||||
The catalogue id is `catan` (so this file is `RULES-catan.md` and the code lives
|
||||
in `game/catan/`), but the printed name is trademarked, so the game is listed
|
||||
and titled **Katan**. The rules themselves are the base game for three to four
|
||||
players, as published.
|
||||
|
||||
The engine is a pure state machine (`CatanRules`) with no Android types, and its
|
||||
behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`.
|
||||
|
||||
## Table
|
||||
|
||||
- **Players — *toggle*.** Three or four. `CatanOptions.playerCount`; the engine
|
||||
refuses anything else.
|
||||
- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at
|
||||
least 2. A lower target makes a test game short.
|
||||
|
||||
The setup screen that exposes these lands with the screen task; the engine reads
|
||||
them from the table configuration today.
|
||||
|
||||
## Board
|
||||
|
||||
The island is the standard layout, derived once from axial hex coordinates in
|
||||
`CatanBoard` and checked at class-init:
|
||||
|
||||
- **19 hexes**, the hexes within distance 2 of the centre.
|
||||
- **54 vertices** and **72 edges**, shared between neighbouring hexes by exact
|
||||
integer position, not floating point. `HEX_CORNERS`, `EDGE_VERTICES`,
|
||||
`VERTEX_EDGES`, `VERTEX_HEXES` and `EDGE_HEXES` are the shared graph.
|
||||
- **30 coastal edges**, one closed ring.
|
||||
- **9 harbours**, on fixed positions around the coast.
|
||||
|
||||
Terrain is dealt from the physical mix and shuffled: **4 forest, 4 pasture,
|
||||
4 fields, 3 hills, 3 mountains, 1 desert**.
|
||||
|
||||
Number tokens are the 18 the game ships, shuffled over the land hexes: four red
|
||||
tokens (**6, 6, 8, 8**) and fourteen others
|
||||
(**2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12**). Seven never appears. The
|
||||
desert bears no token and the robber starts on it.
|
||||
|
||||
### Keeping 6s and 8s apart
|
||||
|
||||
The published game asks that the two red numbers not sit on adjacent hexes, and
|
||||
`generate` guarantees it rather than hoping for it:
|
||||
|
||||
1. Shuffle the land and take four hexes; accept the draw if none of the four
|
||||
touch each other. Retry up to 60 times.
|
||||
2. If no draw separates them, fall back to four of the six board-corner hexes,
|
||||
which are pairwise non-adjacent by construction and at most one of which can
|
||||
be the desert.
|
||||
|
||||
`no seed puts two red tokens on adjacent hexes` runs the whole thing over seeds
|
||||
1..500.
|
||||
|
||||
Harbours: the nine **positions** are fixed (the physical frame is not shuffled),
|
||||
but the nine **types** are shuffled onto them — **five 2:1 resource harbours**
|
||||
(one per resource) and **four generic 3:1 harbours**. The frame positions are
|
||||
chosen so no two harbours sit closer than three coastal edges apart.
|
||||
|
||||
## Setup
|
||||
|
||||
Placement runs in snake order: seats `0, 1, …, n-1` then `n-1, …, 1, 0`, each
|
||||
placing one settlement and one road. During setup the distance rule still holds
|
||||
(a settlement may not sit one edge from any other building) but the road need
|
||||
not connect to anything already placed.
|
||||
|
||||
A player's **second** settlement earns one resource card for each adjacent
|
||||
producing hex, supply permitting — duplicates counted, so a second settlement on
|
||||
two forests earns two lumber. The first pass earns nothing.
|
||||
|
||||
After the last placement, seat 0 leads and the first real turn begins.
|
||||
|
||||
## Turn
|
||||
|
||||
A turn runs through the phases in `CatanPhase`:
|
||||
|
||||
1. **AWAIT_ROLL** — the player on the clock rolls.
|
||||
2. A seven goes to **DISCARDING** and then **ROBBER**; anything else produces and
|
||||
goes straight to **PLAYING**.
|
||||
3. **PLAYING** — build, trade, buy and play development cards, then end the turn.
|
||||
4. **FREE_ROADS** is a detour inside PLAYING while a Road Building card is being
|
||||
spent.
|
||||
|
||||
### Rolling
|
||||
|
||||
The dice are derived from the game seed and a monotonic counter (`rngStep`), not
|
||||
from a random generator stored in the state: the same seed and the same moves
|
||||
replay identically. `RollDice(forced = …)` exists so tests can stage a number;
|
||||
it is deliberately **not** a member of the enumerated move list, so a client
|
||||
cannot send one over the network — only code holding the state directly can use
|
||||
it.
|
||||
|
||||
### Production
|
||||
|
||||
For a non-seven total, each hex whose number matches pays its producing terrain:
|
||||
a settlement takes **1**, a city takes **2**. The robber's hex pays nothing.
|
||||
|
||||
**When the bank cannot meet the demand for a resource, nobody receives that
|
||||
resource this roll.** This is the official ruling, implemented per-resource:
|
||||
demand is totalled first and, if it exceeds the bank's supply, every gain of
|
||||
that resource is dropped.
|
||||
|
||||
## The robber
|
||||
|
||||
A **seven** sends the robber: first every player holding **more than seven**
|
||||
resource cards discards half their hand (rounded down, so nine cards means four
|
||||
gone), in seat order starting from the lowest overfull seat; then the roller
|
||||
moves the robber and may steal.
|
||||
|
||||
The robber **must move to a different hex**. Moving onto a hex lets the roller
|
||||
steal one card from one opponent who has a building on that hex and at least one
|
||||
card in hand. The card stolen is chosen at random, derived like the dice. A
|
||||
hex with no opponent holding cards offers only the move, no steal.
|
||||
|
||||
A played Knight enters the same ROBBER phase, so the robber move after a Knight
|
||||
is the same code path.
|
||||
|
||||
## Trading
|
||||
|
||||
### Domestic
|
||||
|
||||
An offer names **one** addressee and two card multisets: what the offerer gives,
|
||||
what they want. There are no counter-offers and no open auctions in v1.
|
||||
|
||||
An offer is legal only when the offerer's turn is in PLAYING, the addressee is
|
||||
another seat, both legs are non-empty, the two legs are not the same multiset
|
||||
(`countOf(give) != countOf(get)` — like-for-like is not a trade), and the
|
||||
offerer actually holds the cards being offered.
|
||||
|
||||
The addressee may **accept** only if they hold what was asked and the offerer
|
||||
still holds what was promised; otherwise only **decline** is offered. A decline
|
||||
clears the table and leaves every card where it was.
|
||||
|
||||
Offers are free-form intents rather than enumerated moves (the space of card
|
||||
multisets is combinatorial), so they are validated structurally by
|
||||
`CatanRules.isLegalMove` and checked by the network driver's gate like any other
|
||||
move.
|
||||
|
||||
### Maritime
|
||||
|
||||
Bank trade gives `ratio` of one resource for one of another, where the ratio is
|
||||
the best the seat can get: **4:1** through the bank, **3:1** through a generic
|
||||
harbour, **2:1** through a harbour matching the resource being given. A harbour
|
||||
counts when the seat owns a settlement or city on **either** endpoint vertex of
|
||||
its edge.
|
||||
|
||||
## Building and development cards
|
||||
|
||||
| Build | Cost |
|
||||
| --- | --- |
|
||||
| Road | 1 lumber + 1 brick |
|
||||
| Settlement | 1 lumber + 1 brick + 1 wool + 1 grain |
|
||||
| City | 2 grain + 3 ore |
|
||||
| Development card | 1 wool + 1 grain + 1 ore |
|
||||
|
||||
A settlement goes on an **empty vertex** at least two edges from every standing
|
||||
building and connected to the seat's own road network. A city upgrades one of
|
||||
the seat's own settlements. A road goes on an empty edge that connects to the
|
||||
seat's network (a road or a building at one of its ends).
|
||||
|
||||
The development deck is the published 25 cards: **14 Knights, 2 Road Building,
|
||||
2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
|
||||
top of the deck.
|
||||
|
||||
Timing rules the engine enforces:
|
||||
|
||||
- **One development card per turn.** `devCardPlayedThisTurn` gates every play.
|
||||
- **A card cannot be played the turn it was bought.** Each card records
|
||||
`boughtTurn` and is skipped while that equals the current turn.
|
||||
- **Victory Point cards are never played.** They are counted straight from the
|
||||
hand by `victoryPoints`, and are only revealed by winning.
|
||||
- **Knight** → the robber phase, and counts toward Largest Army.
|
||||
- **Road Building** → two free road placements (FREE_ROADS); the player may
|
||||
forfeit any that remain.
|
||||
- **Monopoly** → seizes every card of the named resource from the other seats.
|
||||
- **Year of Plenty** → takes up to two cards from the bank; taking two of a kind
|
||||
requires the bank to hold both.
|
||||
|
||||
## Scoring and winning
|
||||
|
||||
Victory points are:
|
||||
|
||||
- **Settlement 1, city 2.**
|
||||
- **Longest Road 5** — the longest single road of **five or more** segments.
|
||||
- **Largest Army 5** — first to **three** played knights.
|
||||
- **Victory Point cards**, one each.
|
||||
|
||||
A seat wins on reaching **points to win** (default 10). The win is checked after
|
||||
every build, when a turn ends, and when a seat's turn arrives — the last covers
|
||||
points that changed on someone else's turn, such as a broken Longest Road being
|
||||
re-awarded. A seat already at the target therefore wins at the start of its own
|
||||
turn rather than having to act.
|
||||
|
||||
### Longest Road
|
||||
|
||||
The award goes to the longest **simple path** over a seat's own roads. The path
|
||||
never passes *through* an opponent's building (it may run up to one) and never
|
||||
revisits a vertex — so a figure of two loops sharing an intersection counts as
|
||||
one road of five, not two roads, because the intersection is a branch.
|
||||
|
||||
Once held, the award is kept until another seat builds a **strictly longer**
|
||||
road; an equal length leaves it where it is. If the holder's road falls below
|
||||
five segments the award reopens to the longest road of five or more, lowest seat
|
||||
first on a tie. A settlement placed across an opponent's road breaks it, and the
|
||||
award is recomputed for everyone after any settlement.
|
||||
|
||||
### Largest Army
|
||||
|
||||
First to three knights takes it. It is stolen only by a **strictly larger** army:
|
||||
a tie leaves the incumbent ahead, exactly as Longest Road does.
|
||||
|
||||
## The computer opponent
|
||||
|
||||
`CatanAi` plays a legal, mildly strategic game so a solo player can host against
|
||||
computers, with the lobby filling empty seats.
|
||||
|
||||
It reads **only public information** — terrain, numbers, buildings, roads,
|
||||
resource counts, development-card counts and knights played — plus its own hand.
|
||||
It never looks into the deck, another seat's development cards, or the seed
|
||||
beyond the roll the rules derive from it. That is a design constraint, not an
|
||||
accident: it runs on the host, where the authoritative state is fully visible.
|
||||
|
||||
What it does:
|
||||
|
||||
- **Setup.** Rates a settlement by the production its three hexes give (weighted
|
||||
by the token odds and by how much it wants each resource), plus a bonus for
|
||||
resource variety and for sitting on a harbour, plus a small term for where the
|
||||
accompanying road points.
|
||||
- **Main phase**, in priority order: raise a city, build a settlement, play a
|
||||
knight, play Year of Plenty or Monopoly when it closes a gap, play Road
|
||||
Building when two segments are useful, buy a development card, build a road
|
||||
that reaches a spot worth settling, trade at the bank only when the trade
|
||||
completes a build this turn, make one domestic offer that closes a build, else
|
||||
end the turn.
|
||||
- **The robber.** Moves onto the hex where it blocks the most opposing
|
||||
production (weighted by token odds), preferring to rob whoever is holding the
|
||||
most cards and the most points.
|
||||
- **Trades.** Accepts an offer when what arrives is worth more than what leaves
|
||||
by its own resource valuations; a mild table, not a shark.
|
||||
- **Discards.** Throws away what it values least (wool, then brick, then lumber,
|
||||
before grain and ore).
|
||||
|
||||
It makes **at most one domestic offer a turn**. A declined offer changes nothing,
|
||||
so re-offering the same thing would only bounce between two seats forever; the
|
||||
engine allows more offers, the AI chooses not to.
|
||||
|
||||
`CatanAiTest` plays three- and four-seat tables of engines out to a result from
|
||||
several seeds, refusing any illegal move the AI chooses, and checks the winner
|
||||
reached the target.
|
||||
|
||||
## Provenance and rule choices
|
||||
|
||||
The rules are the published base game. Where the published rules leave room, the
|
||||
engine picks one reading and records it here.
|
||||
|
||||
**Followed from the published rules:** the board and its component mix; the
|
||||
number tokens; 6s and 8s not adjacent; snake-order setup and second-settlement
|
||||
income; production weighting; the robber on a seven; discard half over seven;
|
||||
domestic and maritime trade; build costs; the 25-card development deck and its
|
||||
timing rules; Longest Road and Largest Army worth five; victory at ten.
|
||||
|
||||
**Choices made here, and why:**
|
||||
|
||||
- **A bank short of a resource pays nobody that roll.** This is the official
|
||||
ruling, and it is implemented per-resource rather than per-player.
|
||||
- **Longest Road and Largest Army are kept on a tie**, and stolen only by a
|
||||
strictly longer road or a strictly larger army. The alternative reading lets a
|
||||
tie steal the award; "strictly better" is the common tournament reading and
|
||||
keeps the award from flickering.
|
||||
- **A ring road counts as a simple path.** No vertex is revisited, so six
|
||||
segments around one hex are worth five, not six.
|
||||
- **The win is checked when a seat's turn arrives**, not only on that seat's own
|
||||
action, so points that move on another player's turn still win.
|
||||
- **A bought Victory Point card does not win immediately.** It is counted when
|
||||
the win is next checked (a build or the end of the turn), which is when the
|
||||
holder would reveal it.
|
||||
- **Year of Plenty two-of-a-kind needs the bank to hold both cards**, not one.
|
||||
- **The robber must move.** Some printings allow staying put; this engine always
|
||||
requires a different hex.
|
||||
- **Offers are one-to-one, no auctions or counter-offers** in v1.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No domestic auction or counter-offer.** An offer is addressed to a single
|
||||
seat. Open trading to the whole table, and counter-offers, are the obvious next
|
||||
step and are not implemented.
|
||||
- **No player-to-player trade over the wire beyond accept/decline.** The engine
|
||||
supports it; whether the UI surfaces it is the screen task's concern.
|
||||
- **No trading away a development card.** Not part of the base game.
|
||||
- **The setup screen is not part of this task.** `CatanOptions` (players, target
|
||||
points) is read from the table configuration; the screen that sets it, and the
|
||||
catalogue descriptor's `available` flag, land with the screen and verification
|
||||
tasks.
|
||||
- **The AI does not look ahead.** It scores one move deep with a fixed resource
|
||||
valuation. It plays legally and plausibly; it is not a strong opponent, and no
|
||||
strength claim is made.
|
||||
- **The board is the standard published layout, generated.** Unlike the Sequence
|
||||
board there is no unverified substitute here — the geometry is the real one —
|
||||
but the *island* (terrain and token placement) is freshly generated each game,
|
||||
so it will not match any particular physical set-up. That is the intent.
|
||||
@@ -86,6 +86,10 @@ fi
|
||||
id="$(grep -o '"id":[0-9]*' "$tmp/create.json" | head -n1 | tr -dc '0-9' || true)"
|
||||
[ -n "$id" ] || { echo "publish-release: no release id in response" >&2; cat "$tmp/create.json" >&2; echo >&2; exit 1; }
|
||||
|
||||
# Attach the APK first, then check the stored state: uploading has been
|
||||
# observed to leave a freshly created release as a draft, whatever the create
|
||||
# response said, so the check has to be the last thing that happens rather
|
||||
# than a step in the middle.
|
||||
code="$(curl -sS -o "$tmp/asset.json" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${apk};filename=${apk_name}" \
|
||||
@@ -98,4 +102,25 @@ if [ "$code" != "201" ] && [ "$code" != "200" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read back what the database actually stored before declaring success. A draft
|
||||
# is invisible to the updater — exactly what a channel must not silently become
|
||||
# — and this instance has been seen to report one as published at create time
|
||||
# and store it as a draft by the time it is read.
|
||||
curl -sS -o "$tmp/stored.json" -H "Authorization: token ${GITEA_TOKEN}" "$api/releases/${id}"
|
||||
if grep -q '"draft":true' "$tmp/stored.json" ||
|
||||
! grep -q "\"prerelease\":${prerelease}" "$tmp/stored.json"; then
|
||||
state="$(curl -sS -o "$tmp/fix.json" -w '%{http_code}' -X PATCH \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||
"$api/releases/${id}")"
|
||||
if [ "$state" != "200" ]; then
|
||||
echo "publish-release: release ${tag} is stored with the wrong state (HTTP ${state})" >&2
|
||||
cat "$tmp/fix.json" >&2
|
||||
echo >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "publish-release: corrected the stored state of ${tag} (it was saved as a draft)"
|
||||
fi
|
||||
|
||||
echo "publish-release: ${tag} -> release ${id} on ${api#*/repos/}, asset ${apk_name}"
|
||||
|
||||
Reference in New Issue
Block a user