Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87cf8decaa | ||
|
|
dcf553a8b1 | ||
|
|
5e9bb2feaf | ||
|
|
cc33dffd9a |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user