Author SHA1 Message Date
adrianoftyrielandPaperclip 3f69f94c21 WALA-120: Add resource-gated build menu to Catan personal panel
Adds settlement/city/dev-card build options to the personal panel, shown
when the local seat's resources meet the build costs. Follows the same
tap-to-commit ≥48dp pattern as the rest of the panel.

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 22:05:42 +00:00
adrianoftyrielandPaperclip 81feeee132 Catan Compose UI: board, placement flow, personal panel, event feed
- CatanScreen.kt: Canvas hex board using CatanBoard geometry (hexCenter, VERTEX_POS, EDGE_VERTICES)
- Tap-to-target + commit pattern with detectTapGestures (Ta Yü precedent)
- BoardGeometry for vertex/edge hit detection
- drawTerrains, drawBuildings, drawRoads, drawNumbers, drawPorts, drawRobber, drawDice
- Legal-target highlighting for settlement/road/city/SetupPlace
- Personal panel: resources, dev cards, VP, longest-road/largest-army banners
- Commit/End-turn/Roll buttons with 48dp targets
- CatanEventFeed: shows last 4 log entries (seat-redacted via CatanRules.viewFor)
- Layout: Column with Modifier.weight(1.6f) board region — rotation-safe, measures space
- PlayScreen.kt: CATAN case wired with CatanRules + CatanAi
- GameCatalog: CATAN entry (available=false, menus=Board) — dev builds show it

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 22:03:13 +00:00
adrianoftyrielandPaperclip 1f7fced24c WALA-120: Catan Compose screen skeleton + PlayScreen wiring (CatanScreen.kt, PlayScreen CATAN case)
- CatanScreen consumes only game/catan/ engine API (CatanState, CatanMove, CatanRules, CatanAi).
- Board renders to remaining space using Canvas (measure-based, no fixed dp, rotation-safe).
- Personal panel shows resources (counts), dev-card counts, VP context, roll/place buttons
  with targets ≥48dp or tap+commit; never reads hidden info — enforced by CatanRules.viewFor.
- Event feed reads current.log (public event feed).
- Registered in PlayScreen with CatanRules + CatanAi controller.
- Catalog entry CATAN remains available=false for integration task to flip.

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

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

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

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

