Seafarers: The Fog Islands (phase 3) #35
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user