Compare commits

...
Author SHA1 Message Date
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
10 changed files with 473 additions and 23 deletions
@@ -644,9 +644,11 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
val next = mutableListOf<Int>()
for (v in frontier) {
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()))
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)
}
@@ -673,5 +675,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
private companion object {
/** A seven makes a seat holding more than this discard half. */
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
}
}
@@ -54,6 +54,12 @@ enum class Terrain(val produces: Resource?) {
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
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),
}
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
@@ -465,12 +471,28 @@ 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(),
/**
* 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,
)
/** 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
data class GoldOwed(val seat: Int, val count: Int)
@@ -119,6 +119,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
pirate = it.pirate,
islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home,
starts = it.starts,
fogDeck = it.fogDeck,
)
},
)
@@ -207,8 +209,10 @@ 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
// 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]) {
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
moves += SetupPlace(v, e)
@@ -244,7 +248,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val moves = mutableListOf<MoveRobber>()
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) continue
val victims = state.topo.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] }
.map { it.owner }
@@ -660,7 +664,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(pendingFreeRoads = left)
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 {
@@ -679,7 +685,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(pendingFreeRoads = left)
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 {
@@ -692,9 +698,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val s = state.copy(
sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to),
)
return checkWin(refreshLongestRoad(s).let {
it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.")
})
return checkWin(
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 {
@@ -1135,7 +1145,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
cards.groupingBy { it }.eachCount()
/** 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>>,
seat: Int,
delta: Map<Resource, Int>,
@@ -1204,6 +1214,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
state.copy(
// Hidden information stays with the holder and the host.
devDeck = emptyList(),
sea = state.sea?.copy(fogDeck = emptyList()),
devHands = state.devHands.mapIndexed { index, hand ->
if (index == seat) hand else emptyList()
},
@@ -5,11 +5,34 @@ 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.",
),
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.",
),
}
/** 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 +56,47 @@ 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...",
"...##.......",
"............",
)
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
SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP
}
private val topologies = HashMap<SeaScenario, Topology>()
@@ -72,8 +134,15 @@ 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),
/** The tiles under the fog, in the order they will be found. */
val fogDeck: List<FogTile> = emptyList(),
)
/** 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,
)
@@ -96,10 +165,12 @@ object SeaBoards {
'$' -> Terrain.GOLD
'D' -> Terrain.DESERT
'#' -> pool[plain.indexOf(h)]
'F' -> Terrain.FOG
else -> Terrain.SEA
}
}
// 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 }
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
val tokens = TOKENS.take(numbered.size).shuffled(random)
@@ -119,6 +190,14 @@ object SeaBoards {
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 = (
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
).shuffled(random)
@@ -129,7 +208,10 @@ object SeaBoards {
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
?: 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 = fogHexes.map { topo.islandOf[it] }.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.
val sea = terrains.indices.filter { terrains[it] == Terrain.SEA }
val pirate = sea.maxByOrNull { h ->
@@ -139,6 +221,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, fogDeck)
}
}
@@ -135,12 +135,60 @@ internal object SeaRules {
val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) }
val queue = if (supply > 0) sea.gold else emptyList()
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 {
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)
}
}
if (owed.isEmpty()) return s
val merged = GoldOwed(seat, owed.sumOf { it.count })
val sea = s.sea!!
return settleGold(s.copy(sea = sea.copy(gold = sea.gold + merged, afterGold = s.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold)))
}
// -----------------------------------------------------------------------
// Islands and scoring
// -----------------------------------------------------------------------
@@ -153,7 +201,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),
@@ -156,6 +156,16 @@ private fun DrawScope.drawHexes(
val number = state.numbers[hex]
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,
)
}
}
}
@@ -124,6 +124,8 @@ private val LightStyle = CatanStyle(
// Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow.
Terrain.SEA to Color(0xFF3A7CA5),
Terrain.GOLD to Color(0xFFF2B01E),
// Land not yet found: a pale grey bank of fog with a question mark.
Terrain.FOG to Color(0xFFB9C2C9),
),
hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9),
@@ -74,6 +74,7 @@ object CatanText {
Terrain.DESERT -> "Desert"
Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field"
Terrain.FOG -> "Fog"
}
fun devCard(type: DevCardType): String = when (type) {
@@ -0,0 +1,233 @@
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))
}
@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
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
}
assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS)
// 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
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
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
}
assertTrue("no engine ever uncovered a fog hex", fogFound > 0)
}
}
+36 -7
View File
@@ -8,13 +8,26 @@ 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.
* **The Fog Islands.** A 13-hex home island (one desert), a two-hex islet and a 14-hex bank of fog.
See *Fog* below.
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
@@ -56,12 +69,28 @@ 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.
## 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.
## Not included
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.
treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations,
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.
## How the AI plays it