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

This commit was merged in pull request #34.
This commit is contained in:
2026-11-11 11:28:48 -06:00
7 changed files with 210 additions and 13 deletions
@@ -646,7 +646,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
if (!seen.add(v)) continue
if (CatanRules.vertexOpenForSettlement(state, 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())
best = maxOf(best, value)
}
@@ -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<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,
/** 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. */
val gold: List<GoldOwed> = 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)
@@ -119,6 +119,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
pirate = it.pirate,
islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home,
starts = it.starts,
)
},
)
@@ -207,8 +208,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val moves = mutableListOf<SetupPlace>()
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)
@@ -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<String> = 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<SeaScenario, Topology>()
@@ -72,8 +116,13 @@ object SeaBoards {
val pirate: Int,
val ports: List<Port>,
val home: Int,
/** The islands the starting settlements may go on; always includes [home]. */
val starts: List<Int> = 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)
}
}
@@ -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),
@@ -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<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))
}
@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<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 })
}
@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)))
}
}
}
}
+16 -6
View File
@@ -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