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 3d0a95c..3d2968c 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 @@ -646,7 +646,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi= 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()) best = maxOf(best, value) } 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 023275f..88732c7 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 @@ -465,12 +465,17 @@ data class SeaState( val shipMoved: Boolean = false, /** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */ val islandBonus: List, - /** 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, + /** The islands the starting settlements may go on. Empty in a save from before there were several: just [home]. */ + val starts: List = emptyList(), /** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */ val gold: List = emptyList(), ) +/** 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 data class GoldOwed(val seat: Int, val count: Int) 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 018c966..6e296c1 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 @@ -119,6 +119,7 @@ object CatanRules : GameRules { pirate = it.pirate, islandBonus = List(topo.islandCount) { NO_PLAYER }, home = it.home, + starts = it.starts, ) }, ) @@ -207,8 +208,8 @@ object CatanRules : GameRules { val moves = mutableListOf() for (v in 0 until state.topo.VERTEX_COUNT) { if (!vertexOpenForSettlement(state, v)) continue - // Seafarers: everyone starts on the home island. - if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue + // Seafarers: the starting settlements go on a starting island. + if (state.sea != null && !state.sea.isStart(state.topo.vertexIsland[v])) continue for (e in state.topo.VERTEX_EDGES[v]) { if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue moves += SetupPlace(v, e) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt index 54f96ee..0ca88e1 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt @@ -5,11 +5,29 @@ 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. */ @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( "Heading for New Shores", "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.", + ), +} + +/** 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 +51,34 @@ object SeaBoards { "..........", ) + private val FOUR_ISLANDS_MAP = listOf( + "............", + "..###..###..", + "..###..###..", + "............", + "..$.....$...", + "............", + "..###..###..", + "..###..###..", + "............", + ) + + private val THROUGH_THE_DESERT_MAP = listOf( + "............", + ".###DD###...", + ".###DDD###..", + "..###DD###..", + "............", + "..$.....$...", + "............", + "...##...#...", + "............", + ) + private fun rows(scenario: SeaScenario): List = when (scenario) { SeaScenario.NEW_SHORES -> NEW_SHORES_MAP + SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP + SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP } private val topologies = HashMap() @@ -72,8 +116,13 @@ object SeaBoards { val pirate: Int, val ports: List, val home: Int, + /** The islands the starting settlements may go on; always includes [home]. */ + val starts: List = listOf(home), ) + /** An island of this many land hexes or more can be started on. */ + private const val MAIN_ISLAND_HEXES = 4 + private val RESOURCE_TILES = listOf( Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS, ) @@ -139,6 +188,13 @@ object SeaBoards { } ?: 0 } ?: 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) } + } + + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts) } } 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 1f59aac..01da44e 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 @@ -153,7 +153,7 @@ internal object SeaRules { fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState { val sea = state.sea ?: return state 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 } return state.copy( sea = sea.copy(islandBonus = bonus), diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt new file mode 100644 index 0000000..b8ad82d --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -0,0 +1,125 @@ +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 { + 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)) + } + + @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) + assertEquals("$scenario hex $h", land && s.terrains[h] != Terrain.DESERT, s.numbers[h] != 0) + } + assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS) + 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() + 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() + assertTrue(moves.all { old.topo.vertexIsland[it.vertex] == old.sea!!.home }) + } + + @Test + fun `engines finish a game on every map at three and four players`() { + 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) + assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state))) + } + } + } +} diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index e4f5941..494a11b 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -8,13 +8,23 @@ settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` 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 -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 +Each map is a fixed arrangement of land and sea; the **resources on the plain hexes, the number tokens 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). +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. + +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, a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching @@ -60,8 +70,8 @@ Everything else (development cards, trading, harbours, Largest Army, the discard 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 scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map -is a new text map and a new enum entry. +the scenarios that need new rules (fog, cloth, wonders, the forgotten tribe, pirate islands) and trade ships. +A map-only scenario is a new text map and a new `SeaScenario` entry. ## How the AI plays it