From 87cf8decaa08252260f6a835ab295ed4fcd1cd92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:40:18 +0000 Subject: [PATCH] Seafarers: The Fog Islands scenario 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 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../prolibertate/games/game/catan/CatanAi.kt | 5 + .../games/game/catan/CatanModel.kt | 17 +++ .../games/game/catan/CatanRules.kt | 24 ++-- .../games/game/catan/CatanSeaBoard.kt | 37 +++++- .../games/game/catan/CatanSeaRules.kt | 50 +++++++- .../games/ui/game/catan/CatanBoardView.kt | 10 ++ .../games/ui/game/catan/CatanStyle.kt | 2 + .../games/ui/game/catan/CatanText.kt | 1 + .../games/game/catan/CatanSeaScenariosTest.kt | 110 +++++++++++++++++- docs/RULES-catan-seafarers.md | 23 +++- 10 files changed, 266 insertions(+), 13 deletions(-) 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 3d2968c..6ac67f6 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 @@ -644,6 +644,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi() 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 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER @@ -673,5 +675,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi = emptyList(), /** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */ val gold: List = 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 = 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 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 6e296c1..ecffed2 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 @@ -120,6 +120,7 @@ object CatanRules : GameRules { islandBonus = List(topo.islandCount) { NO_PLAYER }, home = it.home, starts = it.starts, + fogDeck = it.fogDeck, ) }, ) @@ -210,6 +211,8 @@ object CatanRules : GameRules { if (!vertexOpenForSettlement(state, v)) 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) @@ -245,7 +248,7 @@ object CatanRules : GameRules { private fun robberMoves(state: CatanState, seat: Int): List { val moves = mutableListOf() 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 } @@ -661,7 +664,9 @@ object CatanRules : GameRules { 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 { @@ -680,7 +685,7 @@ object CatanRules : GameRules { 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 { @@ -693,9 +698,13 @@ object CatanRules : GameRules { 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 { @@ -1136,7 +1145,7 @@ object CatanRules : GameRules { 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>, seat: Int, delta: Map, @@ -1205,6 +1214,7 @@ object CatanRules : GameRules { 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() }, 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 0ca88e1..5291e3f 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 @@ -19,6 +19,11 @@ enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart "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. */ @@ -75,10 +80,23 @@ object SeaBoards { "............", ) + /** `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 = 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() @@ -118,6 +136,8 @@ object SeaBoards { val home: Int, /** The islands the starting settlements may go on; always includes [home]. */ val starts: List = listOf(home), + /** The tiles under the fog, in the order they will be found. */ + val fogDeck: List = emptyList(), ) /** An island of this many land hexes or more can be started on. */ @@ -145,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) @@ -168,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) @@ -178,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 -> @@ -195,6 +228,6 @@ object SeaBoards { }.ifEmpty { listOf(home) } } - return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts) + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck) } } 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 01da44e..536c144 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 @@ -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 { + 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() + 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 // ----------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt index 85397b0..672c54c 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -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, + ) + } } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt index 50c1df0..44bc064 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt @@ -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), 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 8e00cda..55d75b3 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 @@ -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) { 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 index b8ad82d..8cf1687 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -42,6 +42,7 @@ class CatanSeaScenariosTest { 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 @@ -54,9 +55,14 @@ class CatanSeaScenariosTest { 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) + 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 } @@ -100,8 +106,108 @@ class CatanSeaScenariosTest { 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, 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() + 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>>().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() + 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() + 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().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) @@ -118,8 +224,10 @@ class CatanSeaScenariosTest { 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) } } diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index 494a11b..1feaa98 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -24,6 +24,9 @@ Pick the map at setup; each scenario names where the starting settlements may go 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, @@ -66,11 +69,27 @@ 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 that need new rules (fog, cloth, wonders, the forgotten tribe, pirate islands) and trade ships. +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