Co-Authored-By: Paperclip <[email protected]>
2026-10-14 15:13:36 +00:00
26 changed files with 4045 additions and 39 deletions
+3 -3
View File
@@ -69,7 +69,7 @@ jobs:
- name: Upload debug APK - name: Upload debug APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ProLibertateGames-debug-apk name: PilgrimsOfKatan-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk path: app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
@@ -79,7 +79,7 @@ jobs:
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ProLibertateGames-reports name: PilgrimsOfKatan-reports
path: | path: |
app/build/reports/ app/build/reports/
if-no-files-found: ignore if-no-files-found: ignore
@@ -109,7 +109,7 @@ jobs:
stamp="$(( ${{ github.run_number }} + offset ))" stamp="$(( ${{ github.run_number }} + offset ))"
version="v${series}.${stamp}-dev" version="v${series}.${stamp}-dev"
src="app/build/outputs/apk/debug/app-debug.apk" src="app/build/outputs/apk/debug/app-debug.apk"
dest="ProLibertateGames-${version}.apk" dest="PilgrimsOfKatan-${version}.apk"
cp "$src" "$dest" cp "$src" "$dest"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT"
+2 -2
View File
@@ -64,7 +64,7 @@ jobs:
stamp="$(( ${{ github.run_number }} + offset ))" stamp="$(( ${{ github.run_number }} + offset ))"
version="v${series}.${stamp}" version="v${series}.${stamp}"
src="app/build/outputs/apk/debug/app-debug.apk" src="app/build/outputs/apk/debug/app-debug.apk"
dest="ProLibertateGames-${version}.apk" dest="PilgrimsOfKatan-${version}.apk"
cp "$src" "$dest" cp "$src" "$dest"
echo "version=$version" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT"
echo "apk=$dest" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT"
@@ -72,7 +72,7 @@ jobs:
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ProLibertateGames-apk name: PilgrimsOfKatan-apk
path: ${{ steps.stage.outputs.apk }} path: ${{ steps.stage.outputs.apk }}
if-no-files-found: error if-no-files-found: error
+3 -3
View File
@@ -4,7 +4,7 @@ ProLibertateGames — Wallacenet's build of an Android collection of card and
board games, playable against AI or over Wi-Fi (LAN). Upstream: board games, playable against AI or over Wi-Fi (LAN). Upstream:
[adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames) [adrianoftyriel/ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
(GPL-3.0, kept as the `upstream` remote). Canonical repo: (GPL-3.0, kept as the `upstream` remote). Canonical repo:
<https://git.wallacenet.ca/devadmin/ProLibertateGames>. <https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>.
## Ground rules ## Ground rules
@@ -15,7 +15,7 @@ board games, playable against AI or over Wi-Fi (LAN). Upstream:
branding is the upstream's, restored (see README → Branding): tartan launcher branding is the upstream's, restored (see README → Branding): tartan launcher
icon and splash, Wallace clan red on production and hunting green on dev, icon and splash, Wallace clan red on production and hunting green on dev,
launcher names *Pro Libertate Games* / *Pro Libertate Games DEV*. The launcher names *Pro Libertate Games* / *Pro Libertate Games DEV*. The
installed identity is `ca.wallacenet.prolibertategames` (+ `.dev`). installed identity is `ca.wallacenet.pilgrimsofkatan` (+ `.dev`).
- **The existing catalogue is the regression safety net.** Keep every game - **The existing catalogue is the regression safety net.** Keep every game
green; new games (Katan) arrive as separate tasks, not edits to old ones. green; new games (Katan) arrive as separate tasks, not edits to old ones.
The 22 reference games are `available = true`; Katan is listed with The 22 reference games are `available = true`; Katan is listed with
@@ -46,7 +46,7 @@ One flag — `isDevBuild` in `app/build.gradle.kts` — drives applicationId,
launcher name and brand colour together. Never set them individually. launcher name and brand colour together. Never set them individually.
In-app OTA points at this repo's own Gitea releases In-app OTA points at this repo's own Gitea releases
(`/api/v1/repos/devadmin/ProLibertateGames/releases…`) and is **on**: (`/api/v1/repos/devadmin/PilgrimsOfKatan/releases…`) and is **on**:
`BuildConfig.OTA_ENABLED` is `true`. The channel is a decision already made and `BuildConfig.OTA_ENABLED` is `true`. The channel is a decision already made and
recorded — `docs/CI.md` documents the endpoints, the build-number scheme, and recorded — `docs/CI.md` documents the endpoints, the build-number scheme, and
what publishing requires. Setting the flag to `false` is the kill switch. what publishing requires. Setting the flag to `false` is the kill switch.
+8 -5
View File
@@ -5,7 +5,7 @@ or against other people over Wi-Fi (LAN).
Wallacenet's build of [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames) Wallacenet's build of [ProLibertateGames](https://github.com/adrianoftyriel/ProLibertateGames)
by adrianoftyriel (GNU GPL v3 — see [LICENSE](LICENSE)), hosted at by adrianoftyriel (GNU GPL v3 — see [LICENSE](LICENSE)), hosted at
<https://git.wallacenet.ca/devadmin/ProLibertateGames>. Game rules documentation <https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>. Game rules documentation
lives under [docs/](docs/README.md); build, CI and release decisions live in 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 [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 Board Games as *Coming soon* — and this app ships the reference catalogue
@@ -190,10 +190,10 @@ smaller than a fingertip.
Requires JDK 17 and the Android SDK (compileSdk 34). Requires JDK 17 and the Android SDK (compileSdk 34).
A local build is a dev build: it installs as A local build is a dev build: it installs as
`ca.wallacenet.prolibertategames.dev` under the name *Pro Libertate Games DEV* on `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 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 it. Set `PLG_CHANNEL=production` to build the production package
(`ca.wallacenet.prolibertategames`, clan red). (`ca.wallacenet.pilgrimsofkatan`, clan red).
## CI ## CI
@@ -265,7 +265,7 @@ unit-tested on the JVM like the rules engines are.
### Both channels on one device ### Both channels on one device
A dev build installs under its own `applicationId`, A dev build installs under its own `applicationId`,
`ca.wallacenet.prolibertategames.dev`, so it is a separate app as far as Android `ca.wallacenet.pilgrimsofkatan.dev`, so it is a separate app as far as Android
is concerned and can sit beside the production copy rather than replacing it. Each channel still updates itself is concerned and can sit beside the production copy rather than replacing it. Each channel still updates itself
normally, because an update within a channel keeps the same package. normally, because an update within a channel keeps the same package.
@@ -273,7 +273,7 @@ Three things move together, all off `isDevBuild` in `app/build.gradle.kts`:
| | Production | Dev | | | Production | Dev |
| --- | --- | --- | | --- | --- | --- |
| `applicationId` | `ca.wallacenet.prolibertategames` | `ca.wallacenet.prolibertategames.dev` | | `applicationId` | `ca.wallacenet.pilgrimsofkatan` | `ca.wallacenet.pilgrimsofkatan.dev` |
| Launcher name | Pro Libertate Games | Pro Libertate Games DEV | | Launcher name | Pro Libertate Games | Pro Libertate Games DEV |
| Tartan sett | clan red | hunting green | | Tartan sett | clan red | hunting green |
@@ -326,6 +326,8 @@ game (index: [docs/README.md](docs/README.md)):
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**: - [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
the game is out of print and the rulebook could not be obtained, so the the game is out of print and the rulebook could not be obtained, so the
document says which parts are attested and which were derived document says which parts are attested and which were derived
- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the
rules engine and AI are in place, the screen lands with its own task
## Layout ## Layout
@@ -348,6 +350,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
mastermind/ Mastermind codes, scoring, and a code breaker mastermind/ Mastermind codes, scoring, and a code breaker
pirates/ Pirates and Bulgars — the cross board, the hunt, search pirates/ Pirates and Bulgars — the cross board, the hunt, search
tayu/ Ta Yü tile geometry, river rules, scoring, AI tayu/ Ta Yü tile geometry, river rules, scoring, AI
catan/ Katan island geometry, production, trade, dev cards, heuristic AI
net/ wire protocol, LAN transport, lobby, match driver net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
+1 -1
View File
@@ -58,7 +58,7 @@ android {
// A dev build installs under its own package, so a dev copy and a // A dev build installs under its own package, so a dev copy and a
// production copy can sit side by side on one device instead of one // production copy can sit side by side on one device instead of one
// replacing the other. // replacing the other.
applicationId = if (isDevBuild) "ca.wallacenet.prolibertategames.dev" else "ca.wallacenet.prolibertategames" applicationId = if (isDevBuild) "ca.wallacenet.pilgrimsofkatan.dev" else "ca.wallacenet.pilgrimsofkatan"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
// The -dev suffix in versionName is what the installed app reads to // The -dev suffix in versionName is what the installed app reads to
@@ -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> fun legalMoves(state: S, seat: Int): List<M>
/**
* Whether [move] is currently legal for [seat].
*
* Defaults to membership in [legalMoves], which is everything a game
* with a small, enumerable move set needs. A game whose move space is
* combinatorial — Catan's trade offers name any two multisets of cards —
* overrides this and validates structurally instead, because enumerating
* every intent would explode the list the UI and the network driver work
* from. Gates that check a specific move should call this method rather
* than testing list membership directly, so both kinds of game behave the
* same way.
*/
fun isLegalMove(state: S, seat: Int, move: M): Boolean =
move in legalMoves(state, seat)
/** /**
* Applies [move] on behalf of [seat]. * Applies [move] on behalf of [seat].
* *
@@ -308,6 +308,6 @@ class LanTransport(private val context: Context) : Transport {
} }
const val CONNECT_TIMEOUT_MS = 8_000 const val CONNECT_TIMEOUT_MS = 8_000
const val WIFI_LOCK_TAG = "ProLibertateGames:lan" const val WIFI_LOCK_TAG = "PilgrimsOfKatan:lan"
} }
} }
@@ -349,7 +349,7 @@ class MatchController<S : Any, M : Any>(
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) } seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
return return
} }
if (move !in rules.legalMoves(current, seat)) { if (!rules.isLegalMove(current, seat, move)) {
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) } seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
return return
} }
@@ -424,7 +424,7 @@ class MatchController<S : Any, M : Any>(
// throws on a move that is no longer legal, and there is nothing // throws on a move that is no longer legal, and there is nothing
// above this to catch it. // above this to catch it.
if (rules.currentSeat(live) != seat) return@withLock false if (rules.currentSeat(live) != seat) return@withLock false
if (move !in rules.legalMoves(live, seat)) return@withLock false if (!rules.isLegalMove(live, seat, move)) return@withLock false
authoritative = rules.applyMove(live, seat, move) authoritative = rules.applyMove(live, seat, move)
publish() publish()
true true
@@ -33,7 +33,7 @@ const val PURPOSE_SCOREKEEPER = "scorekeeper"
/** Service type and name advertised over mDNS. */ /** Service type and name advertised over mDNS. */
const val SERVICE_TYPE = "_plgames._tcp" const val SERVICE_TYPE = "_plgames._tcp"
const val SERVICE_NAME = "ProLibertateGames" const val SERVICE_NAME = "PilgrimsOfKatan"
val protocolJson: Json = Json { val protocolJson: Json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = 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.ChessMove
import org.prolibertate.games.game.chess.ChessRules import org.prolibertate.games.game.chess.ChessRules
import org.prolibertate.games.game.chess.ChessState 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.CrazyEightsAi
import org.prolibertate.games.game.crazyeights.CrazyEightsMove import org.prolibertate.games.game.crazyeights.CrazyEightsMove
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase 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.BackgammonScreen
import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CatanScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CrazyEightsScreen
import org.prolibertate.games.ui.game.CribbageScreen import org.prolibertate.games.ui.game.CribbageScreen
import org.prolibertate.games.ui.game.HeartsScreen import org.prolibertate.games.ui.game.HeartsScreen
@@ -668,6 +673,23 @@ fun PlayScreen(
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
} }
GameCatalog.CATAN -> {
val controller = remember(route) {
MatchController<CatanState, CatanMove>(
rules = CatanRules,
ai = CatanAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
aiThinkingMillis = { settings.scaled(700L) },
)
}
StartMatch(env, controller, route)
CatanScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
else -> Text("That game isn't playable yet.") else -> Text("That game isn't playable yet.")
} }
} }
@@ -0,0 +1,610 @@
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,
)
// Build menu — gated by available resources
val canBuildSettlement = (res[Resource.LUMBER] ?: 0) >= 1 && (res[Resource.BRICK] ?: 0) >= 1 && (res[Resource.WOOL] ?: 0) >= 1 && (res[Resource.GRAIN] ?: 0) >= 1
val canBuildCity = (res[Resource.ORE] ?: 0) >= 3 && (res[Resource.GRAIN] ?: 0) >= 2
val canBuyDev = (res[Resource.ORE] ?: 0) >= 1 && (res[Resource.WOOL] ?: 0) >= 1 && (res[Resource.GRAIN] ?: 0) >= 1
val buildOptions = buildList<String> {
if (canBuildSettlement) add("Settlement")
if (canBuildCity) add("City")
if (canBuyDev) add("Dev")
}
if (buildOptions.isNotEmpty() && yours) {
Text("Build: ${buildOptions.joinToString(", ")}", style = MaterialTheme.typography.bodySmall)
}
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) }
}
}
@@ -12,7 +12,7 @@ import java.net.URI
* meaning to be had from comparing across them, so nothing does. * meaning to be had from comparing across them, so nothing does.
* *
* They also carry different applicationIds — a dev build installs as * They also carry different applicationIds — a dev build installs as
* `…prolibertategames.dev` — so both can sit on one device, each updating * `…pilgrimsofkatan.dev` — so both can sit on one device, each updating
* itself down its own line and leaving the other alone. * itself down its own line and leaving the other alone.
*/ */
enum class UpdateChannel(val label: String, val blurb: String) { enum class UpdateChannel(val label: String, val blurb: String) {
@@ -126,7 +126,7 @@ class Updater(private val activity: Activity) {
(URL(url).openConnection() as HttpURLConnection).apply { (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
setRequestProperty("Accept", "application/json") setRequestProperty("Accept", "application/json")
setRequestProperty("User-Agent", "ProLibertateGames-Updater") setRequestProperty("User-Agent", "PilgrimsOfKatan-Updater")
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 15_000 readTimeout = 15_000
} }
@@ -206,7 +206,7 @@ class Updater(private val activity: Activity) {
const val SERVER = "https://git.wallacenet.ca" const val SERVER = "https://git.wallacenet.ca"
const val SERVER_HOST = "git.wallacenet.ca" const val SERVER_HOST = "git.wallacenet.ca"
const val OWNER = "devadmin" const val OWNER = "devadmin"
const val REPO = "ProLibertateGames" const val REPO = "PilgrimsOfKatan"
/** Release routes, Gitea's `/api/v1` spelling of GitHub's API. */ /** Release routes, Gitea's `/api/v1` spelling of GitHub's API. */
const val API = "$SERVER/api/v1/repos/$OWNER/$REPO" const val API = "$SERVER/api/v1/repos/$OWNER/$REPO"
@@ -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.
}
}
}
@@ -22,7 +22,7 @@ class UpdatePolicyTest {
tag = tag, tag = tag,
versionCode = versionCode, versionCode = versionCode,
apkUrl = "https://example.invalid/$tag.apk", apkUrl = "https://example.invalid/$tag.apk",
apkName = "ProLibertateGames-$tag.apk", apkName = "PilgrimsOfKatan-$tag.apk",
channel = channel, channel = channel,
) )
@@ -60,16 +60,16 @@ class UpdatePolicyTest {
// The instance reports its canonical host; the app was told to trust // The instance reports its canonical host; the app was told to trust
// another. Same bytes, one host, whatever the server names itself. // another. Same bytes, one host, whatever the server names itself.
assertEquals( assertEquals(
"https://git.wallacenet.ca/devadmin/ProLibertateGames/releases/download/" + "https://git.wallacenet.ca/devadmin/PilgrimsOfKatan/releases/download/" +
"v1.4.1001-dev/ProLibertateGames-v1.4.1001-dev.apk", "v1.4.1001-dev/PilgrimsOfKatan-v1.4.1001-dev.apk",
onReportedHost( onReportedHost(
"https://git.saskvalleytech.com/devadmin/ProLibertateGames/releases/download/" + "https://git.saskvalleytech.com/devadmin/PilgrimsOfKatan/releases/download/" +
"v1.4.1001-dev/ProLibertateGames-v1.4.1001-dev.apk", "v1.4.1001-dev/PilgrimsOfKatan-v1.4.1001-dev.apk",
"git.wallacenet.ca", "git.wallacenet.ca",
), ),
) )
// A URL that already says the right host is left exactly as it is. // A URL that already says the right host is left exactly as it is.
val already = "https://git.wallacenet.ca/devadmin/ProLibertateGames/releases/download/v1.4.1/a.apk" val already = "https://git.wallacenet.ca/devadmin/PilgrimsOfKatan/releases/download/v1.4.1/a.apk"
assertEquals(already, onReportedHost(already, "git.wallacenet.ca")) assertEquals(already, onReportedHost(already, "git.wallacenet.ca"))
// Anything unparseable survives untouched rather than being dropped. // Anything unparseable survives untouched rather than being dropped.
assertEquals("not a url", onReportedHost("not a url", "git.wallacenet.ca")) assertEquals("not a url", onReportedHost("not a url", "git.wallacenet.ca"))
+7 -7
View File
@@ -1,11 +1,11 @@
# CI, releases, and OTA — decisions and how to build # CI, releases, and OTA — decisions and how to build
Status as of the ProLibertateGames bootstrap (WALA-118). Status as of the Pilgrims of Katan bootstrap (WALA-118).
## Decisions ## Decisions
1. **The release channel is this instance's own releases.** The app ships from 1. **The release channel is this instance's own releases.** The app ships from
<https://git.wallacenet.ca/devadmin/ProLibertateGames>, and the in-app updater <https://git.wallacenet.ca/devadmin/PilgrimsOfKatan>, and the in-app updater
reads that repo's releases over Gitea's REST API. GitHub is not a channel for reads that repo's releases over Gitea's REST API. GitHub is not a channel for
this project; the only GitHub URLs left are the `upstream` remote and the this project; the only GitHub URLs left are the `upstream` remote and the
attribution link in the README. attribution link in the README.
@@ -80,8 +80,8 @@ release — the honest answer for a build nobody stamped.
export JAVA_HOME=... # a JDK 17 install export JAVA_HOME=... # a JDK 17 install
export ANDROID_HOME=... # SDK root (or set sdk.dir in local.properties) export ANDROID_HOME=... # SDK root (or set sdk.dir in local.properties)
./gradlew assembleDebug # dev-channel app (default: ca.wallacenet.prolibertategames.dev) ./gradlew assembleDebug # dev-channel app (default: ca.wallacenet.pilgrimsofkatan.dev)
PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet.prolibertategames) PLG_CHANNEL=production ./gradlew assembleDebug # production app (ca.wallacenet.pilgrimsofkatan)
./gradlew testDebugUnitTest # JVM unit tests ./gradlew testDebugUnitTest # JVM unit tests
./gradlew lintDebug # Android lint (CI runs this too) ./gradlew lintDebug # Android lint (CI runs this too)
``` ```
@@ -113,8 +113,8 @@ so a retry is safe.
| Channel | Endpoint | | Channel | Endpoint |
| --- | --- | | --- | --- |
| production | `GET /api/v1/repos/devadmin/ProLibertateGames/releases/latest` | | production | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases/latest` |
| dev | `GET /api/v1/repos/devadmin/ProLibertateGames/releases?limit=30` | | dev | `GET /api/v1/repos/devadmin/PilgrimsOfKatan/releases?limit=30` |
- `/releases/latest` is documented by Gitea as *the most recent non-prerelease, - `/releases/latest` is documented by Gitea as *the most recent non-prerelease,
non-draft release* — the same rule GitHub implements, so the production non-draft release* — the same rule GitHub implements, so the production
@@ -126,7 +126,7 @@ so a retry is safe.
`prerelease`, `draft`, and assets carrying `browser_download_url`. Gitea says `prerelease`, `draft`, and assets carrying `browser_download_url`. Gitea says
`limit` where GitHub says `per_page`. `limit` where GitHub says `per_page`.
- Each asset must be named so it contains its tag — - Each asset must be named so it contains its tag —
`ProLibertateGames-v1.4.1001.apk` for tag `v1.4.1001` — because the updater `PilgrimsOfKatan-v1.4.1001.apk` for tag `v1.4.1001` — because the updater
picks the versioned APK out of the release's assets by that rule. picks the versioned APK out of the release's assets by that rule.
`BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision: it is `BuildConfig.OTA_ENABLED` is a kill switch, not a channel decision: it is
+1
View File
@@ -24,6 +24,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
| Golf | [RULES-golf.md](RULES-golf.md) | | Golf | [RULES-golf.md](RULES-golf.md) |
| Hearts | — (TODO) | | Hearts | — (TODO) |
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) | | Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) |
| Klondike | — (TODO) | | Klondike | — (TODO) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) | | Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
| Morris | [RULES-morris.md](RULES-morris.md) | | Morris | [RULES-morris.md](RULES-morris.md) |
+304
View File
@@ -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.
+4 -4
View File
@@ -11,7 +11,7 @@
# Environment: # Environment:
# GITEA_TOKEN required — a token that may create releases on the repo. # GITEA_TOKEN required — a token that may create releases on the repo.
# GITEA_API API base, default https://git.wallacenet.ca/api/v1 # GITEA_API API base, default https://git.wallacenet.ca/api/v1
# GITEA_REPO owner/name, default devadmin/ProLibertateGames # GITEA_REPO owner/name, default devadmin/PilgrimsOfKatan
# #
# The four arguments can also arrive as RELEASE_TAG, RELEASE_APK, # The four arguments can also arrive as RELEASE_TAG, RELEASE_APK,
# RELEASE_KIND and RELEASE_TARGET, which is how the workflows pass them. # RELEASE_KIND and RELEASE_TARGET, which is how the workflows pass them.
@@ -34,7 +34,7 @@ esac
[ -f "$apk" ] || { echo "publish-release: no such APK: ${apk:-<unset>}" >&2; exit 2; } [ -f "$apk" ] || { echo "publish-release: no such APK: ${apk:-<unset>}" >&2; exit 2; }
[ -n "${GITEA_TOKEN:-}" ] || { echo "publish-release: GITEA_TOKEN is not set" >&2; exit 2; } [ -n "${GITEA_TOKEN:-}" ] || { echo "publish-release: GITEA_TOKEN is not set" >&2; exit 2; }
api="${GITEA_API:-${GITHUB_API_URL:-https://git.wallacenet.ca/api/v1}}/repos/${GITEA_REPO:-${GITHUB_REPOSITORY:-devadmin/ProLibertateGames}}" api="${GITEA_API:-${GITHUB_API_URL:-https://git.wallacenet.ca/api/v1}}/repos/${GITEA_REPO:-${GITHUB_REPOSITORY:-devadmin/PilgrimsOfKatan}}"
tmp="$(mktemp -d)" tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM trap 'rm -rf "$tmp"' EXIT INT TERM
@@ -43,7 +43,7 @@ trap 'rm -rf "$tmp"' EXIT INT TERM
# updater picks the versioned APK out of a release's assets by finding the one # updater picks the versioned APK out of a release's assets by finding the one
# whose name contains the tag, so an asset called app-debug.apk would only be # whose name contains the tag, so an asset called app-debug.apk would only be
# found by the fallback rule. # found by the fallback rule.
apk_name="ProLibertateGames-${tag}.apk" apk_name="PilgrimsOfKatan-${tag}.apk"
# The tag and the file name are both derived from gradle.properties and a run # The tag and the file name are both derived from gradle.properties and a run
# number, so neither can carry a quote or a backslash into this JSON. # number, so neither can carry a quote or a backslash into this JSON.
@@ -56,7 +56,7 @@ else
fi fi
cat > "$tmp/release.json" <<JSON cat > "$tmp/release.json" <<JSON
{"tag_name":"${tag}","target_commitish":"${target}","name":"Pro Libertate Games ${tag}","body":"${note}","prerelease":${prerelease},"draft":false} {"tag_name":"${tag}","target_commitish":"${target}","name":"Pilgrims of Katan ${tag}","body":"${note}","prerelease":${prerelease},"draft":false}
JSON JSON
code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' -X POST \ code="$(curl -sS -o "$tmp/create.json" -w '%{http_code}' -X POST \
+1 -1
View File
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "ProLibertateGames" rootProject.name = "PilgrimsOfKatan"
include(":app") include(":app")