Compare commits

...
2 Commits
Author SHA1 Message Date
devadmin f8750684e6 Merge pull request 'Seafarers: The Fog Islands (phase 3)' (#35) from feature/catan-fog-islands into dev
CI / Build and check (push) Successful in 11m8s
CI / Deploy to Cloudflare Pages (push) Successful in 34s
2026-11-11 13:51:37 -06:00
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
10 changed files with 266 additions and 13 deletions
@@ -644,6 +644,8 @@ 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 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER
@@ -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]. */
@@ -471,8 +477,19 @@ data class SeaState(
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
@@ -120,6 +120,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home,
starts = it.starts,
fogDeck = it.fogDeck,
)
},
)
@@ -210,6 +211,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
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<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 }
@@ -661,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 {
@@ -680,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 {
@@ -693,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 {
@@ -1136,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>,
@@ -1205,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()
},
@@ -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<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>()
@@ -118,6 +136,8 @@ object SeaBoards {
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. */
@@ -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)
}
}
@@ -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
// -----------------------------------------------------------------------
@@ -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) {
@@ -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<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)
@@ -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)
}
}
+21 -2
View File
@@ -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