From 42334c3bf82c2c5ac0b2a408d7af43a508c0be7e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 18:14:00 +0000 Subject: [PATCH] Katan: base-island scenarios (First Island, The Harbormaster) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../prolibertate/games/game/catan/CatanAi.kt | 5 + .../games/game/catan/CatanBoard.kt | 3 + .../prolibertate/games/game/catan/CatanCk.kt | 1 + .../games/game/catan/CatanModel.kt | 23 +++- .../games/game/catan/CatanRules.kt | 58 ++++++++- .../prolibertate/games/ui/GameSetupScreen.kt | 10 ++ .../games/ui/game/catan/CatanPanels.kt | 2 + .../games/game/catan/CatanBaseScenarioTest.kt | 115 ++++++++++++++++++ docs/RULES-catan.md | 20 +++ 9 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanBaseScenarioTest.kt 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 30f1869..e96472d 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 @@ -599,6 +599,10 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi 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]) { @@ -709,5 +713,6 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi = emptySet(), val scenario: SeaScenario = SeaScenario.NEW_SHORES, + val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP, ) { init { require(playerCount in 3..4) { "The island seats three or four players" } @@ -214,6 +230,7 @@ private class CatanOptionsWire( val pointsToWin: Int = 10, val expansions: Set = emptySet(), val scenario: SeaScenario = SeaScenario.NEW_SHORES, + val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP, ) /** Reads the legacy single `ruleSet` field as an expansion set. */ @@ -222,7 +239,7 @@ object CatanOptionsSerializer : KSerializer { override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue( CatanOptionsWire.serializer(), - CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario), + CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario, value.baseScenario), ) override fun deserialize(decoder: Decoder): CatanOptions { @@ -234,7 +251,7 @@ object CatanOptionsSerializer : KSerializer { 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) + return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario, wire.baseScenario) } } @@ -306,6 +323,8 @@ data class CatanState( val log: List, /** Everything Cities & Knights adds; null in the base game. */ 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. */ val sea: SeaState? = null, ) { 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 0e7ef6d..7e0674d 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 @@ -59,7 +59,9 @@ object CatanRules : GameRules { val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) } - ?: CatanBoard.generate(config.seed) + ?: 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 // produces both a fixed island and a fixed deck. val ck = options.citiesAndKnights @@ -532,7 +534,7 @@ object CatanRules : GameRules { } val newIndex = state.setupIndex + 1 - s = s.copy(setupIndex = newIndex) + s = refreshHarbormaster(s.copy(setupIndex = newIndex)) return when { newIndex >= 2 * n -> s.copy( phase = CatanPhase.AWAIT_ROLL, @@ -1023,16 +1025,64 @@ object CatanRules : GameRules { } state.longestRoadOwner?.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) 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 = publicVictoryPoints(state)[seat] + state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT } - internal fun checkWin(state: CatanState): CatanState { - if (state.phase == CatanPhase.GAME_OVER) return state + internal fun checkWin(settled: CatanState): CatanState { + if (settled.phase == CatanPhase.GAME_OVER) return settled + val state = refreshHarbormaster(settled) val seat = state.turn val points = victoryPoints(state, seat) if (points < state.options.pointsToWin) return state 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 ccca392..5c61086 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -61,6 +61,7 @@ import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.catan.CatanExpansion import org.prolibertate.games.game.catan.SeaScenario +import org.prolibertate.games.game.catan.BaseScenario import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuOptions @@ -442,6 +443,15 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) - 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( label = "Players", diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index 9d01735..6560fe4 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -256,6 +256,7 @@ fun CatanPlayerCard( if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis") } if (ck.merchant?.seat == seat) add("Merchant") + if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster") addAll(seaAwards(state, seat, compact)) } CardLine( @@ -281,6 +282,7 @@ fun CatanPlayerCard( val awards = buildList { if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road") if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army") + 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. diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanBaseScenarioTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanBaseScenarioTest.kt new file mode 100644 index 0000000..bb9be3a --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanBaseScenarioTest.kt @@ -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 = 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(old).baseScenario) + val options = CatanOptions(3, 10, baseScenario = BaseScenario.HARBORMASTER) + assertEquals(options, json.decodeFromString(json.encodeToString(options))) + } + + /** Vertices of the harbours, one per harbour, in board order. */ + private fun CatanState.harbourVertices(): List = 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) + } +} diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index 3e8df72..3403f3b 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -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 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 - **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`). -- 2.47.3