From 7f3c2fe0e9a976f22585a9321682379ca04e16f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 20:35:19 +0000 Subject: [PATCH] Katan: Cities & Knights on Seafarers maps 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 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- CLAUDE.md | 4 +- .../prolibertate/games/game/catan/CatanAi.kt | 2 +- .../prolibertate/games/game/catan/CatanCk.kt | 105 ++++++++---- .../games/game/catan/CatanCkAi.kt | 23 ++- .../games/game/catan/CatanModel.kt | 2 - .../games/game/catan/CatanRules.kt | 3 +- .../games/game/catan/CatanSeaRules.kt | 2 +- .../prolibertate/games/ui/GameSetupScreen.kt | 8 +- .../games/ui/game/catan/CatanScreen.kt | 2 +- .../games/ui/game/catan/CatanText.kt | 13 +- .../games/game/catan/CatanSeaCkTest.kt | 154 ++++++++++++++++++ .../games/ui/game/catan/CatanCkTextTest.kt | 9 +- docs/RULES-catan-ck.md | 7 + docs/RULES-catan-seafarers.md | 23 ++- 14 files changed, 298 insertions(+), 59 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanSeaCkTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index c28dd54..095f66e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,9 +56,9 @@ The island game is catalogue id `catan`, shown as **Katan**. It has two expansio (`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind `CatanState.ck`, which is null in the base game) and Seafarers (`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind -`CatanState.sea`). The base and Seafarers rules read the board from `state.topo`, not from +`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 -(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 is in `docs/RE-catan-universe.md`. The screen is layered; see `docs/UI-catan.md` before changing it. diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index 6ac67f6..5de0e0a 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt @@ -189,7 +189,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi, profile: AiProfile): CatanMove { + internal fun chooseGold(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove { val picks = legal.filterIsInstance() if (picks.isEmpty()) return legal.first() return when (profile.level) { diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt index d156d39..b53fb5b 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt @@ -171,18 +171,22 @@ object CatanCk { /** 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 = - (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) } private fun ownsCity(state: CatanState, seat: Int): Boolean = 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 = - (0 until CatanBoard.VERTEX_COUNT).filter { v -> - state.buildings[v] == null && knightAt(state, v) == null && - CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } + (0 until state.topo.VERTEX_COUNT).filter { v -> + state.topo.vertexLand[v] && state.buildings[v] == null && knightAt(state, v) == null && + state.topo.VERTEX_EDGES[v].any { holds(state, seat, it) } } /** @@ -196,9 +200,9 @@ object CatanCk { val queue = ArrayDeque().apply { add(from) } while (queue.isNotEmpty()) { val v = queue.removeFirst() - for (e in CatanBoard.VERTEX_EDGES[v]) { - if (state.roads[e] != seat) continue - val w = CatanBoard.otherEnd(e, v) + for (e in state.topo.VERTEX_EDGES[v]) { + if (!holds(state, seat, e)) continue + val w = state.topo.otherEnd(e, v) if (!seen.add(w)) continue val building = state.buildings[w] val knight = knightAt(state, w) @@ -215,14 +219,14 @@ object CatanCk { private fun destinations(state: CatanState, k: Knight): List = walk(state, k.owner, k.vertex).filter { 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) }.sorted() /** Empty crossings a driven-off [knight] of its owner could still reach. */ private fun refuges(state: CatanState, knight: Knight): List = 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() private fun promotable(state: CatanState, k: Knight): Boolean { @@ -251,6 +255,7 @@ object CatanCk { points[seat] += ck.defenderVp[seat] + ck.progressVp[seat] } ck.merchant?.let { points[it.seat] += 1 } + if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat) return points.toList() } @@ -266,15 +271,18 @@ object CatanCk { 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.FREE_ROADS -> - CatanRules.roadMoves(state, seat) + listOf(EndFreeRoads) + CatanRules.roadMoves(state, seat) + + (if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) + + listOf(EndFreeRoads) - // Seafarers is never combined with Cities & Knights. - CatanPhase.GOLD -> emptyList() + CatanPhase.GOLD -> SeaRules.goldMoves(state) CatanPhase.TRADE_PENDING -> { val offer = state.ck!!.goodsOffer @@ -327,7 +335,7 @@ object CatanCk { if (!k.active && affordable(state, seat, ACTIVATE_COST)) moves += ActivateKnight(k.vertex) if (canAct(state, k)) { 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) } } @@ -340,6 +348,10 @@ object CatanCk { val moves = mutableListOf() 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) + } if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) { moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) } } @@ -380,7 +392,7 @@ object CatanCk { /** Own cities with no wall, while the seat has a wall left to build. */ private fun wallSites(state: CatanState, seat: Int): List { 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 } } @@ -423,20 +435,37 @@ object CatanCk { private fun robberMoves(state: CatanState, seat: Int): List { val bishop = state.ck!!.bishop val moves = mutableListOf() - for (h in 0 until CatanBoard.HEX_COUNT) { - if (h == state.robber) continue + for (h in 0 until state.topo.HEX_COUNT) { + if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG) continue val victims = victimsAt(state, seat, h) when { - bishop -> moves += MoveRobber(CatanBoard.HEXES[h]) - victims.isEmpty() -> moves += MoveRobber(CatanBoard.HEXES[h]) - else -> victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) } + bishop -> moves += MoveRobber(state.topo.HEXES[h]) + victims.isEmpty() -> moves += MoveRobber(state.topo.HEXES[h]) + else -> victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) } } } 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 = - CatanBoard.HEX_CORNERS[hex] + state.topo.HEX_CORNERS[hex] .mapNotNull { state.buildings[it] } .map { it.owner } .filter { it != seat } @@ -445,7 +474,7 @@ object CatanCk { fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState { val ck = state.ck!! - val hex = CatanBoard.hexIndex[move.hex] + val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}") var s = state.copy(robber = hex, rngStep = state.rngStep + 1) val rng = Random(state.seed * 1_000_003L + state.rngStep) @@ -495,7 +524,7 @@ object CatanCk { ProgressType.ENGINEER -> wallSites(state, seat).map { PlayProgress(id, vertex = it) } 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.filter { b -> b > a && state.numbers[b] != state.numbers[a] } .map { b -> PlayProgress(id, hexA = a, hexB = b) } @@ -530,7 +559,7 @@ object CatanCk { ProgressType.INTRIGUE -> 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) } ProgressType.SABOTEUR -> listOf(PlayProgress(id)) @@ -559,9 +588,9 @@ object CatanCk { } ProgressType.MERCHANT -> - (0 until CatanBoard.HEX_COUNT).filter { h -> - state.terrains[h] != Terrain.DESERT && - CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } + (0 until state.topo.HEX_COUNT).filter { h -> + state.terrains[h] != Terrain.DESERT && state.topo.land[h] && state.terrains[h] != Terrain.FOG && + state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } }.map { PlayProgress(id, hexA = it) } ProgressType.MERCHANT_FLEET -> Good.entries.map { PlayProgress(id, good = it) } @@ -596,19 +625,19 @@ object CatanCk { } private fun hexesNextTo(state: CatanState, seat: Int, terrain: Terrain): List = - (0 until CatanBoard.HEX_COUNT).filter { h -> + (0 until state.topo.HEX_COUNT).filter { h -> 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. */ private fun openRoads(state: CatanState): List = - (0 until CatanBoard.EDGE_COUNT).filter { e -> + (0 until state.topo.EDGE_COUNT).filter { e -> 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 -> 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 +1529,12 @@ object CatanCk { private fun production(state: CatanState, number: Int): CatanState { val gains = List(state.playerCount) { mutableMapOf() } - 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 val terrain = state.terrains[h] val resource = terrain.produces ?: continue 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 mine = gains[building.owner] fun add(good: Good, n: Int) { mine[good] = (mine[good] ?: 0) + n } @@ -1546,7 +1575,11 @@ object CatanCk { improvement(s, seat, Track.SCIENCE) >= IMPROVEMENT_POWER_LEVEL && gains[seat].values.sum() == 0 } 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))) } // ----------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt index e81869f..30bff34 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt @@ -53,7 +53,9 @@ internal class CatanCkAi( 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}") } @@ -131,9 +133,9 @@ internal class CatanCkAi( val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] } val victimWeight = 0.3 + 0.1 * profile.style.boldness fun score(move: MoveRobber): Double { - val hex = CatanBoard.hexIndex.getValue(move.hex) + val hex = state.topo.hexIndex.getValue(move.hex) var score = 0.0 - for (v in CatanBoard.HEX_CORNERS[hex]) { + for (v in state.topo.HEX_CORNERS[hex]) { val building = state.buildings[v] ?: continue val worth = if (building.type == BuildingType.CITY) 2 else 1 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. if (ck.attacked) { val onMine = legal.filterIsInstance().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 } @@ -212,6 +214,12 @@ internal class CatanCkAi( settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it } } + // Seafarers: a ship toward land nobody has settled is worth more than a card. + legal.filterIsInstance() + .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. legal.filterIsInstance() .maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) } @@ -226,6 +234,11 @@ internal class CatanCkAi( if (CatanCk.goodsCount(state, seat) >= (if (boldness < 0) 5 else 7)) return it } + legal.filterIsInstance() + .filter { ai.shipValue(state, seat, it.edge) > 0.15 } + .maxByOrNull { ai.shipValue(state, seat, it.edge) } + ?.let { return it } + legal.filterIsInstance() .filter { ai.roadValue(state, it) > 0.1 - 0.04 * boldness } .maxByOrNull { ai.roadValue(state, it) } @@ -360,7 +373,7 @@ internal class CatanCkAi( /** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */ private fun production(state: CatanState, vertex: Int): Double = - CatanBoard.VERTEX_HEXES[vertex].sumOf { h -> + state.topo.VERTEX_HEXES[vertex].sumOf { h -> val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0 want * CatanBoard.numberOdds(state.numbers[h]) / 36.0 } diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt index 954d99d..4b54083 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt @@ -192,8 +192,6 @@ data class CatanOptions( init { require(playerCount in 3..4) { "The island seats three or four players" } require(pointsToWin >= 2) { "pointsToWin must be at least 2" } - // Lifted once Cities & Knights runs on a Seafarers map. - require(expansions.size <= 1) { "Combining expansions is not supported yet" } } val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt index ecffed2..5e0138d 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt @@ -601,7 +601,7 @@ object CatanRules : GameRules { } /** What each seat is owed for gold fields showing [number], in seat order from the roller. */ - private fun goldOwed(state: CatanState, number: Int): List { + internal fun goldOwed(state: CatanState, number: Int): List { val topo = state.topo val owed = IntArray(state.playerCount) for (h in 0 until topo.HEX_COUNT) { @@ -708,6 +708,7 @@ object CatanRules : GameRules { } 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}") var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1) var stolen = "" diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt index 536c144..0fda06f 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt @@ -83,7 +83,7 @@ internal object SeaRules { .map { sea.ships[it] } .filter { it != NO_PLAYER && it != thief } .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 { diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 15a8dbb..ccca392 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -376,6 +376,8 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId) /** The targets the setup screen offers for a Katan game with [expansions] on. */ internal fun catanPointsChoices(expansions: Set): List = when { + // Both: Cities & Knights' long game plus the new-island points. + expansions.size > 1 -> listOf(10, 12, 14, 16) // Seafarers adds two points for each new island, so the target is higher. CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14) // Cities & Knights is played to thirteen, which is also why it runs longer. @@ -386,6 +388,7 @@ internal fun catanPointsChoices(expansions: Set): List = wh /** The published target for a game with [expansions] on. */ internal fun catanDefaultPoints(expansions: Set): Int = when { + expansions.size > 1 -> 14 CatanExpansion.SEAFARERS in expansions -> 12 CatanExpansion.CITIES_AND_KNIGHTS in expansions -> 13 else -> 10 @@ -393,11 +396,10 @@ internal fun catanDefaultPoints(expansions: Set): Int = when { /** * [options] with [expansion] switched on or off. The target moves to the new combination's own - * default unless the one already chosen is on its menu too. Expansions cannot be combined yet, - * so switching one on switches the other off. + * default unless the one already chosen is on its menu too. */ internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions { - val expansions = if (on) setOf(expansion) else options.expansions - expansion + val expansions = if (on) options.expansions + expansion else options.expansions - expansion return options.copy( expansions = expansions, pointsToWin = options.pointsToWin.takeIf { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index b421fec..b5659e9 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -198,7 +198,7 @@ fun CatanScreen( val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val actions = CatanActions( buildKinds = when { - current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP } + current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP || current.isSea } current.isSea -> BuildKind.SEAFARERS else -> BuildKind.BASE }, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt index 55d75b3..a90a657 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -194,7 +194,7 @@ object CatanText { /** Seafarers: a ship has been picked to sail, and its destination is awaited. */ movingShip: Boolean = false, ): String { - if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed) + if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed, movingShip) val me = seats.me val onClock = CatanRules.currentSeat(state) val mine = onClock == me @@ -401,6 +401,7 @@ object CatanText { building: BuildKind?, movingKnight: Boolean, armed: ProgressType?, + movingShip: Boolean, ): String { val me = seats.me val onClock = CatanRules.currentSeat(state) @@ -409,7 +410,12 @@ object CatanText { val ck = state.ck!! 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 -> { val winner = state.winner @@ -435,6 +441,7 @@ object CatanText { CatanPhase.ROBBER -> when { !mine -> "$turn is moving the robber…" 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." } @@ -442,6 +449,8 @@ object CatanText { !mine -> "$turn is playing…" armed != null -> armedPrompt(armed) 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.ROAD -> "Tap an edge to build a road." building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaCkTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaCkTest.kt new file mode 100644 index 0000000..3525ce2 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaCkTest.kt @@ -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().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().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() + .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() + 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) + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt index 3ed5400..bdfe691 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt @@ -72,8 +72,11 @@ class CatanCkTextTest { assertEquals(8, short.pointsToWin) assertEquals(3, ck.playerCount) - // Expansions cannot be combined yet: switching one on switches the other off. - val sea = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true) - assertEquals(setOf(CatanExpansion.SEAFARERS), sea.expansions) + // 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) } } diff --git a/docs/RULES-catan-ck.md b/docs/RULES-catan-ck.md index f34fc8c..0f2fac9 100644 --- a/docs/RULES-catan-ck.md +++ b/docs/RULES-catan-ck.md @@ -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. - The Commercial Harbor offers a single resource type (see above). - 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. + diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index 1feaa98..d500da7 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -4,7 +4,7 @@ Seafarers is an expansion switched on at setup (`CatanOptions.expansions`; the o 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), `CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea` -(null in the other rule sets). It cannot yet be combined with Cities & Knights; the setup screen switches one off when the other is turned on. +(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. @@ -85,10 +85,29 @@ pile is hidden from every seat but the host's, like the development deck. 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. +## 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 The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**: -treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations, +treasure islands, the oasis, the great canal, the Explorers & Pirates combination, the scenarios that need new rules (cloth, wonders, the forgotten tribe, pirate islands) and trade ships. A map-only scenario is a new text map and a new `SeaScenario` entry. -- 2.47.3