Compare commits

...
Author SHA1 Message Date
devadmin 3deb764905 Merge pull request 'Katan: zoom the board in portrait' (#41) from feature/catan-portrait-zoom into dev
CI / Build and check (push) Successful in 11m20s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
CI / Build and check (pull_request) Successful in 11m32s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-12 19:14:59 -06:00
Claude da772bd326 Katan: pinch-zoom and pan in portrait; max zoom 6x
CI / Build and check (pull_request) Successful in 11m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-13 01:03:36 +00:00
devadmin dc73a71993 Merge pull request 'Katan: base-island scenarios (phase 6b)' (#40) from feature/catan-base-scenarios into dev
CI / Build and check (push) Successful in 11m32s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-11-12 12:25:57 -06:00
Claude 42334c3bf8 Katan: base-island scenarios (First Island, The Harbormaster)
CI / Build and check (pull_request) Successful in 11m29s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-12 18:14:00 +00:00
devadmin 421cbd4c70 Merge pull request 'Seafarers: Catan Universe extras (phase 6a)' (#39) from feature/catan-universe-extras into dev
CI / Build and check (push) Successful in 11m2s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-11-12 11:23:46 -06:00
Claude cd220396f9 Seafarers: Treasure Islands, Oases and the Great Canal (inferred rules)
CI / Build and check (pull_request) Successful in 11m3s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-12 17:11:47 +00:00
devadmin af279d3592 Merge pull request 'Seafarers: Pirate Islands and Wonders of Catan (phase 5b)' (#38) from feature/catan-pirates-wonders into dev
CI / Build and check (push) Successful in 11m30s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-11-11 23:33:45 -06:00
Claude 3315790688 Merge origin/dev
CI / Build and check (pull_request) Successful in 11m33s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-12 05:21:47 +00:00
Claude 20b6cb4b5f Seafarers: Pirate Islands and Wonders of Catan
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-12 05:21:46 +00:00
devadmin eef024848e Merge pull request 'Seafarers: Cloth for Catan and The Forgotten Tribe (phase 5a)' (#37) from feature/catan-cloth-tribe into dev
CI / Build and check (push) Successful in 11m16s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-11-11 17:05:22 -06:00
Claude c3f2efa313 Seafarers: Cloth for Catan and The Forgotten Tribe
CI / Build and check (pull_request) Successful in 11m3s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-11 22:53:25 +00:00
devadmin 385262515e Merge pull request 'Katan: Cities & Knights on Seafarers maps (phase 4)' (#36) from feature/catan-seafarers-ck into dev
CI / Build and check (push) Successful in 11m9s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-11-11 14:46:41 -06:00
Claude 7f3c2fe0e9 Katan: Cities & Knights on Seafarers maps
CI / Build and check (pull_request) Successful in 11m6s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Lift the one-expansion limit. Cities & Knights now reads the board from
state.topo, so it runs on any Seafarers map. Ships, knights (on land
posts reached by road or ship), the pirate (after the first barbarian
attack, robbing goods) and gold (chosen after the roll, before chores)
are wired into the C&K turn; first-landing bonuses count toward points.
Setup toggles stack, with a 10-16 point menu when both are on.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-11 20:35:19 +00:00
devadmin f8750684e6 Merge pull request 'Seafarers: The Fog Islands (phase 3)' (#35) from feature/catan-fog-islands into dev
CI / Build and check (push) Successful in 11m8s
CI / Deploy to Cloudflare Pages (push) Successful in 34s
2026-11-11 13:51:37 -06:00
Claude 87cf8decaa Seafarers: The Fog Islands scenario
CI / Build and check (pull_request) Successful in 11m7s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Add fog: land face down until a road or ship reaches it. The hidden
tiles live in SeaState.fogDeck (hidden from other seats' views); a find
pays one card of the resource or a gold choice, which resumes Road
Building if that was under way. Fog is always land, so the fixed
topology still holds. The AI is lured toward fog.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-11 19:40:18 +00:00
devadmin dcf553a8b1 Merge pull request 'Seafarers: Four Islands and Through the Desert (phase 2)' (#34) from feature/catan-sea-maps into dev
CI / Build and check (push) Successful in 10m56s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-11-11 11:28:48 -06:00
Claude 5e9bb2feaf Seafarers: Four Islands and Through the Desert maps
CI / Build and check (pull_request) Successful in 10m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Add two map-only scenarios. A scenario now says where the starting
settlements may go (SeaStart); SeaState.starts holds the starting
islands, and the first-landing bonus skips all of them. Saves without
starts fall back to the home island.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-11 17:17:57 +00:00
devadmin cc33dffd9a Merge pull request 'Katan: expansions as toggles, scenario in options (phase 1)' (#33) from feature/catan-expansions-toggle into dev
CI / Build and check (push) Successful in 10m40s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-11-11 11:01:42 -06:00
Claude 6c311ad304 Katan: expansions as toggles, scenario chosen in options
CI / Build and check (pull_request) Successful in 10m56s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Replace the single CatanRuleSet with a set of expansions and add a
scenario field; saves with the old ruleSet still decode. Setup shows
a toggle per expansion and a map picker for Seafarers. Combining
expansions is rejected for now (lifted in a later phase).

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-08 14:22:24 +00:00
devadmin a30b1dfcd0 Merge pull request 'Computer difficulty levels and hidden strategies' (#31) from feature/ai-difficulty into dev
CI / Build and check (push) Successful in 11m5s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-11-07 23:26:08 -06:00
36 changed files with 2387 additions and 244 deletions
+4 -4
View File
@@ -51,14 +51,14 @@ integration:
## Katan ## Katan
The island game is catalogue id `catan`, shown as **Katan**. It has two rule The island game is catalogue id `catan`, shown as **Katan**. It has two expansions
sets chosen at setup: the base game (`docs/RULES-catan.md`), Cities & Knights (toggles, `CatanOptions.expansions`; none on is the base game): the base game (`docs/RULES-catan.md`), Cities & Knights
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind (`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`CatanState.ck`, which is null in the base game) and Seafarers `CatanState.ck`, which is null in the base game) and Seafarers
(`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind (`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind
`CatanState.sea`). The base and Seafarers rules read the board from `state.topo`, not from `CatanState.sea`); the two can be switched on together. The base and Seafarers rules read the board from `state.topo`, not from
`CatanBoard` directly, because a Seafarers map is not the 19-hex island `CatanBoard` directly, because a Seafarers map is not the 19-hex island
(Cities & Knights is always the standard island and still uses `CatanBoard`). The (Cities & Knights also reads `state.topo`, so it runs on a Seafarers map too). The
Catan Universe reverse engineering that shaped the landscape HUD and trade board Catan Universe reverse engineering that shaped the landscape HUD and trade board
is in `docs/RE-catan-universe.md`. The screen is layered; see is in `docs/RE-catan-universe.md`. The screen is layered; see
`docs/UI-catan.md` before changing it. `docs/UI-catan.md` before changing it.
@@ -189,7 +189,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
have.toDouble() / cost.values.sum() + if (covers(hand, cost)) 1.0 else 0.0 have.toDouble() / cost.values.sum() + if (covers(hand, cost)) 1.0 else 0.0
} }
private fun chooseGold(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove { internal fun chooseGold(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val picks = legal.filterIsInstance<PickGold>() val picks = legal.filterIsInstance<PickGold>()
if (picks.isEmpty()) return legal.first() if (picks.isEmpty()) return legal.first()
return when (profile.level) { return when (profile.level) {
@@ -277,6 +277,11 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
pickBest(settlements, profile) { spotValue(state, it.vertex) }?.let { return it } pickBest(settlements, profile) { spotValue(state, it.vertex) }?.let { return it }
} }
// A raid on a fortress, or the next stage of a wonder, beats a card.
legal.filterIsInstance<RaidFortress>().firstOrNull()?.let { return it }
legal.filterIsInstance<DigCanal>().firstOrNull()?.let { return it }
legal.filterIsInstance<BuildWonder>().firstOrNull()?.let { return it }
// Seafarers: a ship toward an island nobody has landed on is worth more than a card. // Seafarers: a ship toward an island nobody has landed on is worth more than a card.
legal.filterIsInstance<BuildShip>() legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.5 } .filter { shipValue(state, seat, it.edge) > 0.5 }
@@ -592,6 +597,26 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/** What a seat would think of building on [vertex]: production and ports. */ /** What a seat would think of building on [vertex]: production and ports. */
/** What a spot beside a cloth village or an unclaimed tribe chip is worth beyond its cards. */
private fun scenarioLure(state: CatanState, vertex: Int): Double {
// The Harbormaster: a town on a harbour is a point toward a title worth two.
if (CatanRules.harbormasterGame(state) &&
state.topo.VERTEX_EDGES[vertex].any { e -> state.ports.any { it.edge == e } }
) return HARBOR_LURE
val sea = state.sea ?: return 0.0
var lure = 0.0
for (h in state.topo.VERTEX_HEXES[vertex]) {
// A cloth token is a quarter-point, rolled as often as its number comes up.
if (state.terrains[h] == Terrain.VILLAGE && sea.villageCloth.getOrElse(h) { 0 } > 0) {
lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH
}
// An unfinished canal tile is a place to dig: points now, grain land later.
if (sea.canalNumbers.getOrElse(h) { 0 } != 0 && state.terrains[h] == Terrain.DESERT) lure += CANAL_LURE
if (sea.chips.getOrNull(h).let { it != null && it != TribeChip.NONE }) lure += CHIP_LURE
}
return lure
}
internal fun spotValue(state: CatanState, vertex: Int): Double { internal fun spotValue(state: CatanState, vertex: Int): Double {
if (state.buildings[vertex] != null) return -1.0 if (state.buildings[vertex] != null) return -1.0
var value = 0.0 var value = 0.0
@@ -602,6 +627,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
kinds += resource kinds += resource
} }
value += 0.12 * kinds.size // variety keeps a build plan alive value += 0.12 * kinds.size // variety keeps a build plan alive
value += scenarioLure(state, vertex)
for (e in state.topo.VERTEX_EDGES[vertex]) { for (e in state.topo.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) 0.1 else 0.2 value += if (port.resource == null) 0.1 else 0.2
@@ -644,9 +670,16 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
val next = mutableListOf<Int>() val next = mutableListOf<Int>()
for (v in frontier) { for (v in frontier) {
if (!seen.add(v)) continue if (!seen.add(v)) continue
// Fog is worth a look: a card on arrival, and land to settle that nobody has seen.
if (topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) best = maxOf(best, FOG_LURE * Math.pow(0.85, depth.toDouble()))
// A fortress is worth a raid once a ship lies beside it.
if (sea.fortress.isNotEmpty() && topo.VERTEX_EDGES[v].any { e ->
topo.edgeSea[e] && topo.EDGE_HEXES[e].any { sea.fortress.getOrElse(it) { 0 } > 0 }
}
) best = maxOf(best, FORT_LURE * Math.pow(0.85, depth.toDouble()))
if (CatanRules.vertexOpenForSettlement(state, v)) { if (CatanRules.vertexOpenForSettlement(state, v)) {
val island = topo.vertexIsland[v] val island = topo.vertexIsland[v]
val fresh = island >= 0 && island != sea.home && sea.islandBonus[island] == NO_PLAYER val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble()) val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
best = maxOf(best, value) best = maxOf(best, value)
} }
@@ -673,5 +706,13 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
private companion object { private companion object {
/** A seven makes a seat holding more than this discard half. */ /** A seven makes a seat holding more than this discard half. */
const val DISCARD_LIMIT = 7 const val DISCARD_LIMIT = 7
/** What a ship end beside undiscovered land is worth, on the scale of [spotValue]. */
const val FOG_LURE = 0.6
const val FORT_LURE = 1.2
const val CLOTH_WORTH = 2.5
const val CHIP_LURE = 0.5
const val CANAL_LURE = 0.6
const val HARBOR_LURE = 0.5
} }
} }
@@ -429,6 +429,9 @@ object CatanBoard {
* tokens go to four of the six board-corner hexes, which are pairwise * tokens go to four of the six board-corner hexes, which are pairwise
* non-adjacent, and at most one corner can be desert. * non-adjacent, and at most one corner can be desert.
*/ */
/** The seed of the First Island scenario's fixed layout. */
const val FIRST_ISLAND_SEED = 1L
fun generate(seed: Long): Generated { fun generate(seed: Long): Generated {
val random = Random(seed) val random = Random(seed)
val terrains = TERRAIN_TILES.shuffled(random) val terrains = TERRAIN_TILES.shuffled(random)
@@ -171,18 +171,22 @@ object CatanCk {
/** Cities of [seat] that can carry a metropolis or fall to the barbarians' rival: all but metropolises. */ /** Cities of [seat] that can carry a metropolis or fall to the barbarians' rival: all but metropolises. */
private fun plainCities(state: CatanState, seat: Int): List<Int> = private fun plainCities(state: CatanState, seat: Int): List<Int> =
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && !hasMetropolis(state, v) state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && !hasMetropolis(state, v)
} }
private fun ownsCity(state: CatanState, seat: Int): Boolean = private fun ownsCity(state: CatanState, seat: Int): Boolean =
state.buildings.any { it == CatanBuilding(BuildingType.CITY, seat) } state.buildings.any { it == CatanBuilding(BuildingType.CITY, seat) }
/** Empty crossings at the end of one of [seat]'s roads: where a knight can be put. */ /** Whether [seat] has a road, or a ship in Seafarers, on [edge]: the network a knight moves along. */
private fun holds(state: CatanState, seat: Int, edge: Int): Boolean =
state.roads[edge] == seat || state.sea?.ships?.get(edge) == seat
/** Empty crossings at the end of one of [seat]'s roads or ships on land: where a knight can be put. */
private fun knightSpots(state: CatanState, seat: Int): List<Int> = private fun knightSpots(state: CatanState, seat: Int): List<Int> =
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == null && knightAt(state, v) == null && state.topo.vertexLand[v] && state.buildings[v] == null && knightAt(state, v) == null &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } state.topo.VERTEX_EDGES[v].any { holds(state, seat, it) }
} }
/** /**
@@ -196,9 +200,9 @@ object CatanCk {
val queue = ArrayDeque<Int>().apply { add(from) } val queue = ArrayDeque<Int>().apply { add(from) }
while (queue.isNotEmpty()) { while (queue.isNotEmpty()) {
val v = queue.removeFirst() val v = queue.removeFirst()
for (e in CatanBoard.VERTEX_EDGES[v]) { for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != seat) continue if (!holds(state, seat, e)) continue
val w = CatanBoard.otherEnd(e, v) val w = state.topo.otherEnd(e, v)
if (!seen.add(w)) continue if (!seen.add(w)) continue
val building = state.buildings[w] val building = state.buildings[w]
val knight = knightAt(state, w) val knight = knightAt(state, w)
@@ -215,14 +219,14 @@ object CatanCk {
private fun destinations(state: CatanState, k: Knight): List<Int> = private fun destinations(state: CatanState, k: Knight): List<Int> =
walk(state, k.owner, k.vertex).filter { w -> walk(state, k.owner, k.vertex).filter { w ->
val there = knightAt(state, w) val there = knightAt(state, w)
if (state.buildings[w] != null) return@filter false if (state.buildings[w] != null || !state.topo.vertexLand[w]) return@filter false
there == null || (there.owner != k.owner && there.level < k.level) there == null || (there.owner != k.owner && there.level < k.level)
}.sorted() }.sorted()
/** Empty crossings a driven-off [knight] of its owner could still reach. */ /** Empty crossings a driven-off [knight] of its owner could still reach. */
private fun refuges(state: CatanState, knight: Knight): List<Int> = private fun refuges(state: CatanState, knight: Knight): List<Int> =
walk(state, knight.owner, knight.vertex).filter { w -> walk(state, knight.owner, knight.vertex).filter { w ->
state.buildings[w] == null && knightAt(state, w) == null state.buildings[w] == null && state.topo.vertexLand[w] && knightAt(state, w) == null
}.sorted() }.sorted()
private fun promotable(state: CatanState, k: Knight): Boolean { private fun promotable(state: CatanState, k: Knight): Boolean {
@@ -251,6 +255,8 @@ object CatanCk {
points[seat] += ck.defenderVp[seat] + ck.progressVp[seat] points[seat] += ck.defenderVp[seat] + ck.progressVp[seat]
} }
ck.merchant?.let { points[it.seat] += 1 } ck.merchant?.let { points[it.seat] += 1 }
state.harbormaster?.let { points[it] += CatanRules.HARBORMASTER_POINTS }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList() return points.toList()
} }
@@ -266,15 +272,18 @@ object CatanCk {
addAll(actionMoves(state, seat)) addAll(actionMoves(state, seat))
} }
CatanPhase.ROBBER -> robberMoves(state, seat) // The bishop moves the robber alone; a seven may send the pirate instead.
CatanPhase.ROBBER -> robberMoves(state, seat) +
(if (state.isSea && !state.ck!!.bishop) SeaRules.pirateMoves(state, seat) else emptyList())
CatanPhase.PLAYING -> playingMoves(state, seat) CatanPhase.PLAYING -> playingMoves(state, seat)
CatanPhase.FREE_ROADS -> CatanPhase.FREE_ROADS ->
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads) CatanRules.roadMoves(state, seat) +
(if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) +
listOf<CatanMove>(EndFreeRoads)
// Seafarers is never combined with Cities & Knights. CatanPhase.GOLD -> SeaRules.goldMoves(state)
CatanPhase.GOLD -> emptyList()
CatanPhase.TRADE_PENDING -> { CatanPhase.TRADE_PENDING -> {
val offer = state.ck!!.goodsOffer val offer = state.ck!!.goodsOffer
@@ -327,7 +336,7 @@ object CatanCk {
if (!k.active && affordable(state, seat, ACTIVATE_COST)) moves += ActivateKnight(k.vertex) if (!k.active && affordable(state, seat, ACTIVATE_COST)) moves += ActivateKnight(k.vertex)
if (canAct(state, k)) { if (canAct(state, k)) {
destinations(state, k).forEach { moves += MoveKnight(k.vertex, it) } destinations(state, k).forEach { moves += MoveKnight(k.vertex, it) }
if (ck.attacked && k.vertex in CatanBoard.HEX_CORNERS[state.robber]) { if (ck.attacked && k.vertex in state.topo.HEX_CORNERS[state.robber]) {
moves += ChaseRobber(k.vertex) moves += ChaseRobber(k.vertex)
} }
} }
@@ -340,6 +349,11 @@ object CatanCk {
val moves = mutableListOf<CatanMove>() val moves = mutableListOf<CatanMove>()
if (affordable(state, seat, CatanRules.ROAD_COST)) moves += CatanRules.roadMoves(state, seat) if (affordable(state, seat, CatanRules.ROAD_COST)) moves += CatanRules.roadMoves(state, seat)
if (state.isSea) {
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
moves += SeaRules.moveShipMoves(state, seat)
moves += SeaRules.scenarioMoves(state, seat)
}
if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) { if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) {
moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) } moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) }
} }
@@ -380,7 +394,7 @@ object CatanCk {
/** Own cities with no wall, while the seat has a wall left to build. */ /** Own cities with no wall, while the seat has a wall left to build. */
private fun wallSites(state: CatanState, seat: Int): List<Int> { private fun wallSites(state: CatanState, seat: Int): List<Int> {
if (wallsOf(state, seat) >= MAX_WALLS) return emptyList() if (wallsOf(state, seat) >= MAX_WALLS) return emptyList()
return (0 until CatanBoard.VERTEX_COUNT).filter { v -> return (0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && v !in state.ck!!.walls state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && v !in state.ck!!.walls
} }
} }
@@ -423,20 +437,37 @@ object CatanCk {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> { private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val bishop = state.ck!!.bishop val bishop = state.ck!!.bishop
val moves = mutableListOf<MoveRobber>() val moves = mutableListOf<MoveRobber>()
for (h in 0 until CatanBoard.HEX_COUNT) { for (h in 0 until state.topo.HEX_COUNT) {
if (h == state.robber) continue if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG) continue
val victims = victimsAt(state, seat, h) val victims = victimsAt(state, seat, h)
when { when {
bishop -> moves += MoveRobber(CatanBoard.HEXES[h]) bishop -> moves += MoveRobber(state.topo.HEXES[h])
victims.isEmpty() -> moves += MoveRobber(CatanBoard.HEXES[h]) victims.isEmpty() -> moves += MoveRobber(state.topo.HEXES[h])
else -> victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) } else -> victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
} }
} }
return moves return moves
} }
/** The pirate, sent by a seven once the barbarians have landed: it robs a seat with a ship beside it. */
fun applyPirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1)
var stole = ""
val hand = move.victim?.let { goodsList(s, it) }.orEmpty()
if (move.victim != null && hand.isNotEmpty()) {
val card = hand[Random(state.seed * 1_000_003L + state.rngStep).nextInt(hand.size)]
s = give(s, move.victim, seat, listOf(card))
stole = " and takes a ${good(card)} from seat ${move.victim}"
}
return s.copy(
phase = if (state.ck!!.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING,
log = s.log + "Seat $seat sends the pirate to (${move.hex.q},${move.hex.r})$stole.",
)
}
private fun victimsAt(state: CatanState, seat: Int, hex: Int): List<Int> = private fun victimsAt(state: CatanState, seat: Int, hex: Int): List<Int> =
CatanBoard.HEX_CORNERS[hex] state.topo.HEX_CORNERS[hex]
.mapNotNull { state.buildings[it] } .mapNotNull { state.buildings[it] }
.map { it.owner } .map { it.owner }
.filter { it != seat } .filter { it != seat }
@@ -445,7 +476,7 @@ object CatanCk {
fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState { fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
val ck = state.ck!! val ck = state.ck!!
val hex = CatanBoard.hexIndex[move.hex] val hex = state.topo.hexIndex[move.hex]
?: throw IllegalArgumentException("No hex at ${move.hex}") ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1) var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
val rng = Random(state.seed * 1_000_003L + state.rngStep) val rng = Random(state.seed * 1_000_003L + state.rngStep)
@@ -495,7 +526,7 @@ object CatanCk {
ProgressType.ENGINEER -> wallSites(state, seat).map { PlayProgress(id, vertex = it) } ProgressType.ENGINEER -> wallSites(state, seat).map { PlayProgress(id, vertex = it) }
ProgressType.INVENTOR -> { ProgressType.INVENTOR -> {
val hexes = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] in INVENTOR_NUMBERS } val hexes = (0 until state.topo.HEX_COUNT).filter { state.numbers[it] in INVENTOR_NUMBERS }
hexes.flatMap { a -> hexes.flatMap { a ->
hexes.filter { b -> b > a && state.numbers[b] != state.numbers[a] } hexes.filter { b -> b > a && state.numbers[b] != state.numbers[a] }
.map { b -> PlayProgress(id, hexA = a, hexB = b) } .map { b -> PlayProgress(id, hexA = a, hexB = b) }
@@ -530,7 +561,7 @@ object CatanCk {
ProgressType.INTRIGUE -> ProgressType.INTRIGUE ->
ck.knights.filter { k -> ck.knights.filter { k ->
k.owner != seat && CatanBoard.VERTEX_EDGES[k.vertex].any { state.roads[it] == seat } k.owner != seat && state.topo.VERTEX_EDGES[k.vertex].any { holds(state, seat, it) }
}.map { PlayProgress(id, vertex = it.vertex) } }.map { PlayProgress(id, vertex = it.vertex) }
ProgressType.SABOTEUR -> listOf(PlayProgress(id)) ProgressType.SABOTEUR -> listOf(PlayProgress(id))
@@ -559,9 +590,9 @@ object CatanCk {
} }
ProgressType.MERCHANT -> ProgressType.MERCHANT ->
(0 until CatanBoard.HEX_COUNT).filter { h -> (0 until state.topo.HEX_COUNT).filter { h ->
state.terrains[h] != Terrain.DESERT && state.terrains[h] != Terrain.DESERT && state.topo.land[h] && state.terrains[h] != Terrain.FOG &&
CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
}.map { PlayProgress(id, hexA = it) } }.map { PlayProgress(id, hexA = it) }
ProgressType.MERCHANT_FLEET -> Good.entries.map { PlayProgress(id, good = it) } ProgressType.MERCHANT_FLEET -> Good.entries.map { PlayProgress(id, good = it) }
@@ -596,19 +627,19 @@ object CatanCk {
} }
private fun hexesNextTo(state: CatanState, seat: Int, terrain: Terrain): List<Int> = private fun hexesNextTo(state: CatanState, seat: Int, terrain: Terrain): List<Int> =
(0 until CatanBoard.HEX_COUNT).filter { h -> (0 until state.topo.HEX_COUNT).filter { h ->
state.terrains[h] == terrain && state.terrains[h] == terrain &&
CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
} }
/** Roads with a loose end: one end touching nothing else of anyone's. */ /** Roads with a loose end: one end touching nothing else of anyone's. */
private fun openRoads(state: CatanState): List<Int> = private fun openRoads(state: CatanState): List<Int> =
(0 until CatanBoard.EDGE_COUNT).filter { e -> (0 until state.topo.EDGE_COUNT).filter { e ->
if (state.roads[e] == NO_PLAYER) return@filter false if (state.roads[e] == NO_PLAYER) return@filter false
val (a, b) = CatanBoard.EDGE_VERTICES[e] val (a, b) = state.topo.EDGE_VERTICES[e]
listOf(a, b).any { v -> listOf(a, b).any { v ->
state.buildings[v] == null && knightAt(state, v) == null && state.buildings[v] == null && knightAt(state, v) == null &&
CatanBoard.VERTEX_EDGES[v].none { it != e && state.roads[it] != NO_PLAYER } state.topo.VERTEX_EDGES[v].none { it != e && state.roads[it] != NO_PLAYER }
} }
} }
@@ -1500,12 +1531,12 @@ object CatanCk {
private fun production(state: CatanState, number: Int): CatanState { private fun production(state: CatanState, number: Int): CatanState {
val gains = List(state.playerCount) { mutableMapOf<Good, Int>() } val gains = List(state.playerCount) { mutableMapOf<Good, Int>() }
for (h in 0 until CatanBoard.HEX_COUNT) { for (h in 0 until state.topo.HEX_COUNT) {
if (state.numbers[h] != number || h == state.robber) continue if (state.numbers[h] != number || h == state.robber) continue
val terrain = state.terrains[h] val terrain = state.terrains[h]
val resource = terrain.produces ?: continue val resource = terrain.produces ?: continue
val commodity = commodityOf(terrain) val commodity = commodityOf(terrain)
for (v in CatanBoard.HEX_CORNERS[h]) { for (v in state.topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue val building = state.buildings[v] ?: continue
val mine = gains[building.owner] val mine = gains[building.owner]
fun add(good: Good, n: Int) { mine[good] = (mine[good] ?: 0) + n } fun add(good: Good, n: Int) { mine[good] = (mine[good] ?: 0) + n }
@@ -1539,6 +1570,7 @@ object CatanCk {
} }
val said = if (notes.isEmpty()) "nothing for anyone" else notes.joinToString(", ") val said = if (notes.isEmpty()) "nothing for anyone" else notes.joinToString(", ")
s = s.copy(log = s.log + "The dice pay: $said.") s = s.copy(log = s.log + "The dice pay: $said.")
if (state.isSea) s = SeaRules.payCloth(s, number)
// The aqueduct: a seat that was paid nothing may take a resource of its choice. // The aqueduct: a seat that was paid nothing may take a resource of its choice.
val order = (0 until s.playerCount).map { (s.turn + it) % s.playerCount } val order = (0 until s.playerCount).map { (s.turn + it) % s.playerCount }
@@ -1546,7 +1578,11 @@ object CatanCk {
improvement(s, seat, Track.SCIENCE) >= IMPROVEMENT_POWER_LEVEL && gains[seat].values.sum() == 0 improvement(s, seat, Track.SCIENCE) >= IMPROVEMENT_POWER_LEVEL && gains[seat].values.sum() == 0
} }
val queued = enqueue(s, dry.map { AqueductChore(it) }, Back.AFTER_PRODUCTION) val queued = enqueue(s, dry.map { AqueductChore(it) }, Back.AFTER_PRODUCTION)
return if (queued.ck!!.chores.isNotEmpty()) queued else queued.copy(phase = CatanPhase.PLAYING) val next = if (queued.ck!!.chores.isNotEmpty()) queued else queued.copy(phase = CatanPhase.PLAYING)
// Gold is chosen before anything else; the chores (and then play) wait behind it.
val gold = if (state.isSea) CatanRules.goldOwed(state, number) else emptyList()
if (gold.isEmpty()) return next
return SeaRules.settleGold(next.copy(sea = next.sea!!.copy(gold = gold, afterGold = next.phase)))
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -53,7 +53,9 @@ internal class CatanCkAi(
CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile) CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile)
CatanPhase.DISCARDING, CatanPhase.GOLD, CatanPhase.GAME_OVER -> legal.firstOrNull() CatanPhase.GOLD -> ai.chooseGold(state, seat, legal, profile)
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}") ?: error("Nothing to choose in phase ${state.phase}")
} }
@@ -131,9 +133,9 @@ internal class CatanCkAi(
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] } val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
val victimWeight = 0.3 + 0.1 * profile.style.boldness val victimWeight = 0.3 + 0.1 * profile.style.boldness
fun score(move: MoveRobber): Double { fun score(move: MoveRobber): Double {
val hex = CatanBoard.hexIndex.getValue(move.hex) val hex = state.topo.hexIndex.getValue(move.hex)
var score = 0.0 var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) { for (v in state.topo.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue val building = state.buildings[v] ?: continue
val worth = if (building.type == BuildingType.CITY) 2 else 1 val worth = if (building.type == BuildingType.CITY) 2 else 1
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0 val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
@@ -178,7 +180,7 @@ internal class CatanCkAi(
// Chase the robber off a hex it is squatting on. // Chase the robber off a hex it is squatting on.
if (ck.attacked) { if (ck.attacked) {
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull() val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat } val robberHurtsMe = state.topo.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
if (onMine != null && robberHurtsMe) return onMine if (onMine != null && robberHurtsMe) return onMine
} }
@@ -212,6 +214,16 @@ internal class CatanCkAi(
settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it } settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it }
} }
legal.filterIsInstance<RaidFortress>().firstOrNull()?.let { return it }
legal.filterIsInstance<DigCanal>().firstOrNull()?.let { return it }
legal.filterIsInstance<BuildWonder>().firstOrNull()?.let { return it }
// Seafarers: a ship toward land nobody has settled is worth more than a card.
legal.filterIsInstance<BuildShip>()
.filter { ai.shipValue(state, seat, it.edge) > 0.5 }
.maxByOrNull { ai.shipValue(state, seat, it.edge) }
?.let { return it }
// Improvements spend cards nothing else needs. Favour the track furthest along. // Improvements spend cards nothing else needs. Favour the track furthest along.
legal.filterIsInstance<BuyImprovement>() legal.filterIsInstance<BuyImprovement>()
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) } .maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
@@ -226,6 +238,11 @@ internal class CatanCkAi(
if (CatanCk.goodsCount(state, seat) >= (if (boldness < 0) 5 else 7)) return it if (CatanCk.goodsCount(state, seat) >= (if (boldness < 0) 5 else 7)) return it
} }
legal.filterIsInstance<BuildShip>()
.filter { ai.shipValue(state, seat, it.edge) > 0.15 }
.maxByOrNull { ai.shipValue(state, seat, it.edge) }
?.let { return it }
legal.filterIsInstance<BuildRoad>() legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.1 - 0.04 * boldness } .filter { ai.roadValue(state, it) > 0.1 - 0.04 * boldness }
.maxByOrNull { ai.roadValue(state, it) } .maxByOrNull { ai.roadValue(state, it) }
@@ -360,7 +377,7 @@ internal class CatanCkAi(
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */ /** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
private fun production(state: CatanState, vertex: Int): Double = private fun production(state: CatanState, vertex: Int): Double =
CatanBoard.VERTEX_HEXES[vertex].sumOf { h -> state.topo.VERTEX_HEXES[vertex].sumOf { h ->
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0 val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0 want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
} }
@@ -1,7 +1,16 @@
package org.prolibertate.games.game.catan package org.prolibertate.games.game.catan
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */ /** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
const val NO_PLAYER = -1 const val NO_PLAYER = -1
@@ -45,6 +54,36 @@ enum class Terrain(val produces: Resource?) {
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */ /** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
GOLD(null), GOLD(null),
/**
* Seafarers: land not yet found. It becomes a resource or gold when a road or ship first reaches it
* (`SeaState.fogDeck`). Until then nothing is built on it, nothing produces, and the robber keeps off.
*/
FOG(null),
/** Seafarers, Cloth for Catan: a village. It pays cloth, not cards, to the settlements around it when its number rolls. */
VILLAGE(null),
/**
* Seafarers, Pirate Islands: a pirate fortress. It stands until ships raid it (`SeaState.fortress`), and
* nobody settles its island meanwhile. Once taken it becomes desert.
*/
FORTRESS(null),
}
/** Base-island scenarios: the board and goals of a game without Seafarers. Seafarers games choose a [SeaScenario] instead. */
@Serializable
enum class BaseScenario(val title: String, val blurb: String) {
RANDOM_MAP("Random map", "The usual island, its tiles and numbers shuffled for every game."),
FIRST_ISLAND(
"First Island",
"The same fixed island every game, so the set-up is not a surprise: good for a first game or a rematch.",
),
HARBORMASTER(
"The Harbormaster",
"Harbours matter. A settlement on a harbour counts a point and a city two; the first to three has the " +
"Harbormaster title, worth 2 victory points, and whoever gets more takes it.",
),
} }
/** Axial coordinate of one hex on the board. See [CatanBoard]. */ /** Axial coordinate of one hex on the board. See [CatanBoard]. */
@@ -151,12 +190,9 @@ enum class CatanPhase {
CHORE, CHORE,
} }
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */ /** An expansion a table can switch on. The base game is what is left with none selected. */
@Serializable @Serializable
enum class CatanRuleSet { enum class CatanExpansion {
/** The base game: development cards, Largest Army, a robber from the start. */
BASE,
/** Cities & Knights: commodities, knights, barbarians, city improvements. */ /** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS, CITIES_AND_KNIGHTS,
@@ -164,16 +200,59 @@ enum class CatanRuleSet {
SEAFARERS, SEAFARERS,
} }
@Serializable /**
* What a table plays: the expansions switched on, and the Seafarers map when that one is.
*
* Saves written before expansions became toggles carry `"ruleSet"` instead of `"expansions"`;
* [CatanOptionsSerializer] reads either.
*/
@Serializable(with = CatanOptionsSerializer::class)
data class CatanOptions( data class CatanOptions(
val playerCount: Int = 3, val playerCount: Int = 3,
val pointsToWin: Int = 10, val pointsToWin: Int = 10,
val ruleSet: CatanRuleSet = CatanRuleSet.BASE, val expansions: Set<CatanExpansion> = emptySet(),
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
) { ) {
init { init {
require(playerCount in 3..4) { "The island seats three or four players" } require(playerCount in 3..4) { "The island seats three or four players" }
require(pointsToWin >= 2) { "pointsToWin must be at least 2" } require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
} }
val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions
val citiesAndKnights: Boolean get() = CatanExpansion.CITIES_AND_KNIGHTS in expansions
}
/** The wire shape of [CatanOptions]. */
@Serializable
private class CatanOptionsWire(
val playerCount: Int = 3,
val pointsToWin: Int = 10,
val expansions: Set<CatanExpansion> = emptySet(),
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
)
/** Reads the legacy single `ruleSet` field as an expansion set. */
object CatanOptionsSerializer : KSerializer<CatanOptions> {
override val descriptor: SerialDescriptor = CatanOptionsWire.serializer().descriptor
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
CatanOptionsWire.serializer(),
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario, value.baseScenario),
)
override fun deserialize(decoder: Decoder): CatanOptions {
val input = decoder as JsonDecoder
var obj = input.decodeJsonElement().jsonObject
val legacy = (obj["ruleSet"] as? JsonPrimitive)?.content
if (legacy != null) {
val expansions = if (legacy == "BASE") emptyList() else listOf(JsonPrimitive(legacy))
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
}
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario, wire.baseScenario)
}
} }
/** /**
@@ -198,6 +277,8 @@ data class CatanState(
val turn: Int, val turn: Int,
/** How many setup placements are done, 0 until 2 * playerCount. */ /** How many setup placements are done, 0 until 2 * playerCount. */
val setupIndex: Int, val setupIndex: Int,
/** The seat that places first and takes the first turn; drawn from the table seed. */
val firstSeat: Int = 0,
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */ /** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
val turnNumber: Int, val turnNumber: Int,
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */ /** Terrain per hex, indexed like [CatanBoard.HEXES]. */
@@ -242,6 +323,8 @@ data class CatanState(
val log: List<String>, val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */ /** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null, val ck: CkState? = null,
/** The Harbormaster scenario: the seat holding the title, or null. */
val harbormaster: Int? = null,
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */ /** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
val sea: SeaState? = null, val sea: SeaState? = null,
) { ) {
@@ -414,12 +497,85 @@ data class SeaState(
val shipMoved: Boolean = false, val shipMoved: Boolean = false,
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */ /** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
val islandBonus: List<Int>, val islandBonus: List<Int>,
/** The island the players start on. */ /** The island that counts as home: the largest, and the pirate-free start of the original map. */
val home: Int, val home: Int,
/** The islands the starting settlements may go on. Empty in a save from before there were several: just [home]. */
val starts: List<Int> = emptyList(),
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */ /** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
val gold: List<GoldOwed> = emptyList(), val gold: List<GoldOwed> = emptyList(),
/**
* The tiles still face down, next to be found first. Hidden from every view but the host's, like the
* development deck.
*/
val fogDeck: List<FogTile> = emptyList(),
/** Where the game goes when the gold queue empties: [CatanPhase.PLAYING] unless a find interrupted something else. */
val afterGold: CatanPhase? = null,
/** Cloth tokens each seat holds (Cloth for Catan); empty when the scenario has none. Two make a victory point. */
val cloth: List<Int> = emptyList(),
/** Cloth still on each hex's village, by hex (Cloth for Catan); empty when the scenario has none. */
val villageCloth: List<Int> = emptyList(),
/** The tribe chip waiting on each hex (Forgotten Tribe), by hex; empty when the scenario has none. */
val chips: List<TribeChip> = emptyList(),
/** Seats that took a harbour chip and so trade 3:1 anywhere. */
val harbourSeats: List<Int> = emptyList(),
/** Victory points each seat holds from tribe chips; empty until one is taken. */
val chipPoints: List<Int> = emptyList(),
/** Strength still left in each hex's pirate fortress, by hex (Pirate Islands); empty when the map has none. */
val fortress: List<Int> = emptyList(),
/** Victory points each seat holds for taking fortresses; empty until one falls. */
val raidPoints: List<Int> = emptyList(),
/** Wonder stages each seat has built (Wonders of Catan), by seat; empty when the scenario has none. */
val wonders: List<Int> = emptyList(),
/** The chest under each hex of fog, by hex (Treasure Islands, Oases); found when the hex is. Hidden from other views. */
val treasures: List<Treasure> = emptyList(),
/** Units dug so far into each canal tile, by hex (Great Canal); empty when the map has none. */
val canalUnits: List<Int> = emptyList(),
/** The number token waiting under each canal tile, by hex; it shows when the tile is finished. */
val canalNumbers: List<Int> = emptyList(),
/** Canal units each seat has dug; every two are a victory point. */
val canalDug: List<Int> = emptyList(),
) )
/** What a treasure chest holds (Treasure Islands). */
@Serializable
enum class Treasure {
NONE,
/** One brick, one wool and one grain. */
BRICK_WOOL_GRAIN,
/** One resource of the finder's choice. */
CHOICE,
/** A development card (a resource of choice in Cities & Knights, which has none). */
DEV_CARD,
/** Two roads' worth of cards: two lumber and two brick. */
ROADS,
}
/** What the first settler beside a tribe hex takes (Forgotten Tribe). */
@Serializable
enum class TribeChip {
NONE,
/** One victory point. */
POINT,
/** A 3:1 harbour, good wherever the seat trades. */
HARBOUR,
/** Two resources of the seat's choice. */
GOLD,
}
/** One tile of the fog: what the hex turns out to be and the number token that comes with it. */
@Serializable
data class FogTile(val terrain: Terrain, val number: Int)
/** Whether [island] is one the game starts on, and so never earns the new-island bonus. */
fun SeaState.isStart(island: Int): Boolean = island == home || island in starts
@Serializable @Serializable
data class GoldOwed(val seat: Int, val count: Int) data class GoldOwed(val seat: Int, val count: Int)
@@ -442,3 +598,18 @@ data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
@Serializable @Serializable
@SerialName("gold") @SerialName("gold")
data class PickGold(val cards: List<Resource>) : CatanMove data class PickGold(val cards: List<Resource>) : CatanMove
/** Raid the pirate fortress on [hex] from a ship beside it: 1 ore, 1 wool and 1 grain take one point of its strength. */
@Serializable
@SerialName("raid")
data class RaidFortress(val hex: HexCoord) : CatanMove
/** Build the next stage of your wonder, which needs a city of yours; the stage's cost is in `SeaRules.WONDER_STAGES`. */
@Serializable
@SerialName("wonder")
data object BuildWonder : CatanMove
/** Dig one unit of the canal tile on [hex]: 1 brick and 1 ore, from a seat with a settlement or city beside it. */
@Serializable
@SerialName("dig")
data class DigCanal(val hex: HexCoord) : CatanMove
@@ -54,15 +54,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
"Seat count ${config.seats.size} does not match ${options.playerCount} players" "Seat count ${config.seats.size} does not match ${options.playerCount} players"
} }
val sea = options.ruleSet == CatanRuleSet.SEAFARERS val sea = options.seafarers
val scenario = SeaScenario.NEW_SHORES val scenario = options.scenario
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) } val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
?: CatanBoard.generate(config.seed) ?: CatanBoard.generate(
if (options.baseScenario == BaseScenario.FIRST_ISLAND) CatanBoard.FIRST_ISLAND_SEED else config.seed,
)
// A different random stream than the island, so the same seed still // A different random stream than the island, so the same seed still
// produces both a fixed island and a fixed deck. // produces both a fixed island and a fixed deck.
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS val ck = options.citiesAndKnights
// Cities & Knights has no development cards: knights and progress // Cities & Knights has no development cards: knights and progress
// cards take their place. // cards take their place.
val devDeck = if (ck) { val devDeck = if (ck) {
@@ -73,12 +75,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
.shuffled(Random(config.seed + 1)) .shuffled(Random(config.seed + 1))
} }
// Seat 0 used to lead every game; a separate stream from the board and
// the deck keeps the same seed giving the same table.
val firstSeat = Random(config.seed + 2).nextInt(options.playerCount)
return CatanState( return CatanState(
options = options, options = options,
seed = config.seed, seed = config.seed,
phase = CatanPhase.SETUP_FIRST, phase = CatanPhase.SETUP_FIRST,
turn = 0, turn = firstSeat,
setupIndex = 0, setupIndex = 0,
firstSeat = firstSeat,
turnNumber = 0, turnNumber = 0,
terrains = board.terrains, terrains = board.terrains,
numbers = board.numbers, numbers = board.numbers,
@@ -114,6 +121,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
pirate = it.pirate, pirate = it.pirate,
islandBonus = List(topo.islandCount) { NO_PLAYER }, islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home, home = it.home,
starts = it.starts,
fogDeck = it.fogDeck,
cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 },
villageCloth = it.villageCloth,
chips = it.chips,
fortress = it.fortress,
treasures = it.treasures,
canalUnits = if (it.canalNumbers.isEmpty()) emptyList() else List(topo.HEX_COUNT) { 0 },
canalNumbers = it.canalNumbers,
canalDug = if (it.canalNumbers.isEmpty()) emptyList() else List(options.playerCount) { 0 },
wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(),
) )
}, },
) )
@@ -121,8 +139,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
override fun currentSeat(state: CatanState): Int? = when (state.phase) { override fun currentSeat(state: CatanState): Int? = when (state.phase) {
CatanPhase.GAME_OVER -> null CatanPhase.GAME_OVER -> null
CatanPhase.SETUP_FIRST -> state.setupIndex CatanPhase.SETUP_FIRST -> (state.firstSeat + state.setupIndex) % state.playerCount
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex CatanPhase.SETUP_SECOND ->
(state.firstSeat + 2 * state.playerCount - 1 - state.setupIndex) % state.playerCount
CatanPhase.DISCARDING -> state.discardSeat CatanPhase.DISCARDING -> state.discardSeat
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 } CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 } ?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
@@ -201,8 +220,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val moves = mutableListOf<SetupPlace>() val moves = mutableListOf<SetupPlace>()
for (v in 0 until state.topo.VERTEX_COUNT) { for (v in 0 until state.topo.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue if (!vertexOpenForSettlement(state, v)) continue
// Seafarers: everyone starts on the home island. // Seafarers: the starting settlements go on a starting island.
if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue if (state.sea != null && !state.sea.isStart(state.topo.vertexIsland[v])) continue
// Nobody starts beside land that has not been found.
if (state.topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) continue
for (e in state.topo.VERTEX_EDGES[v]) { for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
moves += SetupPlace(v, e) moves += SetupPlace(v, e)
@@ -238,7 +259,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> { private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val moves = mutableListOf<MoveRobber>() val moves = mutableListOf<MoveRobber>()
for (h in 0 until state.topo.HEX_COUNT) { for (h in 0 until state.topo.HEX_COUNT) {
if (h == state.robber || !state.topo.land[h]) continue if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG || state.terrains[h] == Terrain.FORTRESS) continue
val victims = state.topo.HEX_CORNERS[h] val victims = state.topo.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] } .mapNotNull { state.buildings[it] }
.map { it.owner } .map { it.owner }
@@ -270,6 +291,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.isSea) { if (state.isSea) {
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat) if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
moves += SeaRules.moveShipMoves(state, seat) moves += SeaRules.moveShipMoves(state, seat)
moves += SeaRules.scenarioMoves(state, seat)
} }
if (!state.devCardPlayedThisTurn) { if (!state.devCardPlayedThisTurn) {
@@ -404,6 +426,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
/** An empty vertex with no building on it or one edge away. */ /** An empty vertex with no building on it or one edge away. */
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] && state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
!SeaRules.fortressHolds(state, v) &&
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/** /**
@@ -457,6 +480,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is MoveShip -> applyMoveShip(state, seat, move) is MoveShip -> applyMoveShip(state, seat, move)
is MovePirate -> applyMovePirate(state, seat, move) is MovePirate -> applyMovePirate(state, seat, move)
is PickGold -> applyPickGold(state, seat, move) is PickGold -> applyPickGold(state, seat, move)
is RaidFortress -> SeaRules.applyRaid(state, seat, move)
is DigCanal -> SeaRules.applyDig(state, seat, move)
BuildWonder -> SeaRules.applyWonder(state, seat)
is BuildRoad -> applyBuildRoad(state, seat, move) is BuildRoad -> applyBuildRoad(state, seat, move)
is BuildSettlement -> applyBuildSettlement(state, seat, move) is BuildSettlement -> applyBuildSettlement(state, seat, move)
is BuildCity -> applyBuildCity(state, seat, move) is BuildCity -> applyBuildCity(state, seat, move)
@@ -508,13 +534,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
val newIndex = state.setupIndex + 1 val newIndex = state.setupIndex + 1
s = s.copy(setupIndex = newIndex) s = refreshHarbormaster(s.copy(setupIndex = newIndex))
return when { return when {
newIndex >= 2 * n -> s.copy( newIndex >= 2 * n -> s.copy(
phase = CatanPhase.AWAIT_ROLL, phase = CatanPhase.AWAIT_ROLL,
turn = 0, turn = state.firstSeat,
turnNumber = 1, turnNumber = 1,
log = s.log + "Setup done — seat 0 leads.", log = s.log + "Setup done — seat ${state.firstSeat} leads.",
) )
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND) newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
@@ -586,12 +612,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy( s = s.copy(
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.", log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
) )
if (state.isSea) s = SeaRules.payCloth(s, number)
if (goldOwed.isEmpty()) return s if (goldOwed.isEmpty()) return s
return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed))) return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed)))
} }
/** What each seat is owed for gold fields showing [number], in seat order from the roller. */ /** What each seat is owed for gold fields showing [number], in seat order from the roller. */
private fun goldOwed(state: CatanState, number: Int): List<GoldOwed> { internal fun goldOwed(state: CatanState, number: Int): List<GoldOwed> {
val topo = state.topo val topo = state.topo
val owed = IntArray(state.playerCount) val owed = IntArray(state.playerCount)
for (h in 0 until topo.HEX_COUNT) { for (h in 0 until topo.HEX_COUNT) {
@@ -654,7 +681,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(pendingFreeRoads = left) s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = phaseAfterRoads(s)) if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
} }
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")) s = s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")
if (state.sea != null) s = SeaRules.reveal(s, seat, move.edge)
return checkWin(s)
} }
private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState { private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState {
@@ -673,7 +702,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(pendingFreeRoads = left) s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = phaseAfterRoads(s)) if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
} }
return checkWin(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}.")) return checkWin(SeaRules.reveal(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."), seat, move.edge))
} }
private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState { private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState {
@@ -686,12 +715,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val s = state.copy( val s = state.copy(
sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to), sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to),
) )
return checkWin(refreshLongestRoad(s).let { return checkWin(
it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.") SeaRules.reveal(
}) refreshLongestRoad(s).let { it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.") },
seat,
move.to,
),
)
} }
private fun applyMovePirate(state: CatanState, seat: Int, move: MovePirate): CatanState { private fun applyMovePirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
if (state.isCk) return CatanCk.applyPirate(state, seat, move)
val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}") val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1) var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1)
var stolen = "" var stolen = ""
@@ -726,7 +760,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}, },
) )
s = payCost(s, seat, SETTLEMENT_COST) s = payCost(s, seat, SETTLEMENT_COST)
s = SeaRules.claimIsland(s, seat, move.vertex) s = SeaRules.settled(s, seat, move.vertex)
// A settlement can break another player's road, so the award is // A settlement can break another player's road, so the award is
// recomputed for everyone. // recomputed for everyone.
s = refreshLongestRoad(s) s = refreshLongestRoad(s)
@@ -991,16 +1025,64 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
state.longestRoadOwner?.let { points[it] += 5 } state.longestRoadOwner?.let { points[it] += 5 }
state.largestArmyOwner?.let { points[it] += 5 } state.largestArmyOwner?.let { points[it] += 5 }
state.harbormaster?.let { points[it] += HARBORMASTER_POINTS }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat) if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList() return points.toList()
} }
// -----------------------------------------------------------------------
// The Harbormaster
// -----------------------------------------------------------------------
/** The scenario's title: points for a harbour town and for the Harbormaster. */
const val HARBORMASTER_MINIMUM = 3
const val HARBORMASTER_POINTS = 2
/** Whether this table plays the Harbormaster (never in a Seafarers game, which has its own maps). */
internal fun harbormasterGame(state: CatanState): Boolean =
state.options.baseScenario == BaseScenario.HARBORMASTER && !state.isSea
/** Harbour points: a settlement on a harbour counts 1, a city 2, for each harbour it touches. */
fun harbourPoints(state: CatanState, seat: Int): Int {
var total = 0
for (port in state.ports) {
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
val own = listOfNotNull(state.buildings[a], state.buildings[b]).firstOrNull { it.owner == seat }
if (own != null) total += if (own.type == BuildingType.CITY) 2 else 1
}
return total
}
/** Gives the title to the first seat with three harbour points, and to a seat with strictly more than the holder. */
internal fun refreshHarbormaster(state: CatanState): CatanState {
if (!harbormasterGame(state)) return state
val points = List(state.playerCount) { harbourPoints(state, it) }
val holder = state.harbormaster
val best = points.max()
val leaders = points.indices.filter { points[it] == best }
val next = when {
holder == null -> leaders.singleOrNull()?.takeIf { best >= HARBORMASTER_MINIMUM }
best > points[holder] -> leaders.singleOrNull() ?: holder
else -> holder
}
if (next == holder) return state
return state.copy(
harbormaster = next,
log = state.log + if (holder == null) {
"Seat $next is now harbormaster."
} else {
"Seat $next has taken the title harbormaster from seat $holder."
},
)
}
fun victoryPoints(state: CatanState, seat: Int): Int = fun victoryPoints(state: CatanState, seat: Int): Int =
publicVictoryPoints(state)[seat] + publicVictoryPoints(state)[seat] +
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT } state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
internal fun checkWin(state: CatanState): CatanState { internal fun checkWin(settled: CatanState): CatanState {
if (state.phase == CatanPhase.GAME_OVER) return state if (settled.phase == CatanPhase.GAME_OVER) return settled
val state = refreshHarbormaster(settled)
val seat = state.turn val seat = state.turn
val points = victoryPoints(state, seat) val points = victoryPoints(state, seat)
if (points < state.options.pointsToWin) return state if (points < state.options.pointsToWin) return state
@@ -1129,7 +1211,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
cards.groupingBy { it }.eachCount() cards.groupingBy { it }.eachCount()
/** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */ /** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */
private fun withCounts( internal fun withCounts(
resources: List<Map<Resource, Int>>, resources: List<Map<Resource, Int>>,
seat: Int, seat: Int,
delta: Map<Resource, Int>, delta: Map<Resource, Int>,
@@ -1167,6 +1249,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
if (mine) best = port.ratio if (mine) best = port.ratio
} }
if (best > 3 && state.sea?.harbourSeats?.contains(seat) == true) best = 3
return best return best
} }
@@ -1177,7 +1260,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val (a, b) = state.topo.EDGE_VERTICES[port.edge] val (a, b) = state.topo.EDGE_VERTICES[port.edge]
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
} }
return 4 return if (state.sea?.harbourSeats?.contains(seat) == true) 3 else 4
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1198,6 +1281,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
state.copy( state.copy(
// Hidden information stays with the holder and the host. // Hidden information stays with the holder and the host.
devDeck = emptyList(), devDeck = emptyList(),
sea = state.sea?.copy(fogDeck = emptyList(), treasures = emptyList()),
devHands = state.devHands.mapIndexed { index, hand -> devHands = state.devHands.mapIndexed { index, hand ->
if (index == seat) hand else emptyList() if (index == seat) hand else emptyList()
}, },
@@ -5,11 +5,69 @@ import kotlin.random.Random
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */ /** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
@Serializable @Serializable
enum class SeaScenario(val title: String, val blurb: String) { enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart = SeaStart.HOME_ONLY) {
NEW_SHORES( NEW_SHORES(
"Heading for New Shores", "Heading for New Shores",
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.", "One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
), ),
FOUR_ISLANDS(
"Four Islands",
"Four equal islands, one start each, and two gold islets in the middle. Settle your own island, then sail.",
SeaStart.MAIN_ISLANDS,
),
THROUGH_THE_DESERT(
"Through the Desert",
"One island cut by a broad desert that yields nothing. Reach the far side by road, or sail round to the islets.",
),
FOG_ISLANDS(
"The Fog Islands",
"A home island, an islet, and a bank of fog. Land in the fog is face down until a road or ship reaches it; " +
"the finder takes a card of what lies there.",
),
TREASURE_ISLANDS(
"The Treasure Islands",
"Chests lie hidden on the fog-covered isles beside a home island. Whoever uncovers one opens it: " +
"cards, a resource of choice, a development card or a road's worth of materials.",
),
OASES(
"The Oases",
"An enormous desert, with fertile oases hidden inside it. Roads that reach an oasis uncover it " +
"and whatever treasure is buried there.",
),
GREAT_CANAL(
"The Great Canal",
"A desert strip splits the island. Dig the canal tile by tile (a brick and an ore a unit) and each finished " +
"tile becomes grain land; every two units dug are a victory point.",
),
PIRATE_ISLANDS(
"Pirate Islands",
"Two isles are held by pirate fortresses. Sail ships beside one and raid it with ore, wool and grain; " +
"nobody may settle the isle until the fortress falls, and the one who takes it earns two points.",
),
WONDERS_OF_CATAN(
"Wonders of Catan",
"Two big islands and room to grow. Raise a city, then build a wonder in four stages; each stage is a " +
"victory point and the first to finish all four wins.",
),
CLOTH_FOR_CATAN(
"Cloth for Catan",
"Four villages weave cloth. Settle beside one and it pays you a cloth token whenever its number rolls; " +
"every two tokens are a victory point.",
),
FORGOTTEN_TRIBE(
"The Forgotten Tribe",
"Four small isles hold the chips of a lost tribe. The first to settle beside one takes it: " +
"a victory point, a harbour, or gold.",
),
}
/** Where the starting settlements may go. */
enum class SeaStart {
/** The largest island only. */
HOME_ONLY,
/** Any island big enough to live on (four hexes or more); the small ones are prizes to sail to. */
MAIN_ISLANDS,
} }
/** /**
@@ -33,8 +91,134 @@ object SeaBoards {
"..........", "..........",
) )
private val FOUR_ISLANDS_MAP = listOf(
"............",
"..###..###..",
"..###..###..",
"............",
"..$.....$...",
"............",
"..###..###..",
"..###..###..",
"............",
)
private val THROUGH_THE_DESERT_MAP = listOf(
"............",
".###DD###...",
".###DDD###..",
"..###DD###..",
"............",
"..$.....$...",
"............",
"...##...#...",
"............",
)
/** `F` is fog: land that is face down until found. */
private val FOG_ISLANDS_MAP = listOf(
"............",
".###....FF..",
".####..FFF..",
"..#D#..FFFF.",
".###...FFF..",
".......FF...",
"...##.......",
"............",
)
/** `V` is a village; `t` is a resource hex that carries a tribe chip. */
private val CLOTH_FOR_CATAN_MAP = listOf(
"............",
"..V###..V##.",
"..#####..##.",
"..####V..#..",
"...###......",
".......##V..",
"............",
"...##.......",
"............",
)
private val FORGOTTEN_TRIBE_MAP = listOf(
"............",
"..###..t#...",
"..#####..#t.",
"..#####..#..",
"..####......",
"........t##.",
".#t.........",
"............",
)
private val TREASURE_ISLANDS_MAP = listOf(
"............",
"..###..FF...",
"..####..FF..",
"..#####.....",
"..###...F...",
".......FF...",
"..F.........",
"............",
)
private val OASES_MAP = listOf(
"............",
"..###DD###..",
"..#DDFDD#...",
"..#DDDFD#...",
"..#DFDDD#...",
"..###DD###..",
"............",
)
/** `C` is a canal tile: desert with a number hidden under it until it is dug out. */
private val GREAT_CANAL_MAP = listOf(
"............",
"..#####.....",
"..#####.....",
"..CCCCC.....",
"..#####.....",
"..#####.....",
"........$$..",
"............",
)
/** `P` is a pirate fortress. */
private val PIRATE_ISLANDS_MAP = listOf(
"............",
"..####.P##..",
"..####..##..",
"..####......",
"..####..P#..",
"...##...##..",
"............",
"............",
)
private val WONDERS_OF_CATAN_MAP = listOf(
"............",
"..####.###..",
".#####.####.",
"..###D.###..",
".......##...",
"..$......$..",
"...##.......",
"............",
)
private fun rows(scenario: SeaScenario): List<String> = when (scenario) { private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP
SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP
SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP
SeaScenario.TREASURE_ISLANDS -> TREASURE_ISLANDS_MAP
SeaScenario.OASES -> OASES_MAP
SeaScenario.GREAT_CANAL -> GREAT_CANAL_MAP
SeaScenario.PIRATE_ISLANDS -> PIRATE_ISLANDS_MAP
SeaScenario.WONDERS_OF_CATAN -> WONDERS_OF_CATAN_MAP
SeaScenario.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP
SeaScenario.FORGOTTEN_TRIBE -> FORGOTTEN_TRIBE_MAP
} }
private val topologies = HashMap<SeaScenario, Topology>() private val topologies = HashMap<SeaScenario, Topology>()
@@ -72,8 +256,34 @@ object SeaBoards {
val pirate: Int, val pirate: Int,
val ports: List<Port>, val ports: List<Port>,
val home: Int, val home: Int,
/** The islands the starting settlements may go on; always includes [home]. */
val starts: List<Int> = listOf(home),
/** The tiles under the fog, in the order they will be found. */
val fogDeck: List<FogTile> = emptyList(),
/** The tribe chip on each hex, or [TribeChip.NONE]; empty when the map has none. */
val chips: List<TribeChip> = emptyList(),
/** Cloth waiting on each village, by hex; empty when the map has none. */
val villageCloth: List<Int> = emptyList(),
/** Strength of the fortress on each hex, by hex; empty when the map has none. */
val fortress: List<Int> = emptyList(),
/** The chest under each hex, by hex; empty when the map has none. */
val treasures: List<Treasure> = emptyList(),
/** The number under each canal tile, by hex; empty when the map has none. */
val canalNumbers: List<Int> = emptyList(),
) )
/** An island of this many land hexes or more can be started on. */
private const val MAIN_ISLAND_HEXES = 4
/** Cloth each village starts with. */
const val VILLAGE_CLOTH = 6
/** Ships' raids a pirate fortress takes to fall. */
const val FORTRESS_STRENGTH = 3
/** Chests dealt to the fog, at most. */
private const val CHESTS = 5
private val RESOURCE_TILES = listOf( private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS, Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
) )
@@ -88,19 +298,24 @@ object SeaBoards {
val random = Random(seed) val random = Random(seed)
// Resources fill the '#' hexes as evenly as the count allows, in shuffled order. // Resources fill the '#' hexes as evenly as the count allows, in shuffled order.
val plain = kinds.indices.filter { kinds[it] == '#' } val plain = kinds.indices.filter { kinds[it] == '#' || kinds[it] == 't' }
val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random) val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random)
val terrains = MutableList(kinds.size) { Terrain.SEA } val terrains = MutableList(kinds.size) { Terrain.SEA }
kinds.forEachIndexed { h, ch -> kinds.forEachIndexed { h, ch ->
terrains[h] = when (ch) { terrains[h] = when (ch) {
'$' -> Terrain.GOLD '$' -> Terrain.GOLD
'D' -> Terrain.DESERT 'D' -> Terrain.DESERT
'#' -> pool[plain.indexOf(h)] '#', 't' -> pool[plain.indexOf(h)]
'V' -> Terrain.VILLAGE
'P' -> Terrain.FORTRESS
'C' -> Terrain.DESERT
'F' -> Terrain.FOG
else -> Terrain.SEA else -> Terrain.SEA
} }
} }
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT } // The fog hexes take part as slots, so the tokens they hide are dealt from the same pile.
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && (terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS || kinds[it] == 'C') }
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" } check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
val tokens = TOKENS.take(numbered.size).shuffled(random) val tokens = TOKENS.take(numbered.size).shuffled(random)
val numbers = IntArray(terrains.size) val numbers = IntArray(terrains.size)
@@ -119,6 +334,14 @@ object SeaBoards {
attempt++ attempt++
} }
// What lies under the fog: mostly resources, one gold field in five, drawn from the same tokens.
val fogHexes = terrains.indices.filter { terrains[it] == Terrain.FOG }
val fogKinds = List(fogHexes.size) { i ->
if (i % 5 == 4) Terrain.GOLD else RESOURCE_TILES[i % RESOURCE_TILES.size]
}.shuffled(random)
val fogDeck = fogHexes.mapIndexed { i, h -> FogTile(fogKinds[i], numbers[h]) }.shuffled(random)
fogHexes.forEach { numbers[it] = 0 }
val portTypes = ( val portTypes = (
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null } listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
).shuffled(random) ).shuffled(random)
@@ -129,7 +352,12 @@ object SeaBoards {
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 } val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
?: numbered.first() ?: numbered.first()
val home = (0 until topo.islandCount).maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0 // Home is the largest island that is not under the fog: nobody starts on land they cannot see.
val fogged = (0 until topo.islandCount).filter { island ->
topo.islandOf.indices.filter { topo.islandOf[it] == island }.all { terrains[it] == Terrain.FOG }
}.toSet()
val home = (0 until topo.islandCount).filter { it !in fogged }
.maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
// The pirate starts in the open sea: the sea hex farthest from every shore. // The pirate starts in the open sea: the sea hex farthest from every shore.
val sea = terrains.indices.filter { terrains[it] == Terrain.SEA } val sea = terrains.indices.filter { terrains[it] == Terrain.SEA }
val pirate = sea.maxByOrNull { h -> val pirate = sea.maxByOrNull { h ->
@@ -139,6 +367,52 @@ object SeaBoards {
} ?: 0 } ?: 0
} ?: sea.first() } ?: sea.first()
return Generated(terrains, numbers.toList(), desert, pirate, ports, home) val starts = when (scenario.start) {
SeaStart.HOME_ONLY -> listOf(home)
SeaStart.MAIN_ISLANDS -> (0 until topo.islandCount).filter { island ->
topo.islandOf.count { it == island } >= MAIN_ISLAND_HEXES
}.ifEmpty { listOf(home) }
}
// Tribe chips: one of each kind in turn, dealt to the marked hexes in random order.
val chipHexes = kinds.indices.filter { kinds[it] == 't' }
val chips = if (chipHexes.isEmpty()) {
emptyList()
} else {
val kindsOfChip = listOf(TribeChip.POINT, TribeChip.HARBOUR, TribeChip.GOLD)
val dealt = List(chipHexes.size) { kindsOfChip[it % kindsOfChip.size] }.shuffled(random)
List(terrains.size) { h -> chipHexes.indexOf(h).let { if (it < 0) TribeChip.NONE else dealt[it] } }
}
val villages = if (kinds.none { it == 'V' }) {
emptyList()
} else {
List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 }
}
val fortress = if (kinds.none { it == 'P' }) {
emptyList()
} else {
List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 }
}
// Chests under the fog: the four rewards in turn, on hexes picked at random.
val chestKinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.CHOICE, Treasure.DEV_CARD, Treasure.ROADS)
val chestHexes = if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) {
fogHexes.shuffled(random).take(CHESTS)
} else {
emptyList()
}
val treasures = if (chestHexes.isEmpty()) {
emptyList()
} else {
List(terrains.size) { h -> chestHexes.indexOf(h).let { if (it < 0) Treasure.NONE else chestKinds[it % chestKinds.size] } }
}
// Canal tiles keep their number under the tile until it is dug out.
val canalHexes = kinds.indices.filter { kinds[it] == 'C' }
val canalNumbers = if (canalHexes.isEmpty()) emptyList() else List(terrains.size) { h -> if (h in canalHexes) numbers[h] else 0 }
canalHexes.forEach { numbers[it] = 0 }
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress, treasures, canalNumbers)
} }
} }
@@ -83,7 +83,7 @@ internal object SeaRules {
.map { sea.ships[it] } .map { sea.ships[it] }
.filter { it != NO_PLAYER && it != thief } .filter { it != NO_PLAYER && it != thief }
.distinct() .distinct()
.filter { state.resourceCount(it) > 0 } .filter { if (state.isCk) CatanCk.goodsCount(state, it) > 0 else state.resourceCount(it) > 0 }
} }
fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> { fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> {
@@ -135,25 +135,356 @@ internal object SeaRules {
val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) } val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) }
val queue = if (supply > 0) sea.gold else emptyList() val queue = if (supply > 0) sea.gold else emptyList()
return if (queue.isEmpty()) { return if (queue.isEmpty()) {
state.copy(sea = sea.copy(gold = emptyList()), phase = CatanPhase.PLAYING) state.copy(sea = sea.copy(gold = emptyList(), afterGold = null), phase = sea.afterGold ?: CatanPhase.PLAYING)
} else { } else {
state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD) state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD)
} }
} }
// -----------------------------------------------------------------------
// Fog
// -----------------------------------------------------------------------
/** Hidden hexes a piece on [edge] reaches: any that touches either end. */
private fun fogReached(state: CatanState, edge: Int): List<Int> {
val topo = state.topo
return topo.EDGE_VERTICES[edge].toList()
.flatMap { topo.VERTEX_HEXES[it] }
.filter { state.terrains[it] == Terrain.FOG }
.distinct()
.sorted()
}
/**
* After a road or ship lands on [edge]: turn each fog hex it now touches face up. The finder takes one card
* of what it yields, or chooses any resource if it is gold, supply permitting. The caller's phase is kept.
*/
fun reveal(state: CatanState, seat: Int, edge: Int): CatanState {
var s = state
val found = fogReached(state, edge)
if (found.isEmpty()) return s
val owed = mutableListOf<GoldOwed>()
for (hex in found) {
val sea = s.sea!!
val tile = sea.fogDeck.firstOrNull() ?: break
val name = tile.terrain.name.lowercase()
s = s.copy(
terrains = s.terrains.toMutableList().also { it[hex] = tile.terrain },
numbers = s.numbers.toMutableList().also { it[hex] = tile.number },
sea = sea.copy(fogDeck = sea.fogDeck.drop(1)),
log = s.log + "Seat $seat uncovers ${if (tile.terrain == Terrain.GOLD) "a gold field" else "the $name"} " +
"(${s.topo.HEXES[hex].q},${s.topo.HEXES[hex].r})" +
if (tile.number != 0) " with a ${tile.number}." else ".",
)
val resource = tile.terrain.produces
if (resource != null && CatanRules.bankSupply(s, resource) > 0) {
s = s.copy(resources = CatanRules.withCounts(s.resources, seat, mapOf(resource to 1), 1))
} else if (tile.terrain == Terrain.GOLD) {
owed += GoldOwed(seat, 1)
}
val chest = s.sea!!.treasures.getOrNull(hex) ?: Treasure.NONE
if (chest != Treasure.NONE) {
val (opened, choose) = openChest(s, seat, chest)
s = opened
if (choose > 0) owed += GoldOwed(seat, choose)
}
}
if (owed.isEmpty()) return s
return owe(s, seat, owed.sumOf { it.count })
}
/**
* [seat] opens [chest]: the state after what it pays directly, and the number of cards of the seat's own
* choice still owed. Cards the bank cannot cover are simply not paid.
*/
private fun openChest(state: CatanState, seat: Int, chest: Treasure): Pair<CatanState, Int> {
var s = state
fun pay(cards: Map<Resource, Int>) {
val real = cards.filter { (r, n) -> CatanRules.bankSupply(s, r) >= n }
if (real.isNotEmpty()) s = s.copy(resources = CatanRules.withCounts(s.resources, seat, real, 1))
}
var choose = 0
val said = when (chest) {
Treasure.BRICK_WOOL_GRAIN -> {
pay(mapOf(Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1))
"a brick, a wool and a grain"
}
Treasure.ROADS -> {
pay(mapOf(Resource.LUMBER to 2, Resource.BRICK to 2))
"materials for two roads"
}
Treasure.DEV_CARD -> if (!s.isCk && s.devDeck.isNotEmpty()) {
val card = s.devDeck.first().copy(boughtTurn = s.turnNumber)
val hands = s.devHands.toMutableList().also { it[seat] = it[seat] + card }
s = s.copy(devDeck = s.devDeck.drop(1), devHands = hands, devHandCounts = hands.map { it.size })
"a development card"
} else {
choose = 1
"a resource of its choice"
}
else -> {
choose = 1
"a resource of its choice"
}
}
s = s.copy(log = s.log + "Seat $seat opens a treasure chest: $said.")
return s to choose
}
/** Queues [count] cards of [seat]'s choice, to be taken before play resumes where it left off. */
private fun owe(state: CatanState, seat: Int, count: Int): CatanState {
val sea = state.sea!!
val queued = sea.copy(
gold = sea.gold + GoldOwed(seat, count),
afterGold = state.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold,
)
return settleGold(state.copy(sea = queued))
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Islands and scoring // Islands and scoring
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/** The bonus points [seat] holds for first landings. */ /** The bonus points [seat] holds: first landings, tribe chips, and one for every two cloth. */
fun bonusPoints(state: CatanState, seat: Int): Int = fun bonusPoints(state: CatanState, seat: Int): Int {
(state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS val sea = state.sea ?: return 0
return sea.islandBonus.count { it == seat } * ISLAND_BONUS +
(sea.raidPoints.getOrNull(seat) ?: 0) +
(sea.wonders.getOrNull(seat) ?: 0) +
clothOf(state, seat) / CLOTH_PER_POINT +
(sea.chipPoints.getOrNull(seat) ?: 0) +
(sea.canalDug.getOrNull(seat) ?: 0) / CANAL_UNITS_PER_POINT
}
// -----------------------------------------------------------------------
// The Great Canal
// -----------------------------------------------------------------------
val DIG_COST: Map<Resource, Int> = mapOf(Resource.BRICK to 1, Resource.ORE to 1)
/** Units that finish a canal tile. */
const val CANAL_TILE_UNITS = 3
/** Two units dug are a victory point. */
const val CANAL_UNITS_PER_POINT = 2
/** Tiles [seat] may dig a unit of: unfinished, with a settlement or city of the seat's on a corner. */
private fun digTargets(state: CatanState, seat: Int): List<Int> {
val sea = state.sea!!
if (sea.canalUnits.isEmpty()) return emptyList()
return sea.canalUnits.indices.filter { h ->
sea.canalNumbers[h] != 0 && sea.canalUnits[h] in 0 until CANAL_TILE_UNITS &&
state.terrains[h] == Terrain.DESERT &&
state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
}
}
fun applyDig(state: CatanState, seat: Int, move: DigCanal): CatanState {
val sea = state.sea!!
val hex = state.topo.hexIndex.getValue(move.hex)
val units = sea.canalUnits[hex] + 1
val dug = sea.canalDug.toMutableList().also { it[seat] += 1 }
var s = state.copy(
resources = CatanRules.withCounts(state.resources, seat, DIG_COST, -1),
sea = sea.copy(canalUnits = sea.canalUnits.toMutableList().also { it[hex] = units }, canalDug = dug),
log = state.log + "Seat $seat digs the canal at (${move.hex.q},${move.hex.r}): $units of $CANAL_TILE_UNITS.",
)
if (units == CANAL_TILE_UNITS) {
s = s.copy(
terrains = s.terrains.toMutableList().also { it[hex] = Terrain.FIELDS },
numbers = s.numbers.toMutableList().also { it[hex] = sea.canalNumbers[hex] },
log = s.log + "The tile is dug out: it is fields now, with a ${sea.canalNumbers[hex]}.",
)
}
return CatanRules.checkWin(s)
}
// -----------------------------------------------------------------------
// Cloth for Catan
// -----------------------------------------------------------------------
const val CLOTH_PER_POINT = 2
fun clothOf(state: CatanState, seat: Int): Int = state.sea?.cloth?.getOrNull(seat) ?: 0
/**
* After the dice pay: every village showing [number] pays one cloth to each settlement or city beside it
* (a city is not worth more here), until its stock runs out. A robber on the village stops it.
*/
fun payCloth(state: CatanState, number: Int): CatanState {
val sea = state.sea ?: return state
if (sea.villageCloth.isEmpty()) return state
var s = state
val topo = state.topo
for (h in 0 until topo.HEX_COUNT) {
if (state.terrains[h] != Terrain.VILLAGE || state.numbers[h] != number || h == state.robber) continue
val takers = topo.HEX_CORNERS[h].mapNotNull { state.buildings[it]?.owner }
if (takers.isEmpty()) continue
// Seats in turn order from the roller get the last of the stock.
val order = takers.sortedBy { (it - state.turn + state.playerCount) % state.playerCount }
val current = s.sea!!
var stock = current.villageCloth[h]
val cloth = current.cloth.toMutableList()
val said = mutableListOf<String>()
for (seat in order) {
if (stock == 0) break
cloth[seat] += 1
stock--
said += "seat $seat"
}
if (said.isEmpty()) continue
val stocks = current.villageCloth.toMutableList().also { it[h] = stock }
s = s.copy(
sea = current.copy(cloth = cloth, villageCloth = stocks),
log = s.log + "The village at (${topo.HEXES[h].q},${topo.HEXES[h].r}) pays cloth to ${said.joinToString(", ")}.",
)
}
return s
}
// -----------------------------------------------------------------------
// Pirate Islands
// -----------------------------------------------------------------------
val RAID_COST: Map<Resource, Int> = mapOf(Resource.ORE to 1, Resource.WOOL to 1, Resource.GRAIN to 1)
const val FORTRESS_POINTS = 2
/** Whether a pirate fortress still stands on the island [vertex] is part of, so nobody may settle there. */
fun fortressHolds(state: CatanState, vertex: Int): Boolean {
val sea = state.sea ?: return false
if (sea.fortress.isEmpty()) return false
val island = state.topo.vertexIsland[vertex]
return island >= 0 && sea.fortress.indices.any { sea.fortress[it] > 0 && state.topo.islandOf[it] == island }
}
/** Fortress hexes [seat] has a ship beside. */
private fun raidTargets(state: CatanState, seat: Int): List<Int> {
val sea = state.sea!!
if (sea.fortress.isEmpty()) return emptyList()
val topo = state.topo
return sea.fortress.indices.filter { h ->
sea.fortress[h] > 0 && (0 until topo.EDGE_COUNT).any { h in topo.EDGE_HEXES[it] && sea.ships[it] == seat }
}
}
fun applyRaid(state: CatanState, seat: Int, move: RaidFortress): CatanState {
val sea = state.sea!!
val hex = state.topo.hexIndex.getValue(move.hex)
val left = sea.fortress[hex] - 1
var s = state.copy(resources = CatanRules.withCounts(state.resources, seat, RAID_COST, -1))
val strength = sea.fortress.toMutableList().also { it[hex] = left }
if (left > 0) {
return s.copy(
sea = sea.copy(fortress = strength),
log = s.log + "Seat $seat raids the fortress at (${move.hex.q},${move.hex.r}); $left strength left.",
)
}
val points = sea.raidPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList().also { it[seat] += FORTRESS_POINTS }
s = s.copy(
terrains = s.terrains.toMutableList().also { it[hex] = Terrain.DESERT },
sea = sea.copy(fortress = strength, raidPoints = points),
log = s.log + "Seat $seat takes the fortress at (${move.hex.q},${move.hex.r}): +$FORTRESS_POINTS victory points, " +
"and the island is open.",
)
return CatanRules.checkWin(s)
}
// -----------------------------------------------------------------------
// Wonders of Catan
// -----------------------------------------------------------------------
/** What each of the four stages of a wonder costs. */
val WONDER_STAGES: List<Map<Resource, Int>> = listOf(
mapOf(Resource.BRICK to 2, Resource.LUMBER to 1, Resource.GRAIN to 1),
mapOf(Resource.BRICK to 1, Resource.ORE to 2, Resource.WOOL to 1),
mapOf(Resource.ORE to 3, Resource.GRAIN to 1, Resource.LUMBER to 1),
mapOf(Resource.ORE to 3, Resource.GRAIN to 2, Resource.BRICK to 1),
)
/** The next stage [seat] could build, if the scenario has wonders and the seat has a city to raise it in. */
private fun nextStage(state: CatanState, seat: Int): Map<Resource, Int>? {
val stage = state.sea?.wonders?.getOrNull(seat) ?: return null
if (stage >= WONDER_STAGES.size) return null
if (state.buildings.none { it?.owner == seat && it.type == BuildingType.CITY }) return null
return WONDER_STAGES[stage]
}
/** The extra moves a scenario offers during play: raids and wonder stages. */
fun scenarioMoves(state: CatanState, seat: Int): List<CatanMove> {
val moves = mutableListOf<CatanMove>()
if (CatanRules.affordable(state, seat, RAID_COST)) {
raidTargets(state, seat).forEach { moves += RaidFortress(state.topo.HEXES[it]) }
}
if (CatanRules.affordable(state, seat, DIG_COST)) {
digTargets(state, seat).forEach { moves += DigCanal(state.topo.HEXES[it]) }
}
nextStage(state, seat)?.let { if (CatanRules.affordable(state, seat, it)) moves += BuildWonder }
return moves
}
fun applyWonder(state: CatanState, seat: Int): CatanState {
val sea = state.sea!!
val stage = sea.wonders[seat] + 1
val built = sea.wonders.toMutableList().also { it[seat] = stage }
val s = state.copy(
resources = CatanRules.withCounts(state.resources, seat, WONDER_STAGES[stage - 1], -1),
sea = sea.copy(wonders = built),
log = state.log + "Seat $seat builds stage $stage of a wonder.",
)
if (stage < WONDER_STAGES.size) return CatanRules.checkWin(s)
return s.copy(
phase = CatanPhase.GAME_OVER,
winner = seat,
log = s.log + "Seat $seat completes a wonder of Catan and wins.",
)
}
// -----------------------------------------------------------------------
// The Forgotten Tribe
// -----------------------------------------------------------------------
/** After a settlement on [vertex]: take any tribe chip lying on a hex beside it. */
fun takeChips(state: CatanState, seat: Int, vertex: Int): CatanState {
var s = state
if (s.sea?.chips.isNullOrEmpty()) return s
var gold = 0
for (hex in state.topo.VERTEX_HEXES[vertex]) {
val sea = s.sea!!
val chip = sea.chips.getOrNull(hex) ?: continue
if (chip == TribeChip.NONE) continue
val rest = sea.chips.toMutableList().also { it[hex] = TribeChip.NONE }
var next = sea.copy(chips = rest)
val said = when (chip) {
TribeChip.POINT -> {
val points = next.chipPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList()
points[seat] += 1
next = next.copy(chipPoints = points)
"a victory point"
}
TribeChip.HARBOUR -> {
next = next.copy(harbourSeats = (next.harbourSeats + seat).distinct())
"a harbour that trades 3:1"
}
else -> {
gold += 2
"two resources of its choice"
}
}
s = s.copy(sea = next, log = s.log + "Seat $seat takes the tribe's chip beside it: $said.")
}
return if (gold > 0) owe(s, seat, gold) else s
}
/** What happens to a new settlement of [seat]'s on [vertex] beyond the base game. */
fun settled(state: CatanState, seat: Int, vertex: Int): CatanState =
takeChips(claimIsland(state, seat, vertex), seat, vertex)
/** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */ /** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState { fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
val sea = state.sea ?: return state val sea = state.sea ?: return state
val island = state.topo.vertexIsland[vertex] val island = state.topo.vertexIsland[vertex]
if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state if (island < 0 || sea.isStart(island) || sea.islandBonus[island] != NO_PLAYER) return state
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat } val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
return state.copy( return state.copy(
sea = sea.copy(islandBonus = bonus), sea = sea.copy(islandBonus = bonus),
@@ -1,6 +1,7 @@
package org.prolibertate.games.game.mastermind package org.prolibertate.games.game.mastermind
import kotlin.random.Random import kotlin.random.Random
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi import org.prolibertate.games.game.engine.GameAi
@@ -18,15 +19,13 @@ import org.prolibertate.games.game.engine.GameAi
* plays the one whose *worst* possible answer leaves the smallest number of * plays the one whose *worst* possible answer leaves the smallest number of
* candidates behind, so it cannot be given a reply that tells it little. * candidates behind, so it cannot be given a reply that tells it little.
* *
* The table already carries its own three-step level ([MastermindOptions.level]), * The computer difficulty chosen at setup picks the level: Easy plays Casual,
* chosen at setup and shown to the player, and it means exactly what an * Medium plays whatever the table's saved [MastermindOptions.level] says (Club
* [org.prolibertate.games.game.engine.AiLevel] would here. Layering a second * for a new game) and Hard plays Strong. [level] overrides all of that, which is
* knob on top would make the two disagree, so the shared [profiles] are * what the tests use.
* accepted for a uniform constructor but this game keeps playing at the level
* the table names. [level] overrides that, which is what the tests use.
*/ */
class MastermindAi( class MastermindAi(
@Suppress("unused") private val profiles: AiProfiles = AiProfiles.DEFAULT, private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: MastermindLevel? = null, private val level: MastermindLevel? = null,
) : GameAi<MastermindState, MastermindMove> { ) : GameAi<MastermindState, MastermindMove> {
@@ -38,7 +37,12 @@ class MastermindAi(
legal: List<MastermindMove>, legal: List<MastermindMove>,
): MastermindMove { ): MastermindMove {
require(legal.isNotEmpty()) { "No legal guess for seat $seat" } require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
val setting = level ?: state.options.level val profile = profiles.of(seat)
val setting = level ?: when (profile.level) {
AiLevel.EASY -> MastermindLevel.CASUAL
AiLevel.MEDIUM -> state.options.level
AiLevel.HARD -> MastermindLevel.STRONG
}
// Deterministic for a given position, so a host and a client watching // Deterministic for a given position, so a host and a client watching
// the same game agree on what the computer did. // the same game agree on what the computer did.
@@ -35,11 +35,8 @@ import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.SavedMatch import org.prolibertate.games.game.engine.SavedMatch
import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.backgammon.BackgammonLevel
import org.prolibertate.games.game.backgammon.BackgammonOptions import org.prolibertate.games.game.backgammon.BackgammonOptions
import org.prolibertate.games.game.checkers.CheckersLevel
import org.prolibertate.games.game.checkers.CheckersOptions import org.prolibertate.games.game.checkers.CheckersOptions
import org.prolibertate.games.game.chess.ChessLevel
import org.prolibertate.games.game.chess.ChessOptions import org.prolibertate.games.game.chess.ChessOptions
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
import org.prolibertate.games.game.cribbage.CribbageOptions import org.prolibertate.games.game.cribbage.CribbageOptions
@@ -57,15 +54,14 @@ import org.prolibertate.games.game.pegsolitaire.PegBoard
import org.prolibertate.games.game.pegsolitaire.PegGoal import org.prolibertate.games.game.pegsolitaire.PegGoal
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
import org.prolibertate.games.game.kaiser.KaiserOptions import org.prolibertate.games.game.kaiser.KaiserOptions
import org.prolibertate.games.game.mastermind.MastermindLevel
import org.prolibertate.games.game.mastermind.MastermindOptions import org.prolibertate.games.game.mastermind.MastermindOptions
import org.prolibertate.games.game.morris.MorrisLevel
import org.prolibertate.games.game.morris.MorrisOptions import org.prolibertate.games.game.morris.MorrisOptions
import org.prolibertate.games.game.pirates.PiratesLevel
import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.SeaScenario
import org.prolibertate.games.game.catan.BaseScenario
import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions import org.prolibertate.games.game.tayu.TayuOptions
@@ -379,47 +375,50 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
else -> seat else -> seat
} }
/** The targets the setup screen offers for a Katan rule set. */ /** The targets the setup screen offers for a Katan game with [expansions] on. */
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) { internal fun catanPointsChoices(expansions: Set<CatanExpansion>): List<Int> = when {
// Five is a short game for trying things out; ten is the published target. // Both: Cities & Knights' long game plus the new-island points.
CatanRuleSet.BASE -> listOf(5, 8, 10, 12) expansions.size > 1 -> listOf(10, 12, 14, 16)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
// Seafarers adds two points for each new island, so the target is higher. // Seafarers adds two points for each new island, so the target is higher.
CatanRuleSet.SEAFARERS -> listOf(8, 10, 12, 14) CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> listOf(8, 10, 13, 15)
// Five is a short game for trying things out; ten is the published target.
else -> listOf(5, 8, 10, 12)
} }
/** The published target for a rule set. */ /** The published target for a game with [expansions] on. */
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) { internal fun catanDefaultPoints(expansions: Set<CatanExpansion>): Int = when {
CatanRuleSet.BASE -> 10 expansions.size > 1 -> 14
CatanRuleSet.CITIES_AND_KNIGHTS -> 13 CatanExpansion.SEAFARERS in expansions -> 12
CatanRuleSet.SEAFARERS -> 12 CatanExpansion.CITIES_AND_KNIGHTS in expansions -> 13
else -> 10
} }
/** /**
* [options] switched to [ruleSet]. The target moves to the new rule set's own * [options] with [expansion] switched on or off. The target moves to the new combination's own
* default unless the one already chosen is on its menu too. * default unless the one already chosen is on its menu too.
*/ */
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions = internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions {
options.copy( val expansions = if (on) options.expansions + expansion else options.expansions - expansion
ruleSet = ruleSet, return options.copy(
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) } expansions = expansions,
?: catanDefaultPoints(ruleSet), pointsToWin = options.pointsToWin.takeIf {
it in catanPointsChoices(expansions) && it != catanDefaultPoints(options.expansions)
} ?: catanDefaultPoints(expansions),
) )
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE -> "Base game"
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
CatanRuleSet.SEAFARERS -> "Seafarers"
} }
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) { private fun catanExpansionName(expansion: CatanExpansion): String = when (expansion) {
CatanRuleSet.BASE -> CatanExpansion.CITIES_AND_KNIGHTS -> "Cities & Knights"
"Settle the island, trade, and play development cards. Longest Road and Largest Army score." CatanExpansion.SEAFARERS -> "Seafarers"
CatanRuleSet.CITIES_AND_KNIGHTS -> }
private fun catanExpansionBlurb(expansion: CatanExpansion): String = when (expansion) {
CatanExpansion.CITIES_AND_KNIGHTS ->
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " + "Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
"cards and metropolises. No development cards; the robber sleeps until the first attack." "cards and metropolises. No development cards; the robber sleeps until the first attack."
CatanRuleSet.SEAFARERS -> CatanExpansion.SEAFARERS ->
"Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " + "Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " +
"and the first settlement on each new island scores two extra points." "and the first settlement on each new island scores two extra points."
} }
@@ -427,14 +426,33 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
@Composable @Composable
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) { private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow( CatanExpansion.entries.forEach { expansion ->
label = "Rules", ToggleRow(
values = CatanRuleSet.entries, title = catanExpansionName(expansion),
selected = options.ruleSet, subtitle = catanExpansionBlurb(expansion),
display = ::catanRuleSetName, checked = expansion in options.expansions,
onSelect = { onChange(catanSwitchRules(options, it)) }, onChange = { onChange(catanToggleExpansion(options, expansion, it)) },
) )
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall) }
if (options.seafarers) {
ChipRow(
label = "Map",
values = SeaScenario.entries,
selected = options.scenario,
display = { it.title },
onSelect = { onChange(options.copy(scenario = it)) },
)
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
} else {
ChipRow(
label = "Island",
values = BaseScenario.entries,
selected = options.baseScenario,
display = { it.title },
onSelect = { onChange(options.copy(baseScenario = it)) },
)
Text(options.baseScenario.blurb, style = MaterialTheme.typography.bodySmall)
}
ChipRow( ChipRow(
label = "Players", label = "Players",
values = listOf(3, 4), values = listOf(3, 4),
@@ -444,7 +462,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
) )
ChipRow( ChipRow(
label = "Game to", label = "Game to",
values = catanPointsChoices(options.ruleSet), values = catanPointsChoices(options.expansions),
selected = options.pointsToWin, selected = options.pointsToWin,
display = { "$it points" }, display = { "$it points" },
onSelect = { onChange(options.copy(pointsToWin = it)) }, onSelect = { onChange(options.copy(pointsToWin = it)) },
@@ -785,13 +803,6 @@ private fun ChessOptionsEditor(
display = { if (it) "White" else "Black" }, display = { if (it) "White" else "Black" },
onSelect = onColour, onSelect = onColour,
) )
ChipRow(
label = "Opponent",
values = ChessLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow( ToggleRow(
title = "Fifty-move rule", title = "Fifty-move rule",
subtitle = "Fifty moves each with no capture and no pawn move is a draw", subtitle = "Fifty moves each with no capture and no pawn move is a draw",
@@ -815,13 +826,6 @@ private fun ChessOptionsEditor(
@Composable @Composable
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) { private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = CheckersLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow( ToggleRow(
title = "Flying kings", title = "Flying kings",
subtitle = "A king slides the length of a diagonal and takes from a distance, " + subtitle = "A king slides the length of a diagonal and takes from a distance, " +
@@ -856,13 +860,6 @@ private fun BackgammonOptionsEditor(
onChange: (BackgammonOptions) -> Unit, onChange: (BackgammonOptions) -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = BackgammonLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow( ToggleRow(
title = "Count gammons", title = "Count gammons",
subtitle = "A loser who bore nothing off has lost double, and one still on the " + subtitle = "A loser who bore nothing off has lost double, and one still on the " +
@@ -884,13 +881,6 @@ private fun MastermindOptionsEditor(
onChange: (MastermindOptions) -> Unit, onChange: (MastermindOptions) -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = MastermindLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ChipRow( ChipRow(
label = "Colours", label = "Colours",
values = listOf(6, 8), values = listOf(6, 8),
@@ -955,13 +945,6 @@ private fun PiratesOptionsEditor(
display = { if (it) "the two pirates" else "the twenty-four Bulgars" }, display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
onSelect = onSide, onSelect = onSide,
) )
ChipRow(
label = "Opponent",
values = PiratesLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow( ToggleRow(
title = "A pirate who can take must take", title = "A pirate who can take must take",
subtitle = "The rule that huffing exists to enforce, applied directly", subtitle = "The rule that huffing exists to enforce, applied directly",
@@ -987,13 +970,6 @@ private fun PiratesOptionsEditor(
@Composable @Composable
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) { private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = MorrisLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ChipRow( ChipRow(
label = "Pieces each", label = "Pieces each",
values = listOf(3, 6, 9), values = listOf(3, 6, 9),
@@ -33,7 +33,9 @@ import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.NO_PLAYER import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.game.catan.Resource import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.SeaRules
import org.prolibertate.games.game.catan.Terrain import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.catan.TribeChip
/** /**
* The island, drawn and tappable. * The island, drawn and tappable.
@@ -156,6 +158,39 @@ private fun DrawScope.drawHexes(
val number = state.numbers[hex] val number = state.numbers[hex]
if (number != 0) drawToken(number, centre, board, style, measurer) if (number != 0) drawToken(number, centre, board, style, measurer)
if (state.terrains[hex] == Terrain.FOG) {
drawCentered(
measurer = measurer,
text = "?",
at = centre,
fontPx = board.radius * 0.9f,
color = style.hexEdge.copy(alpha = 0.7f),
weight = FontWeight.Bold,
)
}
val sea = state.sea
val note = when {
state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth"
sea != null && sea.canalNumbers.getOrElse(hex) { 0 } != 0 && state.terrains[hex] == Terrain.DESERT ->
"dug ${sea.canalUnits.getOrElse(hex) { 0 }}/${SeaRules.CANAL_TILE_UNITS}"
state.terrains[hex] == Terrain.FORTRESS && sea != null -> "fort ${sea.fortress.getOrElse(hex) { 0 }}"
else -> when (sea?.chips?.getOrNull(hex)) {
TribeChip.POINT -> "+1 VP"
TribeChip.HARBOUR -> "3:1"
TribeChip.GOLD -> "gold"
else -> null
}
}
if (note != null) {
drawCentered(
measurer = measurer,
text = note,
at = Pt(centre.x, centre.y + board.radius * 0.62f),
fontPx = board.radius * 0.26f,
color = style.tokenInk,
weight = FontWeight.Bold,
)
}
} }
} }
@@ -173,6 +173,9 @@ object CatanHudGlyphs {
BuildKind.WALL -> "▦" BuildKind.WALL -> "▦"
BuildKind.KNIGHT -> "♞" BuildKind.KNIGHT -> "♞"
BuildKind.SHIP -> "⛵" BuildKind.SHIP -> "⛵"
BuildKind.RAID -> "⚔"
BuildKind.DIG -> "⛏"
BuildKind.WONDER -> "✦"
} }
fun label(kind: BuildKind): String = when (kind) { fun label(kind: BuildKind): String = when (kind) {
@@ -182,6 +185,9 @@ object CatanHudGlyphs {
BuildKind.WALL -> CatanText.WALL BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP BuildKind.SHIP -> CatanText.SHIP
BuildKind.RAID -> CatanText.RAID
BuildKind.DIG -> CatanText.DIG
BuildKind.WONDER -> CatanText.WONDER
} }
} }
@@ -208,7 +214,7 @@ fun HudConfirmButtons(
fun ZoomPanBox( fun ZoomPanBox(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
minScale: Float = 1f, minScale: Float = 1f,
maxScale: Float = 3f, maxScale: Float = 6f,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
var scale by remember { mutableFloatStateOf(1f) } var scale by remember { mutableFloatStateOf(1f) }
@@ -6,7 +6,10 @@ import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildShip import org.prolibertate.games.game.catan.BuildShip
import org.prolibertate.games.game.catan.BuildWonder
import org.prolibertate.games.game.catan.DigCanal
import org.prolibertate.games.game.catan.MovePirate import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.RaidFortress
import org.prolibertate.games.game.catan.MoveShip import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.Topology import org.prolibertate.games.game.catan.Topology
import org.prolibertate.games.game.catan.BuildSettlement import org.prolibertate.games.game.catan.BuildSettlement
@@ -67,13 +70,38 @@ enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT, ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
/** Seafarers: a ship, laid on a sea edge. */ /** Seafarers: a ship, laid on a sea edge. */
SHIP; SHIP,
/** Pirate Islands: raid a fortress; the target is the fortress hex. */
RAID,
/** Great Canal: dig a canal tile; the target is the tile. */
DIG,
/** Wonders of Catan: the next stage of the wonder, which needs no tap on the board. */
WONDER;
companion object { companion object {
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY) val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
/** Seafarers adds the ship, listed after the road it sails beside. */ /** Seafarers adds the ship, listed after the road it sails beside. */
val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, SETTLEMENT, CITY) val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, SETTLEMENT, CITY)
/** The kinds a table's build menu lists: the scenario's own raid or wonder follows the usual ones. */
fun of(state: CatanState): List<BuildKind> {
val sea = state.sea
val extras = buildList {
if (sea != null && sea.fortress.isNotEmpty()) add(RAID)
if (sea != null && sea.canalUnits.isNotEmpty()) add(DIG)
if (sea != null && sea.wonders.isNotEmpty()) add(WONDER)
}
val usual = when {
state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, DIG, WONDER)
state.isSea -> SEAFARERS
else -> BASE
}
return usual + extras
}
} }
} }
@@ -252,6 +280,13 @@ object CatanInteraction {
return when (building) { return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads) BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SHIP -> BoardTargets(edges = ships) BuildKind.SHIP -> BoardTargets(edges = ships)
BuildKind.RAID -> BoardTargets(
hexes = legal.filterIsInstance<RaidFortress>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
)
BuildKind.DIG -> BoardTargets(
hexes = legal.filterIsInstance<DigCanal>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
)
BuildKind.WONDER -> BoardTargets()
BuildKind.SETTLEMENT -> BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet()) BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
@@ -519,6 +554,7 @@ object CatanInteraction {
val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord } val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord } val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
legal.firstOrNull { (it is RaidFortress && it.hex == coord) || (it is DigCanal && it.hex == coord) }?.let { return TapOutcome.Submit(it) }
return when { return when {
moves.isNotEmpty() -> if (moves.size == 1) { moves.isNotEmpty() -> if (moves.size == 1) {
TapOutcome.Submit(moves.first()) TapOutcome.Submit(moves.first())
@@ -586,6 +622,9 @@ object CatanInteraction {
if (legal.any { it is BuildShip }) add(BuildKind.SHIP) if (legal.any { it is BuildShip }) add(BuildKind.SHIP)
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT) if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
if (legal.any { it is BuildCity }) add(BuildKind.CITY) if (legal.any { it is BuildCity }) add(BuildKind.CITY)
if (legal.any { it is RaidFortress }) add(BuildKind.RAID)
if (legal.any { it is DigCanal }) add(BuildKind.DIG)
if (legal.any { it is BuildWonder }) add(BuildKind.WONDER)
if (legal.any { it is BuildWall }) add(BuildKind.WALL) if (legal.any { it is BuildWall }) add(BuildKind.WALL)
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT) if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
} }
@@ -256,6 +256,8 @@ fun CatanPlayerCard(
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis") if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
} }
if (ck.merchant?.seat == seat) add("Merchant") if (ck.merchant?.seat == seat) add("Merchant")
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
addAll(seaAwards(state, seat, compact))
} }
CardLine( CardLine(
text = ckAwards.joinToString(" · ").ifEmpty { BLANK }, text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
@@ -280,6 +282,8 @@ fun CatanPlayerCard(
val awards = buildList { val awards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road") if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army") if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
addAll(seaAwards(state, seat, compact))
} }
// Always drawn, so the card does not grow when somebody takes an award. // Always drawn, so the card does not grow when somebody takes an award.
CardLine( CardLine(
@@ -598,6 +602,9 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.WALL -> CatanText.WALL BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP BuildKind.SHIP -> CatanText.SHIP
BuildKind.RAID -> CatanText.RAID
BuildKind.DIG -> CatanText.DIG
BuildKind.WONDER -> CatanText.WONDER
} }
private fun buildCost(kind: BuildKind): String = when (kind) { private fun buildCost(kind: BuildKind): String = when (kind) {
@@ -607,6 +614,9 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.WALL -> CatanText.WALL_COST BuildKind.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST BuildKind.KNIGHT -> CatanText.KNIGHT_COST
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
BuildKind.RAID -> CatanText.RAID_COST
BuildKind.DIG -> CatanText.DIG_COST
BuildKind.WONDER -> CatanText.WONDER_COST
} }
@Composable @Composable
@@ -634,3 +644,14 @@ fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
} }
} }
} }
/** Seafarers scenario holdings worth a word on a player's card: cloth, and a tribe harbour. */
private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List<String> {
val sea = state.sea ?: return emptyList()
return buildList {
if (sea.cloth.isNotEmpty()) add(if (compact) "${sea.cloth[seat]}c" else "Cloth ${sea.cloth[seat]}")
if (sea.canalDug.isNotEmpty()) add(if (compact) "${sea.canalDug[seat]}⛏" else "Dug ${sea.canalDug[seat]}")
if (sea.wonders.isNotEmpty()) add(if (compact) "W${sea.wonders[seat]}" else "Wonder ${sea.wonders[seat]}/4")
if (seat in sea.harbourSeats) add(if (compact) "3:1" else "Harbour 3:1")
}
}
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptTrade import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.AqueductChore import org.prolibertate.games.game.catan.AqueductChore
import org.prolibertate.games.game.catan.BuildWonder
import org.prolibertate.games.game.catan.BuyDevCard import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.CatanCk import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanMove
@@ -159,6 +160,14 @@ fun CatanScreen(
} }
} }
// A wonder stage needs no tap on the board: choosing it from the menu builds it.
LaunchedEffect(building) {
if (building == BuildKind.WONDER) {
legal.firstOrNull { it is BuildWonder }?.let { controller.submit(it) }
building = null
}
}
val submit: (CatanMove) -> Unit = { move -> val submit: (CatanMove) -> Unit = { move ->
controller.submit(move) controller.submit(move)
building = null building = null
@@ -197,11 +206,7 @@ fun CatanScreen(
val progressHand = CatanInteraction.progressHand(current, localSeat, legal) val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions( val actions = CatanActions(
buildKinds = when { buildKinds = BuildKind.of(current),
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
current.isSea -> BuildKind.SEAFARERS
else -> BuildKind.BASE
},
ck = current.isCk, ck = current.isCk,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(), canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable }, canPlayProgress = progressHand.any { it.playable },
@@ -374,7 +379,8 @@ fun CatanScreen(
players(false) players(false)
if (current.isCk) CatanBarbarians(current, style = style) if (current.isCk) CatanBarbarians(current, style = style)
promptRow() promptRow()
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { // Pinch to zoom and drag to pan, as in landscape: a wide Seafarers map is small at fit.
ZoomPanBox(Modifier.weight(1f).fillMaxWidth()) {
board(Modifier) board(Modifier)
} }
handAndActions() handAndActions()
@@ -124,6 +124,12 @@ private val LightStyle = CatanStyle(
// Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow. // Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow.
Terrain.SEA to Color(0xFF3A7CA5), Terrain.SEA to Color(0xFF3A7CA5),
Terrain.GOLD to Color(0xFFF2B01E), Terrain.GOLD to Color(0xFFF2B01E),
// Land not yet found: a pale grey bank of fog with a question mark.
Terrain.FOG to Color(0xFFB9C2C9),
// Cloth villages: a dyed-cloth purple.
Terrain.VILLAGE to Color(0xFF9B6BB5),
// A pirate fortress: dark stone.
Terrain.FORTRESS to Color(0xFF4A4F57),
), ),
hexEdge = Color(0x55000000), hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9), token = Color(0xFFF7EFD9),
@@ -74,6 +74,9 @@ object CatanText {
Terrain.DESERT -> "Desert" Terrain.DESERT -> "Desert"
Terrain.SEA -> "Sea" Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field" Terrain.GOLD -> "Gold field"
Terrain.FOG -> "Fog"
Terrain.VILLAGE -> "Village"
Terrain.FORTRESS -> "Pirate fortress"
} }
fun devCard(type: DevCardType): String = when (type) { fun devCard(type: DevCardType): String = when (type) {
@@ -113,6 +116,12 @@ object CatanText {
const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)." const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)."
const val SHIP = "Ship" const val SHIP = "Ship"
const val SHIP_COST_TEXT = "1 lumber + 1 wool" const val SHIP_COST_TEXT = "1 lumber + 1 wool"
const val RAID = "Raid"
const val RAID_COST = "1 ore + 1 wool + 1 grain"
const val DIG = "Dig"
const val DIG_COST = "1 brick + 1 ore"
const val WONDER = "Wonder"
const val WONDER_COST = "next stage"
const val GOLD_TITLE = "Gold field" const val GOLD_TITLE = "Gold field"
const val GOLD_TAKE = "Take" const val GOLD_TAKE = "Take"
const val ROLL = "Roll dice" const val ROLL = "Roll dice"
@@ -193,7 +202,7 @@ object CatanText {
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */ /** Seafarers: a ship has been picked to sail, and its destination is awaited. */
movingShip: Boolean = false, movingShip: Boolean = false,
): String { ): String {
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed) if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed, movingShip)
val me = seats.me val me = seats.me
val onClock = CatanRules.currentSeat(state) val onClock = CatanRules.currentSeat(state)
val mine = onClock == me val mine = onClock == me
@@ -246,6 +255,8 @@ object CatanText {
!mine -> "$turn is playing…" !mine -> "$turn is playing…"
movingShip -> "Tap the sea edge to sail the ship to." movingShip -> "Tap the sea edge to sail the ship to."
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship." building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it."
building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit."
building == BuildKind.ROAD -> "Tap an edge to build a road." building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
@@ -400,6 +411,7 @@ object CatanText {
building: BuildKind?, building: BuildKind?,
movingKnight: Boolean, movingKnight: Boolean,
armed: ProgressType?, armed: ProgressType?,
movingShip: Boolean,
): String { ): String {
val me = seats.me val me = seats.me
val onClock = CatanRules.currentSeat(state) val onClock = CatanRules.currentSeat(state)
@@ -408,7 +420,12 @@ object CatanText {
val ck = state.ck!! val ck = state.ck!!
return when (state.phase) { return when (state.phase) {
CatanPhase.GOLD -> "" CatanPhase.GOLD ->
if (mine) {
"Gold! Choose ${state.sea?.gold?.firstOrNull()?.count ?: 1} resource cards."
} else {
"${seats.label(onClock ?: state.turn)} is choosing from the gold…"
}
CatanPhase.GAME_OVER -> { CatanPhase.GAME_OVER -> {
val winner = state.winner val winner = state.winner
@@ -434,6 +451,7 @@ object CatanText {
CatanPhase.ROBBER -> when { CatanPhase.ROBBER -> when {
!mine -> "$turn is moving the robber…" !mine -> "$turn is moving the robber…"
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card." ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
state.isSea -> SEA_ROBBER
else -> "Move the robber. Tap a hex." else -> "Move the robber. Tap a hex."
} }
@@ -441,6 +459,10 @@ object CatanText {
!mine -> "$turn is playing…" !mine -> "$turn is playing…"
armed != null -> armedPrompt(armed) armed != null -> armedPrompt(armed)
movingKnight -> "Tap where the knight should go." movingKnight -> "Tap where the knight should go."
movingShip -> "Tap the sea edge to sail the ship to."
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it."
building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit."
building == BuildKind.ROAD -> "Tap an edge to build a road." building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
@@ -55,15 +55,15 @@ class CatanAiProfilesTest {
(0 until 3).any { p.of(it).style == style } (0 until 3).any { p.of(it).style == style }
} }
private fun forEveryProfile(rules: CatanRuleSet, cap: Int) { private fun forEveryProfile(rules: Set<CatanExpansion>, cap: Int) {
val options = CatanOptions( val options = CatanOptions(
playerCount = 3, playerCount = 3,
pointsToWin = when (rules) { pointsToWin = when {
CatanRuleSet.BASE -> 10 CatanExpansion.CITIES_AND_KNIGHTS in rules -> 13
CatanRuleSet.CITIES_AND_KNIGHTS -> 13 CatanExpansion.SEAFARERS in rules -> 12
else -> 12 else -> 10
}, },
ruleSet = rules, expansions = rules,
) )
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) { for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) {
for (style in AiStyle.entries) { for (style in AiStyle.entries) {
@@ -78,14 +78,14 @@ class CatanAiProfilesTest {
} }
@Test @Test
fun `easy and hard seats of every style play the base game`() = forEveryProfile(CatanRuleSet.BASE, 1_500) fun `easy and hard seats of every style play the base game`() = forEveryProfile(emptySet(), 1_500)
@Test @Test
fun `easy and hard seats of every style play Cities and Knights`() = fun `easy and hard seats of every style play Cities and Knights`() =
forEveryProfile(CatanRuleSet.CITIES_AND_KNIGHTS, 1_500) forEveryProfile(setOf(CatanExpansion.CITIES_AND_KNIGHTS), 1_500)
@Test @Test
fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(CatanRuleSet.SEAFARERS, 1_500) fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(setOf(CatanExpansion.SEAFARERS), 1_500)
@Test @Test
fun `a mixed table of four finishes a base game`() { fun `a mixed table of four finishes a base game`() {
@@ -95,8 +95,8 @@ class CatanAiProfilesTest {
@Test @Test
fun `the default profile plays exactly as an explicit medium table does`() { fun `the default profile plays exactly as an explicit medium table does`() {
for (rules in listOf(CatanRuleSet.BASE, CatanRuleSet.CITIES_AND_KNIGHTS)) { for (rules in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
val options = CatanOptions(playerCount = 3, pointsToWin = 10, ruleSet = rules) val options = CatanOptions(playerCount = 3, pointsToWin = 10, expansions = rules)
val plain = play(options, 9L, AiProfiles.DEFAULT, 600) val plain = play(options, 9L, AiProfiles.DEFAULT, 600)
val medium = play(options, 9L, AiProfiles(AiDifficulty.MEDIUM, 1234L), 600) val medium = play(options, 9L, AiProfiles(AiDifficulty.MEDIUM, 1234L), 600)
assertEquals("$rules", plain, medium) assertEquals("$rules", plain, medium)
@@ -0,0 +1,115 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
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
/** The base-island scenarios: First Island's fixed board and the Harbormaster. */
class CatanBaseScenarioTest {
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,
)
private fun game(
scenario: BaseScenario,
seed: Long = 5L,
expansions: Set<CatanExpansion> = emptySet(),
players: Int = 3,
) = CatanRules.initialState(config(CatanOptions(players, 10, expansions, baseScenario = scenario), seed))
@Test
fun `first island is the same board for every seed and the random map is not`() {
val a = game(BaseScenario.FIRST_ISLAND, 1L)
val b = game(BaseScenario.FIRST_ISLAND, 99L)
assertEquals(a.terrains, b.terrains)
assertEquals(a.numbers, b.numbers)
assertEquals(a.ports, b.ports)
assertNotEquals(game(BaseScenario.RANDOM_MAP, 1L).terrains, game(BaseScenario.RANDOM_MAP, 99L).terrains)
// The deck and the seating still follow the table's own seed.
assertNotEquals(a.devDeck, b.devDeck)
}
@Test
fun `a save from before base scenarios reads as a random map`() {
val old = """{"playerCount":3,"pointsToWin":10,"expansions":[],"scenario":"NEW_SHORES"}"""
assertEquals(BaseScenario.RANDOM_MAP, json.decodeFromString<CatanOptions>(old).baseScenario)
val options = CatanOptions(3, 10, baseScenario = BaseScenario.HARBORMASTER)
assertEquals(options, json.decodeFromString<CatanOptions>(json.encodeToString(options)))
}
/** Vertices of the harbours, one per harbour, in board order. */
private fun CatanState.harbourVertices(): List<Int> = ports.map { topo.EDGE_VERTICES[it.edge].first }
private fun CatanState.with(seat: Int, vertex: Int, type: BuildingType) =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) })
@Test
fun `three harbour points make a harbormaster and more takes the title`() {
var s = game(BaseScenario.HARBORMASTER).copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
val v = s.harbourVertices()
s = s.with(0, v[0], BuildingType.SETTLEMENT).with(0, v[1], BuildingType.SETTLEMENT)
assertEquals(2, CatanRules.harbourPoints(s, 0))
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
// A city counts two: 1 + 2 = 3.
s = CatanRules.refreshHarbormaster(s.with(0, v[1], BuildingType.CITY))
assertEquals(3, CatanRules.harbourPoints(s, 0))
assertEquals(0, s.harbormaster)
assertEquals(CatanRules.publicVictoryPoints(s.copy(harbormaster = null))[0] + 2, CatanRules.publicVictoryPoints(s)[0])
// Equal points do not take it; strictly more does.
s = CatanRules.refreshHarbormaster(
s.with(1, v[2], BuildingType.SETTLEMENT).with(1, v[3], BuildingType.SETTLEMENT).with(1, v[4], BuildingType.SETTLEMENT),
)
assertEquals(0, s.harbormaster)
s = CatanRules.refreshHarbormaster(s.with(1, v[5], BuildingType.SETTLEMENT))
assertEquals(1, s.harbormaster)
assertTrue(s.log.any { "taken the title harbormaster from seat 0" in it })
}
@Test
fun `the title does not exist in other scenarios or in a Seafarers game`() {
var s = game(BaseScenario.RANDOM_MAP).copy(phase = CatanPhase.PLAYING)
val v = s.harbourVertices()
for (i in 0..3) s = s.with(0, v[i], BuildingType.SETTLEMENT)
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
}
@Test
fun `engines finish games on the base scenarios, with and without Cities and Knights`() {
var titles = 0
for (scenario in listOf(BaseScenario.FIRST_ISLAND, BaseScenario.HARBORMASTER)) {
for (expansions in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
for ((players, seed) in listOf(3 to 31L, 4 to 32L)) {
val opts = CatanOptions(players, if (expansions.isEmpty()) 10 else 13, expansions, baseScenario = scenario)
var state = CatanRules.initialState(config(opts, seed))
val ai = CatanAi()
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("$scenario $expansions seed $seed ran long", moves++ < 20_000)
val seat = CatanRules.currentSeat(state)!!
val legal = CatanRules.legalMoves(state, seat)
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
if (state.harbormaster != null) titles++
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
}
}
assertTrue("nobody ever became harbormaster", titles > 0)
}
}
@@ -25,7 +25,7 @@ class CatanCkRulesTest {
CatanOptions( CatanOptions(
playerCount = players, playerCount = players,
pointsToWin = 13, pointsToWin = 13,
ruleSet = if (ck) CatanRuleSet.CITIES_AND_KNIGHTS else CatanRuleSet.BASE, expansions = if (ck) setOf(CatanExpansion.CITIES_AND_KNIGHTS) else emptySet(),
), ),
), ),
seed = seed, seed = seed,
@@ -138,7 +138,7 @@ class CatanCkRulesTest {
val base = CatanRules.initialState(config(ck = false)) val base = CatanRules.initialState(config(ck = false))
assertNull(base.ck) assertNull(base.ck)
assertEquals(25, base.devDeck.size) assertEquals(25, base.devDeck.size)
assertEquals(CatanRuleSet.BASE, base.options.ruleSet) assertEquals(emptySet<CatanExpansion>(), base.options.expansions)
} }
@Test @Test
@@ -157,7 +157,21 @@ class CatanCkRulesTest {
fun `the options without a rule set still read as the base game`() { fun `the options without a rule set still read as the base game`() {
val old = """{"playerCount":4,"pointsToWin":10}""" val old = """{"playerCount":4,"pointsToWin":10}"""
val options = Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(old) val options = Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(old)
assertEquals(CatanRuleSet.BASE, options.ruleSet) assertEquals(emptySet<CatanExpansion>(), options.expansions)
}
@Test
fun `options saved with a rule set still read as the expansion they named`() {
val read = { text: String -> Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(text) }
assertEquals(
setOf(CatanExpansion.CITIES_AND_KNIGHTS),
read("""{"playerCount":3,"pointsToWin":13,"ruleSet":"CITIES_AND_KNIGHTS"}""").expansions,
)
assertEquals(
setOf(CatanExpansion.SEAFARERS),
read("""{"playerCount":3,"pointsToWin":12,"ruleSet":"SEAFARERS"}""").expansions,
)
assertEquals(emptySet<CatanExpansion>(), read("""{"ruleSet":"BASE"}""").expansions)
} }
@Test @Test
@@ -152,7 +152,7 @@ class CatanCkSelfPlayTest {
fun `three engines play Cities and Knights to a finish`() { fun `three engines play Cities and Knights to a finish`() {
val reports = (1L..12L).map { seed -> val reports = (1L..12L).map { seed ->
val (state, report) = playOut( val (state, report) = playOut(
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS), CatanOptions(playerCount = 3, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
seed, seed,
) )
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase) assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
@@ -171,7 +171,7 @@ class CatanCkSelfPlayTest {
fun `four engines play Cities and Knights to a finish`() { fun `four engines play Cities and Knights to a finish`() {
val reports = (101L..108L).map { seed -> val reports = (101L..108L).map { seed ->
val (state, report) = playOut( val (state, report) = playOut(
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS), CatanOptions(playerCount = 4, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
seed, seed,
) )
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase) assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
@@ -115,8 +115,9 @@ class CatanRulesTest {
// -- Setup --------------------------------------------------------------- // -- Setup ---------------------------------------------------------------
@Test @Test
fun `setup runs in snake order and then seat zero leads`() { fun `setup runs in snake order from the first seat and then that seat leads`() {
var state = CatanRules.initialState(config()) var state = CatanRules.initialState(config())
val first = state.firstSeat
val order = mutableListOf<Int>() val order = mutableListOf<Int>()
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) { while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
val seat = CatanRules.currentSeat(state)!! val seat = CatanRules.currentSeat(state)!!
@@ -126,12 +127,21 @@ class CatanRulesTest {
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(), CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
) )
} }
assertEquals(listOf(0, 1, 2, 2, 1, 0), order) val forward = (0 until 3).map { (first + it) % 3 }
assertEquals(forward + forward.reversed(), order)
assertEquals(CatanPhase.AWAIT_ROLL, state.phase) assertEquals(CatanPhase.AWAIT_ROLL, state.phase)
assertEquals(0, state.turn) assertEquals(first, state.turn)
assertEquals(1, state.turnNumber) assertEquals(1, state.turnNumber)
} }
@Test
fun `the first seat comes from the seed and varies between tables`() {
val firsts = (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat }
assertEquals(firsts, (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat })
assertEquals(setOf(0, 1, 2), firsts.toSet())
assertEquals(firsts[0], CatanRules.initialState(config(seed = 1L)).turn)
}
@Test @Test
fun `the second settlement earns one card from each adjacent land`() { fun `the second settlement earns one card from each adjacent land`() {
var state = CatanRules.initialState(config()) var state = CatanRules.initialState(config())
@@ -0,0 +1,154 @@
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.assertNotNull
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
/** Cities & Knights played on a Seafarers map. */
class CatanSeaCkTest {
private val json = Json { encodeDefaults = true }
private fun options(scenario: SeaScenario = SeaScenario.NEW_SHORES, players: Int = 3) = CatanOptions(
playerCount = players,
pointsToWin = 14,
expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS),
scenario = scenario,
)
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,
)
private fun start(scenario: SeaScenario = SeaScenario.NEW_SHORES, seed: Long = 7L) =
CatanRules.initialState(config(options(scenario), seed))
private fun CatanState.city(vertex: Int, seat: Int) =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(BuildingType.CITY, seat) })
@Test
fun `both expansions switch on together and the state carries both`() {
val s = start()
assertNotNull(s.ck)
assertNotNull(s.sea)
assertTrue(s.devDeck.isEmpty())
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), s.options.expansions)
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
}
@Test
fun `a ship is on offer in play, and a knight can stand at the end of a ship route on land`() {
val s0 = start()
val topo = s0.topo
// A coastal home town with a sea edge leaving it.
val town = (0 until topo.VERTEX_COUNT).first { v ->
topo.vertexIsland[v] == s0.sea!!.home &&
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
val s = s0.city(town, 0).copy(
phase = CatanPhase.PLAYING, turn = 0, turnNumber = 3,
resources = List(3) { Resource.entries.associateWith { 5 } },
)
val moves = CatanRules.legalMoves(s, 0)
assertTrue("no ship on offer", moves.any { it is BuildShip })
assertTrue("sea vertices are never knight posts", moves.filterIsInstance<HireKnight>().all { topo.vertexLand[it.vertex] })
}
@Test
fun `a gold field pays its owner a choice after the roll, then play goes on`() {
val s0 = start()
val topo = s0.topo
val gold = s0.terrains.indexOf(Terrain.GOLD)
val number = s0.numbers[gold]
val vertex = topo.HEX_CORNERS[gold].first { topo.vertexLand[it] }
val dice = (1..6).flatMap { a -> (1..6).map { b -> listOf(a, b) } }.first { it.sum() == number }
val s = s0.city(vertex, 0).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 3, robber = 0)
val rolled = CatanRules.applyMove(s, 0, RollDice(forced = dice, event = EventFace.TRADE))
assertEquals(CatanPhase.GOLD, rolled.phase)
assertEquals(listOf(GoldOwed(0, 2)), rolled.sea!!.gold)
val pick = CatanRules.legalMoves(rolled, 0).filterIsInstance<PickGold>().first { it.cards == listOf(Resource.ORE, Resource.ORE) }
val after = CatanRules.applyMove(rolled, 0, pick)
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(2, after.resources[0].getValue(Resource.ORE) - s.resources[0].getValue(Resource.ORE))
}
@Test
fun `once the barbarians have landed a seven may send the pirate, which robs goods`() {
val s0 = start()
val topo = s0.topo
val seaHex = (0 until topo.HEX_COUNT).first { !topo.land[it] && it != s0.sea!!.pirate &&
(0 until topo.EDGE_COUNT).any { e -> it in topo.EDGE_HEXES[e] && topo.edgeLand[e] } }
val edge = (0 until topo.EDGE_COUNT).first { seaHex in topo.EDGE_HEXES[it] }
val s = s0.copy(
phase = CatanPhase.ROBBER, turn = 0, turnNumber = 3,
ck = s0.ck!!.copy(attacked = true),
sea = s0.sea!!.copy(ships = s0.sea!!.ships.toMutableList().also { it[edge] = 1 }),
resources = s0.resources.toMutableList().also { it[1] = it[1] + (Resource.GRAIN to 2) },
)
val pirate = CatanRules.legalMoves(s, 0).filterIsInstance<MovePirate>()
.first { it.hex == topo.HEXES[seaHex] && it.victim == 1 }
val after = CatanRules.applyMove(s, 0, pirate)
assertEquals(seaHex, after.sea!!.pirate)
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(CatanCk.goodsCount(s, 0) + 1, CatanCk.goodsCount(after, 0))
assertEquals(CatanCk.goodsCount(s, 1) - 1, CatanCk.goodsCount(after, 1))
// The robber itself is kept off the sea and the fog.
val robber = CatanRules.legalMoves(s, 0).filterIsInstance<MoveRobber>()
assertTrue(robber.all { topo.land[topo.hexIndex.getValue(it.hex)] })
}
@Test
fun `first landings count toward the target`() {
val s0 = start()
val sea = s0.sea!!
val islet = (0 until s0.topo.islandCount).first { !sea.isStart(it) }
val withBonus = s0.copy(sea = sea.copy(islandBonus = sea.islandBonus.toMutableList().also { it[islet] = 2 }))
assertEquals(CatanCk.publicVictoryPoints(s0)[2] + SeaRules.ISLAND_BONUS, CatanCk.publicVictoryPoints(withBonus)[2])
}
@Test
fun `engines finish a Cities and Knights game on three maps`() {
var ships = 0
var knights = 0
var barbarianAttacks = 0
for ((scenario, players, seed) in listOf(
Triple(SeaScenario.NEW_SHORES, 3, 21L),
Triple(SeaScenario.FOUR_ISLANDS, 4, 22L),
Triple(SeaScenario.FOG_ISLANDS, 3, 23L),
)) {
val ai = CatanAi()
var state = CatanRules.initialState(config(options(scenario, players), seed))
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 40_000)
val seat = CatanRules.currentSeat(state) ?: error("$scenario: nobody to move in ${state.phase}; ${state.log.takeLast(4)}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("$scenario: no legal move for $seat in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue("$scenario: illegal $move in ${state.phase}", CatanRules.isLegalMove(state, seat, move))
state = CatanRules.applyMove(state, seat, move)
if (moves % 40 == 0) assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
assertFalse(state.winner == null)
ships += state.sea!!.ships.count { it != NO_PLAYER }
knights += state.ck!!.knights.size
if (state.ck!!.attacked) barbarianAttacks++
}
assertTrue("no ships were built", ships > 0)
assertTrue("no knights were hired", knights > 0)
assertTrue("the barbarians never landed", barbarianAttacks > 0)
}
}
@@ -0,0 +1,336 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** The scenarios with rules of their own: cloth villages and tribe chips. */
class CatanSeaExtrasTest {
private val json = Json { encodeDefaults = true }
private fun start(scenario: SeaScenario, seed: Long = 5L, players: Int = 3): CatanState {
val options = CatanOptions(
playerCount = players,
pointsToWin = 14,
expansions = setOf(CatanExpansion.SEAFARERS),
scenario = scenario,
)
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(options),
seed = seed,
)
return CatanRules.initialState(config).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
}
private fun dice(total: Int): List<Int> = if (total <= 7) listOf(1, total - 1) else listOf(total - 6, 6)
/** A cloth game with seat 0 and seat 1 both settled beside the first village, the robber elsewhere. */
private fun clothGame(): Pair<CatanState, Int> {
val s = start(SeaScenario.CLOTH_FOR_CATAN)
val village = s.terrains.indexOf(Terrain.VILLAGE)
val corners = s.topo.HEX_CORNERS[village]
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
val staged = s.copy(
robber = elsewhere,
buildings = s.buildings.toMutableList().also {
it[corners[0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
it[corners[3]] = CatanBuilding(BuildingType.CITY, 1)
},
)
return staged to village
}
private fun roll(s: CatanState, number: Int) = CatanRules.applyMove(s, s.turn, RollDice(dice(number)))
@Test
fun `a village pays one cloth to every settlement beside it, a city no more`() {
val (s, village) = clothGame()
val number = s.numbers[village]
assertTrue(number in 2..12 && number != 7)
val after = roll(s, number)
assertEquals(listOf(1, 1, 0), after.sea!!.cloth)
assertEquals(SeaBoards.VILLAGE_CLOTH - 2, after.sea!!.villageCloth[village])
assertTrue(after.log.any { "village" in it && "cloth" in it })
}
@Test
fun `an empty village pays nothing and the last token goes to the seat nearest the roller`() {
val (s, village) = clothGame()
val low = s.copy(sea = s.sea!!.copy(villageCloth = s.sea!!.villageCloth.toMutableList().also { it[village] = 1 }))
val after = roll(low.copy(turn = 1), low.numbers[village])
assertEquals(listOf(0, 1, 0), after.sea!!.cloth)
assertEquals(0, after.sea!!.villageCloth[village])
val again = roll(after.copy(phase = CatanPhase.AWAIT_ROLL), after.numbers[village])
assertEquals(listOf(0, 1, 0), again.sea!!.cloth)
}
@Test
fun `the robber on a village stops it`() {
val (s, village) = clothGame()
val after = roll(s.copy(robber = village), s.numbers[village])
assertEquals(listOf(0, 0, 0), after.sea!!.cloth)
}
@Test
fun `every two cloth are a victory point, and the whole thing survives the wire format`() {
val (s, _) = clothGame()
val rich = s.copy(sea = s.sea!!.copy(cloth = listOf(5, 1, 0)))
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 2, CatanRules.publicVictoryPoints(rich)[0])
assertEquals(CatanRules.publicVictoryPoints(s)[1], CatanRules.publicVictoryPoints(rich)[1])
assertEquals(rich, CatanRules.decodeState(CatanRules.encodeState(rich)))
}
@Test
fun `villages pay cloth with Cities and Knights too`() {
val options = CatanOptions(
playerCount = 3,
pointsToWin = 14,
expansions = setOf(CatanExpansion.SEAFARERS, CatanExpansion.CITIES_AND_KNIGHTS),
scenario = SeaScenario.CLOTH_FOR_CATAN,
)
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(options),
seed = 5L,
)
val s = CatanRules.initialState(config)
.copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
val village = s.terrains.indexOf(Terrain.VILLAGE)
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
val staged = s.copy(
robber = elsewhere,
buildings = s.buildings.toMutableList().also {
it[s.topo.HEX_CORNERS[village][0]] = CatanBuilding(BuildingType.SETTLEMENT, 2)
},
)
val after = roll(staged, staged.numbers[village])
assertEquals(1, after.sea!!.cloth[2])
}
// ---- The Forgotten Tribe ----
/** A tribe game, seat 0 with a road reaching a settlement site beside [chipHex], and plenty of cards. */
private fun tribeGame(chip: TribeChip): Triple<CatanState, Int, Int> {
val s = start(SeaScenario.FORGOTTEN_TRIBE)
val hex = s.sea!!.chips.indexOfFirst { it != TribeChip.NONE }
val chips = s.sea!!.chips.toMutableList().also { it[hex] = chip }
val topo = s.topo
val vertex = topo.HEX_CORNERS[hex].first { v -> topo.VERTEX_NEIGHBORS[v].all { s.buildings[it] == null } }
val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeLand[it] }
val staged = s.copy(
phase = CatanPhase.PLAYING,
roads = s.roads.toMutableList().also { it[edge] = 0 },
resources = List(3) { Resource.entries.associateWith { 4 } },
sea = s.sea!!.copy(chips = chips),
)
return Triple(staged, hex, vertex)
}
@Test
fun `the first settler beside a point chip takes the point`() {
val (s, hex, vertex) = tribeGame(TribeChip.POINT)
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(1, after.sea!!.chipPoints[0])
assertEquals(TribeChip.NONE, after.sea!!.chips[hex])
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 1 + 1 + 2, CatanRules.publicVictoryPoints(after)[0])
}
@Test
fun `a harbour chip trades three to one anywhere`() {
val (s, _, vertex) = tribeGame(TribeChip.HARBOUR)
assertEquals(4, CatanRules.bestPortRatio(s, 0, Resource.WOOL))
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(listOf(0), after.sea!!.harbourSeats)
assertEquals(3, CatanRules.bestPortRatio(after, 0, Resource.WOOL))
assertEquals(4, CatanRules.bestPortRatio(after, 1, Resource.WOOL))
assertTrue(CatanRules.legalMoves(after, 0).any { it is MaritimeTrade })
}
@Test
fun `a gold chip asks for two resources of the settler's choice`() {
val (s, _, vertex) = tribeGame(TribeChip.GOLD)
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(CatanPhase.GOLD, after.phase)
assertEquals(listOf(GoldOwed(0, 2)), after.sea!!.gold)
val done = CatanRules.applyMove(after, 0, CatanRules.legalMoves(after, 0).first())
assertEquals(CatanPhase.PLAYING, done.phase)
}
// ---- Pirate Islands ----
private fun fortGame(): Triple<CatanState, Int, Int> {
val s = start(SeaScenario.PIRATE_ISLANDS).copy(
phase = CatanPhase.PLAYING,
resources = List(3) { Resource.entries.associateWith { 6 } },
)
val fort = s.terrains.indexOf(Terrain.FORTRESS)
val edge = (0 until s.topo.EDGE_COUNT).first { s.topo.edgeSea[it] && fort in s.topo.EDGE_HEXES[it] }
return Triple(s, fort, edge)
}
@Test
fun `nobody settles a fortress island until the fortress falls`() {
val (s, fort, _) = fortGame()
val corner = s.topo.HEX_CORNERS[fort].first()
assertTrue(SeaRules.fortressHolds(s, corner))
assertTrue(!CatanRules.vertexOpenForSettlement(s, corner))
val fort2 = s.terrains.indices.first { s.terrains[it] == Terrain.FORTRESS && it != fort }
assertTrue(s.topo.islandOf[fort] != s.topo.islandOf[fort2])
}
@Test
fun `a raid needs a ship beside the fortress and three cards, and three raids take it`() {
val (s, fort, edge) = fortGame()
val coord = s.topo.HEXES[fort]
assertTrue(CatanRules.legalMoves(s, 0).none { it is RaidFortress })
var state = s.copy(sea = s.sea!!.copy(ships = s.sea!!.ships.toMutableList().also { it[edge] = 0 }))
assertTrue(RaidFortress(coord) in CatanRules.legalMoves(state, 0))
val before = state.resources[0].getValue(Resource.ORE)
repeat(3) { state = CatanRules.applyMove(state, 0, RaidFortress(coord)) }
assertEquals(before - 3, state.resources[0].getValue(Resource.ORE))
assertEquals(Terrain.DESERT, state.terrains[fort])
assertEquals(2, state.sea!!.raidPoints[0])
assertTrue(!SeaRules.fortressHolds(state, s.topo.HEX_CORNERS[fort].first()))
assertTrue(CatanRules.legalMoves(state, 0).none { it is RaidFortress })
// Seat 1 has no ship there and cannot raid.
assertTrue(CatanRules.legalMoves(state.copy(turn = 1), 1).none { it is RaidFortress })
}
// ---- Wonders of Catan ----
private fun wonderGame(city: Boolean = true): CatanState {
val s = start(SeaScenario.WONDERS_OF_CATAN).copy(
phase = CatanPhase.PLAYING,
resources = List(3) { Resource.entries.associateWith { 9 } },
)
return if (!city) s else s.copy(
buildings = s.buildings.toMutableList().also { it[0] = CatanBuilding(BuildingType.CITY, 0) },
)
}
@Test
fun `a wonder needs a city, each stage is a point, and the fourth stage wins`() {
assertTrue(CatanRules.legalMoves(wonderGame(city = false), 0).none { it is BuildWonder })
var s = wonderGame()
val base = CatanRules.publicVictoryPoints(s)[0]
for (stage in 1..3) {
assertTrue(BuildWonder in CatanRules.legalMoves(s, 0))
s = CatanRules.applyMove(s, 0, BuildWonder)
assertEquals(stage, s.sea!!.wonders[0])
assertEquals(base + stage, CatanRules.publicVictoryPoints(s)[0])
assertEquals(CatanPhase.PLAYING, s.phase)
}
s = CatanRules.applyMove(s, 0, BuildWonder)
assertEquals(CatanPhase.GAME_OVER, s.phase)
assertEquals(0, s.winner)
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
}
@Test
fun `a wonder stage is not offered without the cards`() {
val s = wonderGame().copy(resources = List(3) { Resource.entries.associateWith { 0 } })
assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildWonder })
}
// ---- Treasure Islands, Oases ----
@Test
fun `chests lie under the fog and are hidden from other views`() {
for (scenario in listOf(SeaScenario.TREASURE_ISLANDS, SeaScenario.OASES)) {
val s = start(scenario)
val chests = s.sea!!.treasures.indices.filter { s.sea!!.treasures[it] != Treasure.NONE }
assertEquals(minOf(5, s.terrains.count { it == Terrain.FOG }), chests.size)
assertTrue(chests.all { s.terrains[it] == Terrain.FOG })
assertTrue(CatanRules.viewFor(s, 1).sea!!.treasures.isEmpty())
}
}
@Test
fun `an oasis map starts everyone on the desert island, never beside an oasis`() {
val s = start(SeaScenario.OASES)
assertEquals(37, s.topo.islandOf.count { it == s.sea!!.home })
val moves = CatanRules.legalMoves(s.copy(phase = CatanPhase.SETUP_FIRST), s.firstSeat).filterIsInstance<SetupPlace>()
assertTrue(moves.isNotEmpty())
assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } })
}
/** A chest of [kind] under the first fog hex, with a road of seat 0's laid up to the edge that will reveal it. */
private fun chestGame(kind: Treasure): Triple<CatanState, Int, Int> {
val s = start(SeaScenario.OASES).copy(phase = CatanPhase.PLAYING)
val topo = s.topo
val hex = s.terrains.indexOf(Terrain.FOG)
val chests = List(topo.HEX_COUNT) { if (it == hex) kind else Treasure.NONE }
// Seat 0 owns the town beside a road that reaches the oasis; building the road finds it.
val edge = topo.EDGE_VERTICES.indices.first { e -> hex in topo.EDGE_HEXES[e] && topo.edgeLand[e] }
val staged = s.copy(
resources = List(3) { Resource.entries.associateWith { 5 } },
sea = s.sea!!.copy(treasures = chests),
)
return Triple(staged, hex, edge)
}
@Test
fun `opening a chest pays its reward`() {
val kinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.ROADS, Treasure.DEV_CARD, Treasure.CHOICE)
for (kind in kinds) {
val (s, _, _) = chestGame(kind)
// Stage the find directly: reveal as if a road had just reached the hex.
val topo = s.topo
val hex = s.terrains.indexOf(Terrain.FOG)
val edge = (0 until topo.EDGE_COUNT).first { hex in topo.EDGE_HEXES[it] && topo.edgeLand[it] }
val after = SeaRules.reveal(s, 0, edge)
assertTrue("$kind: the chest is announced", after.log.any { "treasure chest" in it })
when (kind) {
Treasure.DEV_CARD -> assertEquals(1, after.devHands[0].size)
Treasure.CHOICE -> assertTrue(after.phase == CatanPhase.GOLD || after.log.any { "uncovers" in it })
Treasure.ROADS -> assertTrue(after.resources[0].getValue(Resource.LUMBER) >= 7)
else -> assertTrue(after.resources[0].getValue(Resource.WOOL) >= 6)
}
}
}
// ---- The Great Canal ----
private fun canalGame(): Triple<CatanState, Int, CatanState> {
val s = start(SeaScenario.GREAT_CANAL).copy(
phase = CatanPhase.PLAYING,
resources = List(3) { Resource.entries.associateWith { 9 } },
)
val tile = s.sea!!.canalNumbers.indexOfFirst { it != 0 }
val staged = s.copy(
buildings = s.buildings.toMutableList().also {
it[s.topo.HEX_CORNERS[tile][0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
},
)
return Triple(staged, tile, s)
}
@Test
fun `a canal tile hides its number, and three digs turn it into fields`() {
var (s, tile, _) = canalGame()
assertEquals(0, s.numbers[tile])
assertEquals(Terrain.DESERT, s.terrains[tile])
val number = s.sea!!.canalNumbers[tile]
val dig = DigCanal(s.topo.HEXES[tile])
assertTrue(dig in CatanRules.legalMoves(s, 0))
assertTrue(CatanRules.legalMoves(s.copy(turn = 1), 1).none { it is DigCanal })
val before = CatanRules.publicVictoryPoints(s)[0]
repeat(2) { s = CatanRules.applyMove(s, 0, dig) }
assertEquals(Terrain.DESERT, s.terrains[tile])
assertEquals(before + 1, CatanRules.publicVictoryPoints(s)[0])
s = CatanRules.applyMove(s, 0, dig)
assertEquals(Terrain.FIELDS, s.terrains[tile])
assertEquals(number, s.numbers[tile])
assertTrue(CatanRules.legalMoves(s, 0).none { it is DigCanal && it.hex == dig.hex })
}
}
@@ -0,0 +1,266 @@
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.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
/** What every Seafarers map owes the engine, and the shape of each one. */
class CatanSeaScenariosTest {
private val json = Json { encodeDefaults = true }
private fun options(scenario: SeaScenario, players: Int = 3) = CatanOptions(
playerCount = players,
pointsToWin = 12,
expansions = setOf(CatanExpansion.SEAFARERS),
scenario = scenario,
)
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,
)
private fun sizes(scenario: SeaScenario): List<Int> {
val topo = SeaBoards.topology(scenario)
return (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted()
}
@Test
fun `each map has the islands it is named for`() {
assertEquals(listOf(2, 3, 3, 21), sizes(SeaScenario.NEW_SHORES))
assertEquals(listOf(1, 1, 6, 6, 6, 6), sizes(SeaScenario.FOUR_ISLANDS))
assertEquals(listOf(1, 1, 1, 2, 25), sizes(SeaScenario.THROUGH_THE_DESERT))
assertEquals(listOf(2, 13, 14), sizes(SeaScenario.FOG_ISLANDS))
assertEquals(listOf(1, 3, 4, 15), sizes(SeaScenario.TREASURE_ISLANDS))
assertEquals(listOf(37), sizes(SeaScenario.OASES))
assertEquals(listOf(2, 25), sizes(SeaScenario.GREAT_CANAL))
assertEquals(listOf(4, 5, 18), sizes(SeaScenario.PIRATE_ISLANDS))
assertEquals(listOf(1, 3, 12, 13), sizes(SeaScenario.WONDERS_OF_CATAN))
assertEquals(listOf(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN))
assertEquals(listOf(2, 3, 5, 17), sizes(SeaScenario.FORGOTTEN_TRIBE))
}
@Test
fun `every map deals numbers, harbours and a pirate at sea, with the reds apart`() {
for (scenario in SeaScenario.entries) {
repeat(5) { i ->
val s = CatanRules.initialState(config(options(scenario), 300L + i))
val topo = s.topo
val deserts = s.terrains.count { it == Terrain.DESERT }
for (h in 0 until topo.HEX_COUNT) {
val land = s.terrains[h] != Terrain.SEA
assertEquals("$scenario hex $h", topo.land[h], land)
val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG ||
s.terrains[h] == Terrain.FORTRESS
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
}
val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN &&
scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS &&
scenario != SeaScenario.TREASURE_ISLANDS
assertTrue("$scenario has no desert", deserts > 0 || !deserted)
// Under fog every hex is face down with no number; the tiles are in the pile.
val fog = s.terrains.indices.filter { s.terrains[it] == Terrain.FOG }
assertEquals(fog.size, s.sea!!.fogDeck.size)
fog.forEach { assertEquals(0, s.numbers[it]) }
assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate])
assertEquals("$scenario harbours", 9, s.ports.size)
val reds = (0 until topo.HEX_COUNT).filter { s.numbers[it] == 6 || s.numbers[it] == 8 }
for (a in reds) for (b in reds) if (a < b) assertFalse("$scenario reds touch", topo.hexesAdjacent(a, b))
assertEquals(scenario, s.sea!!.scenario)
}
}
}
@Test
fun `four islands start on any main island and the islets earn the bonus`() {
val s = CatanRules.initialState(config(options(SeaScenario.FOUR_ISLANDS), 5L))
val sea = s.sea!!
assertEquals(4, sea.starts.size)
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
assertEquals(sea.starts.toSet(), moves.map { s.topo.vertexIsland[it.vertex] }.toSet())
for (island in 0 until s.topo.islandCount) assertEquals(island in sea.starts, sea.isStart(island))
// A settlement on a starting island scores no first-landing bonus.
val onMain = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] == sea.starts.first() }
assertEquals(s, SeaRules.claimIsland(s, 0, onMain))
val islet = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] >= 0 && s.topo.vertexIsland[it] !in sea.starts }
assertEquals(0, SeaRules.bonusPoints(s, 0))
assertEquals(0, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, onMain), 0))
assertEquals(SeaRules.ISLAND_BONUS, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, islet), 0))
}
@Test
fun `through the desert starts everyone on the one big island`() {
val s = CatanRules.initialState(config(options(SeaScenario.THROUGH_THE_DESERT), 5L))
assertEquals(listOf(s.sea!!.home), s.sea!!.starts)
assertEquals(25, s.topo.islandOf.count { it == s.sea!!.home })
assertTrue(s.terrains.count { it == Terrain.DESERT } >= 5)
}
@Test
fun `a save from before there were several starts still starts on the home island`() {
val s = CatanRules.initialState(config(options(SeaScenario.NEW_SHORES), 5L))
val old = s.copy(sea = s.sea!!.copy(starts = emptyList()))
assertTrue(old.sea!!.isStart(old.sea!!.home))
val moves = CatanRules.legalMoves(old, old.firstSeat).filterIsInstance<SetupPlace>()
assertTrue(moves.all { old.topo.vertexIsland[it.vertex] == old.sea!!.home })
}
private fun fogGame(seed: Long = 5L) = CatanRules.initialState(config(options(SeaScenario.FOG_ISLANDS), seed)).let {
it.copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1, setupIndex = 6)
}
/** A town site on the home island, ships already laid toward the fog, and the one edge that will reach it. */
private data class FogRoute(val town: Int, val laid: List<Int>, val last: Int)
private fun CatanState.fogRoute(): FogRoute {
val topo = topo
fun touchesFog(v: Int) = topo.VERTEX_HEXES[v].any { terrains[it] == Terrain.FOG }
val seen = mutableSetOf<Int>()
for (start in 0 until topo.VERTEX_COUNT) {
if (topo.vertexIsland[start] != sea!!.home || touchesFog(start)) continue
if (topo.VERTEX_EDGES[start].none { topo.edgeSea[it] && !topo.edgeLand[it] }) continue
val queue = ArrayDeque<Pair<Int, List<Int>>>().apply { add(start to emptyList()) }
seen.clear()
while (queue.isNotEmpty()) {
val (v, path) = queue.removeFirst()
if (!seen.add(v)) continue
for (e in topo.VERTEX_EDGES[v]) {
if (!topo.edgeSea[e] || topo.edgeLand[e]) continue
val far = topo.otherEnd(e, v)
if (touchesFog(far)) return FogRoute(start, path, e)
if (path.size < 6) queue.add(far to path + e)
}
}
}
error("no ship route from home reaches the fog")
}
/** [fogRoute] laid on the board: the town and all but the last ship. */
private fun CatanState.onTheShore(route: FogRoute): CatanState = copy(
buildings = buildings.toMutableList().also { it[route.town] = CatanBuilding(BuildingType.SETTLEMENT, 0) },
sea = sea!!.copy(ships = sea!!.ships.toMutableList().also { l -> route.laid.forEach { l[it] = 0 } }),
resources = List(3) { Resource.entries.associateWith { 3 } },
)
@Test
fun `fog is hidden at the start, never a place to begin, and keeps the robber off`() {
val s = CatanRules.initialState(config(options(SeaScenario.FOG_ISLANDS), 5L))
assertFalse(s.sea!!.isStart(s.topo.islandOf.indices.first { s.terrains[it] == Terrain.FOG }.let { s.topo.islandOf[it] }))
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } })
val robbing = fogGame().copy(phase = CatanPhase.ROBBER)
val targets = CatanRules.legalMoves(robbing, 0).filterIsInstance<MoveRobber>()
assertTrue(targets.isNotEmpty())
assertTrue(targets.none { robbing.terrains[robbing.topo.hexIndex.getValue(it.hex)] == Terrain.FOG })
}
@Test
fun `a ship that reaches the fog turns the hex over and the finder takes a card`() {
val base = fogGame()
val route = base.fogRoute()
val edge = route.last
val s = base.onTheShore(route)
val tile = s.sea!!.fogDeck.first()
val after = CatanRules.applyMove(s, 0, BuildShip(edge))
val found = s.topo.EDGE_VERTICES[edge].toList().flatMap { s.topo.VERTEX_HEXES[it] }
.filter { s.terrains[it] == Terrain.FOG }.distinct().sorted()
val hex = found.first()
assertEquals(tile.terrain, after.terrains[hex])
assertEquals(tile.number, after.numbers[hex])
assertEquals(s.sea!!.fogDeck.size - found.size, after.sea!!.fogDeck.size)
val produced = tile.terrain.produces
if (produced != null) assertEquals(s.resources[0].getValue(produced) - (if (produced in SeaRules.SHIP_COST) 1 else 0) + 1,
after.resources[0].getValue(produced) - (found.drop(1).count { after.terrains[it].produces == produced }))
if (tile.terrain == Terrain.GOLD) assertEquals(CatanPhase.GOLD, after.phase)
// The find is public: the log names it.
assertTrue(after.log.last { "uncovers" in it }.isNotBlank())
}
@Test
fun `a gold find during road building asks for the choice and then goes back to the free roads`() {
val base = fogGame()
val route = base.fogRoute()
val edge = route.last
val gold = FogTile(Terrain.GOLD, 6)
val shore = base.onTheShore(route)
val s = shore.copy(
phase = CatanPhase.FREE_ROADS,
pendingFreeRoads = 2,
sea = shore.sea!!.copy(fogDeck = List(shore.sea!!.fogDeck.size) { gold }),
)
val after = CatanRules.applyMove(s, 0, BuildShip(edge))
assertEquals(CatanPhase.GOLD, after.phase)
val pick = CatanRules.legalMoves(after, 0).filterIsInstance<PickGold>().first()
val resumed = CatanRules.applyMove(after, 0, pick)
assertEquals(CatanPhase.FREE_ROADS, resumed.phase)
assertEquals(1, resumed.pendingFreeRoads)
}
@Test
fun `other seats do not see the face-down tiles and the state survives its wire format`() {
val s = fogGame()
assertTrue(s.sea!!.fogDeck.isNotEmpty())
assertTrue(CatanRules.viewFor(s, 1).sea!!.fogDeck.isEmpty())
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
}
@Test
fun `engines finish a game on every map at three and four players`() {
var fogFound = 0
var clothPaid = 0
var chipsTaken = 0
var fortsTaken = 0
var wonderStages = 0
var chestsOpened = 0
var canalDug = 0
for (scenario in SeaScenario.entries) {
for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) {
val opts = options(scenario, players)
val ai = CatanAi()
var state = CatanRules.initialState(config(opts, seed))
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 20_000)
val seat = CatanRules.currentSeat(state) ?: error("$scenario seed $seed: nobody to move in ${state.phase}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("$scenario seed $seed: no legal move for $seat in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue("$scenario seed $seed: illegal $move", CatanRules.isLegalMove(state, seat, move))
state = CatanRules.applyMove(state, seat, move)
}
assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase)
if (scenario == SeaScenario.FOG_ISLANDS) fogFound += 14 - state.sea!!.fogDeck.size
if (scenario == SeaScenario.CLOTH_FOR_CATAN) clothPaid += state.sea!!.cloth.sum()
if (scenario == SeaScenario.PIRATE_ISLANDS) fortsTaken += state.sea!!.raidPoints.sum() / 2
if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) {
chestsOpened += state.log.count { "treasure chest" in it }
}
if (scenario == SeaScenario.GREAT_CANAL) canalDug += state.sea!!.canalDug.sum()
if (scenario == SeaScenario.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum()
if (scenario == SeaScenario.FORGOTTEN_TRIBE) {
chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE }
}
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
}
assertTrue("no engine ever uncovered a fog hex", fogFound > 0)
assertTrue("no village ever paid cloth", clothPaid > 0)
assertTrue("no tribe chip was ever taken", chipsTaken > 0)
assertTrue("no fortress ever fell", fortsTaken > 0)
assertTrue("no chest was ever opened", chestsOpened > 0)
assertTrue("no canal unit was ever dug", canalDug > 0)
assertTrue("no wonder stage was ever built", wonderStages > 0)
}
}
@@ -17,7 +17,7 @@ class CatanSeafarersTest {
private val json = Json { encodeDefaults = true } private val json = Json { encodeDefaults = true }
private fun options(players: Int = 3) = private fun options(players: Int = 3) =
CatanOptions(playerCount = players, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS) CatanOptions(playerCount = players, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS))
private fun config(options: CatanOptions, seed: Long) = TableConfig( private fun config(options: CatanOptions, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN, gameId = GameCatalog.CATAN,
@@ -96,7 +96,7 @@ class CatanSeafarersTest {
@Test @Test
fun `setup places both settlements on the home island, with roads rather than ships`() { fun `setup places both settlements on the home island, with roads rather than ships`() {
val s = start() val s = start()
val moves = CatanRules.legalMoves(s, 0).filterIsInstance<SetupPlace>() val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
assertTrue(moves.isNotEmpty()) assertTrue(moves.isNotEmpty())
assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home }) assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home })
assertTrue(moves.all { s.topo.edgeLand[it.edge] }) assertTrue(moves.all { s.topo.edgeLand[it.edge] })
@@ -171,7 +171,7 @@ class SaveRoundTripAllGamesTest {
GameCatalog.CATAN, GameCatalog.CATAN,
CatanRules, CatanRules,
CatanAi(), CatanAi(),
opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)), opts(CatanOptions(3, 13, setOf(org.prolibertate.games.game.catan.CatanExpansion.CITIES_AND_KNIGHTS))),
steps = 600, steps = 600,
) )
} }
@@ -21,7 +21,7 @@ import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber import org.prolibertate.games.game.catan.ChaseRobber
@@ -69,7 +69,7 @@ class CatanCkInteractionTest {
gameId = GameCatalog.CATAN, gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString( optionsJson = json.encodeToString(
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS), CatanOptions(players, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
), ),
seed = seed, seed = seed,
) )
@@ -8,7 +8,7 @@ import org.junit.Test
import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.ProgressType import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerKind
@@ -16,7 +16,7 @@ import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.ui.catanDefaultPoints import org.prolibertate.games.ui.catanDefaultPoints
import org.prolibertate.games.ui.catanPointsChoices import org.prolibertate.games.ui.catanPointsChoices
import org.prolibertate.games.ui.catanSwitchRules import org.prolibertate.games.ui.catanToggleExpansion
class CatanCkTextTest { class CatanCkTextTest {
@@ -28,7 +28,7 @@ class CatanCkTextTest {
gameId = GameCatalog.CATAN, gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString( optionsJson = json.encodeToString(
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS), CatanOptions(3, 13, setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
), ),
seed = 31L, seed = 31L,
) )
@@ -56,20 +56,27 @@ class CatanCkTextTest {
@Test @Test
fun `the setup screen offers each rule set its own targets and switches sensibly`() { fun `the setup screen offers each rule set its own targets and switches sensibly`() {
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE)) assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(emptySet()))
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS)) assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(setOf(CatanExpansion.CITIES_AND_KNIGHTS)))
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE)) assertEquals(10, catanDefaultPoints(emptySet()))
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS)) assertEquals(13, catanDefaultPoints(setOf(CatanExpansion.CITIES_AND_KNIGHTS)))
val base = CatanOptions() val base = CatanOptions()
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS) val ck = catanToggleExpansion(base, CatanExpansion.CITIES_AND_KNIGHTS, true)
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet) assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS), ck.expansions)
assertEquals("the published target for the new rules", 13, ck.pointsToWin) assertEquals("the published target for the new rules", 13, ck.pointsToWin)
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin) assertEquals(10, catanToggleExpansion(ck, CatanExpansion.CITIES_AND_KNIGHTS, false).pointsToWin)
// A short game picked on purpose is kept when both menus have it. // A short game picked on purpose is kept when both menus have it.
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS) val short = catanToggleExpansion(base.copy(pointsToWin = 8), CatanExpansion.CITIES_AND_KNIGHTS, true)
assertEquals(8, short.pointsToWin) assertEquals(8, short.pointsToWin)
assertEquals(3, ck.playerCount) assertEquals(3, ck.playerCount)
// Expansions stack: both on has its own menu, and turning one off leaves the other.
val both = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true)
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), both.expansions)
assertEquals(listOf(10, 12, 14, 16), catanPointsChoices(both.expansions))
assertEquals(14, both.pointsToWin)
assertEquals(setOf(CatanExpansion.SEAFARERS), catanToggleExpansion(both, CatanExpansion.CITIES_AND_KNIGHTS, false).expansions)
} }
} }
@@ -28,7 +28,8 @@ internal object CatanFixtures {
/** A fresh table, on the first setup placement. */ /** A fresh table, on the first setup placement. */
fun start(players: Int = 3, seed: Long = 7L): CatanState = fun start(players: Int = 3, seed: Long = 7L): CatanState =
CatanRules.initialState(config(players, seed)) // These tests are about the screen, not who starts: pin seat 0 first.
CatanRules.initialState(config(players, seed)).let { it.copy(firstSeat = 0, turn = 0) }
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */ /** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
fun playing( fun playing(
@@ -12,7 +12,7 @@ import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanBuilding import org.prolibertate.games.game.catan.CatanBuilding
import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.MovePirate import org.prolibertate.games.game.catan.MovePirate
@@ -29,7 +29,7 @@ import org.prolibertate.games.game.engine.TableConfig
class CatanSeafarersInteractionTest { class CatanSeafarersInteractionTest {
private fun fresh(): CatanState { private fun fresh(): CatanState {
val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS) val options = CatanOptions(playerCount = 3, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS))
return CatanRules.initialState( return CatanRules.initialState(
TableConfig( TableConfig(
gameId = GameCatalog.CATAN, gameId = GameCatalog.CATAN,
+7 -1
View File
@@ -66,7 +66,13 @@ A full-width panel that slides up over the bottom of the screen (`Animator`, col
* **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a * **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a
road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`. road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`.
* **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are * **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are
**not** part of the physical Seafarers rules; they are out of scope here. **not** part of the physical Seafarers rules. Names and texts only (rule values are compiled code):
`TreasureRule` with chest outcomes (bricks, wool, grain; a resource; a development card; two roads),
`SeafarerOasis` (no rule class of its own, base-game rules), and `GreatCanalRule` with `BuildCanal`,
`CanalVictoryPoints`, "The canal was completely dug. Now grain can be grown." and a target of 21 / 18 points.
The base-game scenario `baseharbormaster`: "2 victory points. This card goes to the first player to acquire at
least 3 harbors. If another player acquires more, he will immediately receive this card."
Implemented as inferred rules; see `docs/RULES-catan-seafarers.md`.
* **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip` * **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip`
(number tokens placed per scenario), `Shuffle_Harbors`. (number tokens placed per scenario), `Shuffle_Harbors`.
* **Island bonus:** special victory points for the first settlement on a new island * **Island bonus:** special victory points for the first settlement on a new island
+7
View File
@@ -220,3 +220,10 @@ game in `CatanCkSelfPlayTest` ends in a win at thirteen points.
- No animation for the event die, the barbarian landing or a knight's move. - No animation for the event die, the barbarian landing or a knight's move.
- The Commercial Harbor offers a single resource type (see above). - The Commercial Harbor offers a single resource type (see above).
- The AI is not a strong opponent and makes no claim to be. - The AI is not a strong opponent and makes no claim to be.
## On a Seafarers map
Cities & Knights can be played on a Seafarers map; the rules for how the two meet are in
[RULES-catan-seafarers.md](RULES-catan-seafarers.md#with-cities--knights). The code reads the board from
`state.topo` throughout, so nothing here assumes the 19-hex island.
+145 -10
View File
@@ -1,20 +1,47 @@
# Katan: Seafarers # Katan: Seafarers
The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. It is the base Seafarers is an expansion switched on at setup (`CatanOptions.expansions`; the old single `ruleSet` in saves is still read) and its map is chosen beside it (`CatanOptions.scenario`). It is the base
game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to
settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map), settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map),
`CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea` `CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea`
(null in the other rule sets). It cannot be combined with Cities & Knights. (null in the other rule sets). It can be combined with Cities & Knights; see *With Cities & Knights* below.
Where the published rules leave room, or where this game differs, it says so below. Where the published rules leave room, or where this game differs, it says so below.
## The map ## The maps
One map, *Heading for New Shores*: a large **home island** (21 hexes, one desert) and three small Each map is a fixed arrangement of land and sea; the **resources on the plain hexes, the number tokens
islands (3, 3 and 2 hexes) across the water, inside a ring of sea. Which land hexes are gold, which
are the desert and where the sea lies is fixed; the **resources on the other hexes, the number tokens
and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring
hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14). hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14).
Pick the map at setup; each scenario names where the starting settlements may go (`SeaStart`).
* **Heading for New Shores** (default). A large home island (21 hexes, one desert) and three small islands (3, 3
and 2 hexes) across the water, with gold fields on two of the small ones. Start on the home island.
* **Four Islands.** Four equal islands (six hexes each) and two one-hex gold islets between them. Each player
may start on **any** of the four main islands, so a table can spread out or crowd one; the islets are the
prize for the first ship to arrive.
* **Through the Desert.** One 25-hex island cut across by a broad desert (seven hexes that pay nothing and
hold the robber), with three islets and gold to sail to. Everyone starts on the island. The desert is
land, so a road can cross it, but nothing beside it pays, which is what makes the far side a race.
* **The Fog Islands.** A 13-hex home island (one desert), a two-hex islet and a 14-hex bank of fog.
See *Fog* below.
* **Cloth for Catan.** A 17-hex main island, an eastern isle, two islets, and four **villages** on the coast.
See *Cloth* below.
* **The Forgotten Tribe.** A 17-hex home island and three isles carrying four tribe chips. See *The Forgotten
Tribe* below.
* **Pirate Islands.** An 18-hex home island and two isles (five and four hexes) each held by a pirate
fortress. See *Pirate Islands* below.
* **Wonders of Catan.** A 13-hex island with a desert, a 12-hex island, and gold islets. See *Wonders* below.
* **The Treasure Islands** *(Catan Universe extra)*. A 15-hex home island and four fog isles, eight hexes of fog in
all. See *Catan Universe extras* below.
* **The Oases** *(Catan Universe extra)*. One 37-hex island that is mostly desert, with three oases hidden in it.
* **The Great Canal** *(Catan Universe extra)*. A 25-hex island split by a row of five canal tiles, and gold islets.
A *new island* for the bonus is any island that is not a starting island for that map.
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea, The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching
@@ -56,16 +83,124 @@ through an opponent's town and changes between road and ship only at a town of y
Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game. Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game.
## Fog
Fog hexes (`Terrain.FOG`) are land that is face down. Under them is a shuffled pile of tiles
(`SeaState.fogDeck`): resources, with one gold field in every five, each with its number token. The
pile is hidden from every seat but the host's, like the development deck.
* **Finding.** When a road or ship is built (or a ship sails) and an end of it touches a fog hex, the hex is
turned over, taking the next tile and its number. Several hexes can be found by one piece.
* **The find pays.** The finder takes one card of what lies there, supply permitting; a gold field lets the
finder choose any resource (the gold choice, resuming Road Building if that is what was under way).
* **Until then** nothing produces there, nobody may start beside it, and the robber cannot go on it.
* **Differences from the published scenario.** The fog here is always land: the physical tiles can turn out to
be sea, but the board's land and sea are fixed in the geometry, so a found hex never turns to water. Fog is
also on one bank rather than between several islands, and the number tokens are dealt with the tiles, so two
red numbers can end up side by side once found.
## Cloth
The published scenario puts cloth villages between the islands and pays cloth when a village's number is rolled.
This implementation is the author's reading of that, not a transcription of the printed rules:
* A **village** is a land hex with a number token and a stock of cloth (6). It produces no cards and nothing may
be built on it, but its corners can hold settlements like any other hex's.
* When its number rolls, the village pays **one cloth token** to each settlement or city beside it (a city is
worth no more than a settlement here), until the stock is gone. If the stock is short, the seats nearest the
roller, in turn order, take what is left. The robber on a village stops it; the pirate does not.
* **Two cloth are a victory point** (an odd token is worth nothing alone). Tokens are scored, not spent, and not
tradeable.
* Works with Cities & Knights: the village pays after the dice, before the aqueduct and gold.
## The Forgotten Tribe
Also the author's reading. A chip lies face up on four hexes of the small isles (the hexes are marked on the
board). The chips are dealt at the start of the game, one of each kind in turn:
* **The first settlement** built on a crossing beside a chip hex takes that chip; it is gone for everyone else.
(The setup settlements are never beside one, since the home island is the only start.)
* A **point** chip is one victory point. A **harbour** chip lets that seat trade 3:1 at the bank, wherever it is.
A **gold** chip pays two resources of the settler's choice, taken at once like gold.
* The chips come on top of the island bonus for being first to a new island.
## Pirate Islands
The author's reading of the scenario; the printed one uses knights and a roaming pirate fleet, which this does not.
* A **fortress** is a land hex with no number and a strength of 3. Nobody may build a settlement on any crossing of
an island that still has a standing fortress. Roads and ships may go there.
* A seat with a **ship beside** a fortress may **raid** it in its turn for 1 ore, 1 wool and 1 grain, as many times as
it can pay. Each raid takes one point of strength.
* The raid that takes the last point **takes the fortress**: the hex becomes desert (no number, robber may sit there),
the island is open to settle for everyone, and the taker gets **2 victory points**. The robber never sits on a
standing fortress.
* The island still counts as a new island for the first-landing bonus.
## Wonders
Also the author's reading. Every seat may build one wonder in four stages, once it has a **city** anywhere on the
board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain; 2: 1 brick, 2 ore, 1 wool; 3: 3 ore,
1 grain, 1 lumber; 4: 3 ore, 2 grain, 1 brick). Each stage built is **one victory point**, and the seat that
builds the fourth stage **wins at once**, whatever the point target. Wonders are not shared: each seat has its own.
## Catan Universe extras (inferred)
These three exist in the Catan Universe app but not in the physical set. Only their names, menu texts and log
lines could be read (`docs/RE-catan-universe.md`); the rule values are compiled code that was not recovered. **Every
rule below is inferred from those names and texts**, not copied.
* **Treasure Islands.** The app's text: "Treasures await the Catanians on neighboring, fog-covered islands.
Discoveries are rewarded!" Its chest rewards (log texts and action names) are bricks, wool and grain; a resource of
choice; a development card; two roads. Here: up to five chests are dealt face down to random fog hexes at the
start. A seat that uncovers the hex (the fog rules) opens the chest at once and takes: *a brick, a wool and a grain*;
*one resource of its choice* (a gold pick); *a development card* (a resource of choice if the deck is empty or it is
Cities & Knights, which has no deck); or *two lumber and two brick* standing in for two free roads (the free-roads
phase would have to nest inside a ship move). The app's Cities & Knights version gives a progress card; that is
not modelled. The app's fixed treasure bonus (`SEAFARER_TREASURE_BONUS`) is not modelled either.
* **The Oases.** The app's text: an enormous desert with rumoured oases and treasures, base-game rules. Here: the
oases are fog hexes inside the desert, uncovered by a road reaching them, and hold the same chests as above.
There is no ship rule on the map itself.
* **The Great Canal.** The app's text, for Cities & Knights plus Seafarers: build a canal; first to 21 (3 players) or
18 (4 players) wins; the canal's parts earn victory points; when a tile is dug out, grain grows there. Its knights dig
in the app; here digging is cheaper to implement and play without Cities & Knights: a canal tile is a desert hex with
its number token hidden. A seat with a settlement or city on a corner of the tile digs one **unit** for 1 brick and
1 ore; **three units** finish the tile, which becomes **fields** with the hidden number. Every **two units** a seat
has dug are a **victory point**. The app's gold river, harbour changes and target scores are not modelled.
## With Cities & Knights
Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map
(`CatanState.ck` and `CatanState.sea` both set; the target menu is 10, 12, 14 or 16, default 14).
Where the two meet, this game decides as follows:
* **Ships** cost 1 lumber and 1 wool as before and are built and sailed in a turn like a road, including
with the Road Building progress card (roads and ships, as in Seafarers).
* **Knights** stand on land crossings at the end of one of your roads *or ships*, and walk along that same
network. A crossing that touches only sea is never a knight post.
* **The pirate** sleeps with the robber: only after the first barbarian attack does a seven let the player
move the robber (to land) *or* the pirate (to sea). The pirate robs one good, resource or commodity, from a
seat with a ship beside it. The bishop moves the robber only.
* **Gold** pays after the roll and before the aqueduct and any other chore: a settlement beside it chooses one
resource, a city two. (A city on gold takes two resources, not a resource and a commodity.)
* **Scoring.** First-landing bonus points are added to a seat's points like any other; the barbarians count
cities wherever they stand, island or not.
* **Merchant** goes only on a land hex; **Fog** works as in Seafarers (a find pays a resource card).
## Not included ## Not included
The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**: The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**:
fog islands, treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations, the Explorers & Pirates combination,
the scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships.
is a new text map and a new enum entry. Drought and the app's other scenarios (Six Islands, Oceania, Great Catan, Enchanted Land, the Inka and Big Game sets)
are not built.
A map-only scenario is a new text map and a new `SeaScenario` entry.
## How the AI plays it ## How the AI plays it
It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its
open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that
bonus is in play. It chooses the resource it values most from gold, always moves the robber (never the pirate) and does bonus is in play. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is
worth more (`CatanAi.scenarioLure`), a ship that heads for a standing fortress is worth building, and it raids and builds
wonder stages whenever it can pay (`CatanAi.FORT_LURE`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
not sail ships it has already launched. See `CatanAi.shipValue`. not sail ships it has already launched. See `CatanAi.shipValue`.
+20
View File
@@ -23,6 +23,26 @@ A table plays one of two rule sets, chosen on the setup screen
building, robber and trade rules and changes or replaces the rest; its state lives building, robber and trade rules and changes or replaces the rest; its state lives
behind `CatanState.ck`, which is `null` in the base game. behind `CatanState.ck`, which is `null` in the base game.
## Base-island scenarios
Without Seafarers the setup screen offers an **Island** choice (`CatanOptions.baseScenario`; it works with Cities &
Knights too). Both extras are the author's reading, not transcriptions of the printed scenarios:
- **Random map** (default) — the usual shuffled island.
- **First Island** — the same island every game: one fixed shuffle (`CatanBoard.FIRST_ISLAND_SEED`) of the standard
tiles, numbers and harbours, so a first game or a rematch has no surprise set-up. It is not the printed beginner
layout. The development deck and the seating still follow the table's own seed.
- **The Harbormaster** — the Catan Universe menu text is "Because trade becomes more and more important, harbours
have an ever increasing significance." Its card: 2 victory points to the first player with at least three harbour
points; another player with more takes it at once. What a harbour point is was not in the app's texts, so it is
inferred: a settlement on a harbour counts **1**, a city **2**, for each harbour it touches. The title is checked after
every settlement, city and set-up placement; **equal points do not take it**, only strictly more (like Longest
Road); it is **+2 victory points** to the holder (`CatanState.harbormaster`). The AI values a harbour spot a
little more here (`CatanAi.HARBOR_LURE`).
Seafarers games choose a map instead (see [RULES-catan-seafarers.md](RULES-catan-seafarers.md)); the Harbormaster does not apply there.
The app's other base scenario, Ore for Wool, is not built: its rules are not in the texts that could be read.
## Table ## Table
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`). - **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).