Compare commits

...
Author SHA1 Message Date
devadmin 3deb764905 Merge pull request 'Katan: zoom the board in portrait' (#41) from feature/catan-portrait-zoom into dev
CI / Build and check (push) Successful in 11m20s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
CI / Build and check (pull_request) Successful in 11m32s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-12 19:14:59 -06:00
Claude da772bd326 Katan: pinch-zoom and pan in portrait; max zoom 6x
CI / Build and check (pull_request) Successful in 11m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-13 01:03:36 +00:00
devadmin dc73a71993 Merge pull request 'Katan: base-island scenarios (phase 6b)' (#40) from feature/catan-base-scenarios into dev
CI / Build and check (push) Successful in 11m32s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-11-12 12:25:57 -06:00
Claude 42334c3bf8 Katan: base-island scenarios (First Island, The Harbormaster)
CI / Build and check (pull_request) Successful in 11m29s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-12 18:14:00 +00:00
devadmin 421cbd4c70 Merge pull request 'Seafarers: Catan Universe extras (phase 6a)' (#39) from feature/catan-universe-extras into dev
CI / Build and check (push) Successful in 11m2s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-11-12 11:23:46 -06:00
Claude cd220396f9 Seafarers: Treasure Islands, Oases and the Great Canal (inferred rules)
CI / Build and check (pull_request) Successful in 11m3s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-12 17:11:47 +00:00
devadmin af279d3592 Merge pull request 'Seafarers: Pirate Islands and Wonders of Catan (phase 5b)' (#38) from feature/catan-pirates-wonders into dev
CI / Build and check (push) Successful in 11m30s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-11-11 23:33:45 -06:00
21 changed files with 617 additions and 18 deletions
@@ -279,6 +279,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
// A raid on a fortress, or the next stage of a wonder, beats a card.
legal.filterIsInstance<RaidFortress>().firstOrNull()?.let { return it }
legal.filterIsInstance<DigCanal>().firstOrNull()?.let { return it }
legal.filterIsInstance<BuildWonder>().firstOrNull()?.let { return it }
// Seafarers: a ship toward an island nobody has landed on is worth more than a card.
@@ -598,6 +599,10 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
/** What a seat would think of building on [vertex]: production and ports. */
/** What a spot beside a cloth village or an unclaimed tribe chip is worth beyond its cards. */
private fun scenarioLure(state: CatanState, vertex: Int): Double {
// The Harbormaster: a town on a harbour is a point toward a title worth two.
if (CatanRules.harbormasterGame(state) &&
state.topo.VERTEX_EDGES[vertex].any { e -> state.ports.any { it.edge == e } }
) return HARBOR_LURE
val sea = state.sea ?: return 0.0
var lure = 0.0
for (h in state.topo.VERTEX_HEXES[vertex]) {
@@ -605,6 +610,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
if (state.terrains[h] == Terrain.VILLAGE && sea.villageCloth.getOrElse(h) { 0 } > 0) {
lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH
}
// An unfinished canal tile is a place to dig: points now, grain land later.
if (sea.canalNumbers.getOrElse(h) { 0 } != 0 && state.terrains[h] == Terrain.DESERT) lure += CANAL_LURE
if (sea.chips.getOrNull(h).let { it != null && it != TribeChip.NONE }) lure += CHIP_LURE
}
return lure
@@ -705,5 +712,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
const val FORT_LURE = 1.2
const val CLOTH_WORTH = 2.5
const val CHIP_LURE = 0.5
const val CANAL_LURE = 0.6
const val HARBOR_LURE = 0.5
}
}
@@ -429,6 +429,9 @@ object CatanBoard {
* tokens go to four of the six board-corner hexes, which are pairwise
* non-adjacent, and at most one corner can be desert.
*/
/** The seed of the First Island scenario's fixed layout. */
const val FIRST_ISLAND_SEED = 1L
fun generate(seed: Long): Generated {
val random = Random(seed)
val terrains = TERRAIN_TILES.shuffled(random)
@@ -255,6 +255,7 @@ object CatanCk {
points[seat] += ck.defenderVp[seat] + ck.progressVp[seat]
}
ck.merchant?.let { points[it.seat] += 1 }
state.harbormaster?.let { points[it] += CatanRules.HARBORMASTER_POINTS }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList()
}
@@ -215,6 +215,7 @@ internal class CatanCkAi(
}
legal.filterIsInstance<RaidFortress>().firstOrNull()?.let { return it }
legal.filterIsInstance<DigCanal>().firstOrNull()?.let { return it }
legal.filterIsInstance<BuildWonder>().firstOrNull()?.let { return it }
// Seafarers: a ship toward land nobody has settled is worth more than a card.
@@ -71,6 +71,21 @@ enum class Terrain(val produces: Resource?) {
FORTRESS(null),
}
/** Base-island scenarios: the board and goals of a game without Seafarers. Seafarers games choose a [SeaScenario] instead. */
@Serializable
enum class BaseScenario(val title: String, val blurb: String) {
RANDOM_MAP("Random map", "The usual island, its tiles and numbers shuffled for every game."),
FIRST_ISLAND(
"First Island",
"The same fixed island every game, so the set-up is not a surprise: good for a first game or a rematch.",
),
HARBORMASTER(
"The Harbormaster",
"Harbours matter. A settlement on a harbour counts a point and a city two; the first to three has the " +
"Harbormaster title, worth 2 victory points, and whoever gets more takes it.",
),
}
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
@Serializable
data class HexCoord(val q: Int, val r: Int)
@@ -197,6 +212,7 @@ data class CatanOptions(
val pointsToWin: Int = 10,
val expansions: Set<CatanExpansion> = emptySet(),
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
) {
init {
require(playerCount in 3..4) { "The island seats three or four players" }
@@ -214,6 +230,7 @@ private class CatanOptionsWire(
val pointsToWin: Int = 10,
val expansions: Set<CatanExpansion> = emptySet(),
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
)
/** Reads the legacy single `ruleSet` field as an expansion set. */
@@ -222,7 +239,7 @@ object CatanOptionsSerializer : KSerializer<CatanOptions> {
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
CatanOptionsWire.serializer(),
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario),
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario, value.baseScenario),
)
override fun deserialize(decoder: Decoder): CatanOptions {
@@ -234,7 +251,7 @@ object CatanOptionsSerializer : KSerializer<CatanOptions> {
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
}
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario)
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario, wire.baseScenario)
}
}
@@ -306,6 +323,8 @@ data class CatanState(
val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null,
/** The Harbormaster scenario: the seat holding the title, or null. */
val harbormaster: Int? = null,
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
val sea: SeaState? = null,
) {
@@ -507,8 +526,34 @@ data class SeaState(
val raidPoints: List<Int> = emptyList(),
/** Wonder stages each seat has built (Wonders of Catan), by seat; empty when the scenario has none. */
val wonders: List<Int> = emptyList(),
/** The chest under each hex of fog, by hex (Treasure Islands, Oases); found when the hex is. Hidden from other views. */
val treasures: List<Treasure> = emptyList(),
/** Units dug so far into each canal tile, by hex (Great Canal); empty when the map has none. */
val canalUnits: List<Int> = emptyList(),
/** The number token waiting under each canal tile, by hex; it shows when the tile is finished. */
val canalNumbers: List<Int> = emptyList(),
/** Canal units each seat has dug; every two are a victory point. */
val canalDug: List<Int> = emptyList(),
)
/** What a treasure chest holds (Treasure Islands). */
@Serializable
enum class Treasure {
NONE,
/** One brick, one wool and one grain. */
BRICK_WOOL_GRAIN,
/** One resource of the finder's choice. */
CHOICE,
/** A development card (a resource of choice in Cities & Knights, which has none). */
DEV_CARD,
/** Two roads' worth of cards: two lumber and two brick. */
ROADS,
}
/** What the first settler beside a tribe hex takes (Forgotten Tribe). */
@Serializable
enum class TribeChip {
@@ -563,3 +608,8 @@ data class RaidFortress(val hex: HexCoord) : CatanMove
@Serializable
@SerialName("wonder")
data object BuildWonder : CatanMove
/** Dig one unit of the canal tile on [hex]: 1 brick and 1 ore, from a seat with a settlement or city beside it. */
@Serializable
@SerialName("dig")
data class DigCanal(val hex: HexCoord) : CatanMove
@@ -59,7 +59,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
?: CatanBoard.generate(config.seed)
?: CatanBoard.generate(
if (options.baseScenario == BaseScenario.FIRST_ISLAND) CatanBoard.FIRST_ISLAND_SEED else config.seed,
)
// A different random stream than the island, so the same seed still
// produces both a fixed island and a fixed deck.
val ck = options.citiesAndKnights
@@ -125,6 +127,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
villageCloth = it.villageCloth,
chips = it.chips,
fortress = it.fortress,
treasures = it.treasures,
canalUnits = if (it.canalNumbers.isEmpty()) emptyList() else List(topo.HEX_COUNT) { 0 },
canalNumbers = it.canalNumbers,
canalDug = if (it.canalNumbers.isEmpty()) emptyList() else List(options.playerCount) { 0 },
wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(),
)
},
@@ -475,6 +481,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is MovePirate -> applyMovePirate(state, seat, move)
is PickGold -> applyPickGold(state, seat, move)
is RaidFortress -> SeaRules.applyRaid(state, seat, move)
is DigCanal -> SeaRules.applyDig(state, seat, move)
BuildWonder -> SeaRules.applyWonder(state, seat)
is BuildRoad -> applyBuildRoad(state, seat, move)
is BuildSettlement -> applyBuildSettlement(state, seat, move)
@@ -527,7 +534,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
val newIndex = state.setupIndex + 1
s = s.copy(setupIndex = newIndex)
s = refreshHarbormaster(s.copy(setupIndex = newIndex))
return when {
newIndex >= 2 * n -> s.copy(
phase = CatanPhase.AWAIT_ROLL,
@@ -1018,16 +1025,64 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
state.longestRoadOwner?.let { points[it] += 5 }
state.largestArmyOwner?.let { points[it] += 5 }
state.harbormaster?.let { points[it] += HARBORMASTER_POINTS }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList()
}
// -----------------------------------------------------------------------
// The Harbormaster
// -----------------------------------------------------------------------
/** The scenario's title: points for a harbour town and for the Harbormaster. */
const val HARBORMASTER_MINIMUM = 3
const val HARBORMASTER_POINTS = 2
/** Whether this table plays the Harbormaster (never in a Seafarers game, which has its own maps). */
internal fun harbormasterGame(state: CatanState): Boolean =
state.options.baseScenario == BaseScenario.HARBORMASTER && !state.isSea
/** Harbour points: a settlement on a harbour counts 1, a city 2, for each harbour it touches. */
fun harbourPoints(state: CatanState, seat: Int): Int {
var total = 0
for (port in state.ports) {
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
val own = listOfNotNull(state.buildings[a], state.buildings[b]).firstOrNull { it.owner == seat }
if (own != null) total += if (own.type == BuildingType.CITY) 2 else 1
}
return total
}
/** Gives the title to the first seat with three harbour points, and to a seat with strictly more than the holder. */
internal fun refreshHarbormaster(state: CatanState): CatanState {
if (!harbormasterGame(state)) return state
val points = List(state.playerCount) { harbourPoints(state, it) }
val holder = state.harbormaster
val best = points.max()
val leaders = points.indices.filter { points[it] == best }
val next = when {
holder == null -> leaders.singleOrNull()?.takeIf { best >= HARBORMASTER_MINIMUM }
best > points[holder] -> leaders.singleOrNull() ?: holder
else -> holder
}
if (next == holder) return state
return state.copy(
harbormaster = next,
log = state.log + if (holder == null) {
"Seat $next is now harbormaster."
} else {
"Seat $next has taken the title harbormaster from seat $holder."
},
)
}
fun victoryPoints(state: CatanState, seat: Int): Int =
publicVictoryPoints(state)[seat] +
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
internal fun checkWin(state: CatanState): CatanState {
if (state.phase == CatanPhase.GAME_OVER) return state
internal fun checkWin(settled: CatanState): CatanState {
if (settled.phase == CatanPhase.GAME_OVER) return settled
val state = refreshHarbormaster(settled)
val seat = state.turn
val points = victoryPoints(state, seat)
if (points < state.options.pointsToWin) return state
@@ -1226,7 +1281,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()),
sea = state.sea?.copy(fogDeck = emptyList(), treasures = emptyList()),
devHands = state.devHands.mapIndexed { index, hand ->
if (index == seat) hand else emptyList()
},
@@ -24,6 +24,21 @@ enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart
"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.",
),
TREASURE_ISLANDS(
"The Treasure Islands",
"Chests lie hidden on the fog-covered isles beside a home island. Whoever uncovers one opens it: " +
"cards, a resource of choice, a development card or a road's worth of materials.",
),
OASES(
"The Oases",
"An enormous desert, with fertile oases hidden inside it. Roads that reach an oasis uncover it " +
"and whatever treasure is buried there.",
),
GREAT_CANAL(
"The Great Canal",
"A desert strip splits the island. Dig the canal tile by tile (a brick and an ore a unit) and each finished " +
"tile becomes grain land; every two units dug are a victory point.",
),
PIRATE_ISLANDS(
"Pirate Islands",
"Two isles are held by pirate fortresses. Sail ships beside one and raid it with ore, wool and grain; " +
@@ -136,6 +151,39 @@ object SeaBoards {
"............",
)
private val TREASURE_ISLANDS_MAP = listOf(
"............",
"..###..FF...",
"..####..FF..",
"..#####.....",
"..###...F...",
".......FF...",
"..F.........",
"............",
)
private val OASES_MAP = listOf(
"............",
"..###DD###..",
"..#DDFDD#...",
"..#DDDFD#...",
"..#DFDDD#...",
"..###DD###..",
"............",
)
/** `C` is a canal tile: desert with a number hidden under it until it is dug out. */
private val GREAT_CANAL_MAP = listOf(
"............",
"..#####.....",
"..#####.....",
"..CCCCC.....",
"..#####.....",
"..#####.....",
"........$$..",
"............",
)
/** `P` is a pirate fortress. */
private val PIRATE_ISLANDS_MAP = listOf(
"............",
@@ -164,6 +212,9 @@ object SeaBoards {
SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP
SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP
SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP
SeaScenario.TREASURE_ISLANDS -> TREASURE_ISLANDS_MAP
SeaScenario.OASES -> OASES_MAP
SeaScenario.GREAT_CANAL -> GREAT_CANAL_MAP
SeaScenario.PIRATE_ISLANDS -> PIRATE_ISLANDS_MAP
SeaScenario.WONDERS_OF_CATAN -> WONDERS_OF_CATAN_MAP
SeaScenario.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP
@@ -215,6 +266,10 @@ object SeaBoards {
val villageCloth: List<Int> = emptyList(),
/** Strength of the fortress on each hex, by hex; empty when the map has none. */
val fortress: List<Int> = emptyList(),
/** The chest under each hex, by hex; empty when the map has none. */
val treasures: List<Treasure> = emptyList(),
/** The number under each canal tile, by hex; empty when the map has none. */
val canalNumbers: List<Int> = emptyList(),
)
/** An island of this many land hexes or more can be started on. */
@@ -226,6 +281,9 @@ object SeaBoards {
/** Ships' raids a pirate fortress takes to fall. */
const val FORTRESS_STRENGTH = 3
/** Chests dealt to the fog, at most. */
private const val CHESTS = 5
private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
)
@@ -250,13 +308,14 @@ object SeaBoards {
'#', 't' -> pool[plain.indexOf(h)]
'V' -> Terrain.VILLAGE
'P' -> Terrain.FORTRESS
'C' -> Terrain.DESERT
'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 && terrains[it] != Terrain.FORTRESS }
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && (terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS || kinds[it] == 'C') }
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
val tokens = TOKENS.take(numbered.size).shuffled(random)
val numbers = IntArray(terrains.size)
@@ -294,7 +353,9 @@ object SeaBoards {
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
?: numbered.first()
// 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 fogged = (0 until topo.islandCount).filter { island ->
topo.islandOf.indices.filter { topo.islandOf[it] == island }.all { terrains[it] == Terrain.FOG }
}.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.
@@ -334,6 +395,24 @@ object SeaBoards {
List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 }
}
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress)
// Chests under the fog: the four rewards in turn, on hexes picked at random.
val chestKinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.CHOICE, Treasure.DEV_CARD, Treasure.ROADS)
val chestHexes = if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) {
fogHexes.shuffled(random).take(CHESTS)
} else {
emptyList()
}
val treasures = if (chestHexes.isEmpty()) {
emptyList()
} else {
List(terrains.size) { h -> chestHexes.indexOf(h).let { if (it < 0) Treasure.NONE else chestKinds[it % chestKinds.size] } }
}
// Canal tiles keep their number under the tile until it is dug out.
val canalHexes = kinds.indices.filter { kinds[it] == 'C' }
val canalNumbers = if (canalHexes.isEmpty()) emptyList() else List(terrains.size) { h -> if (h in canalHexes) numbers[h] else 0 }
canalHexes.forEach { numbers[it] = 0 }
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress, treasures, canalNumbers)
}
}
@@ -182,11 +182,55 @@ internal object SeaRules {
} else if (tile.terrain == Terrain.GOLD) {
owed += GoldOwed(seat, 1)
}
val chest = s.sea!!.treasures.getOrNull(hex) ?: Treasure.NONE
if (chest != Treasure.NONE) {
val (opened, choose) = openChest(s, seat, chest)
s = opened
if (choose > 0) owed += GoldOwed(seat, choose)
}
}
if (owed.isEmpty()) return s
return owe(s, seat, owed.sumOf { it.count })
}
/**
* [seat] opens [chest]: the state after what it pays directly, and the number of cards of the seat's own
* choice still owed. Cards the bank cannot cover are simply not paid.
*/
private fun openChest(state: CatanState, seat: Int, chest: Treasure): Pair<CatanState, Int> {
var s = state
fun pay(cards: Map<Resource, Int>) {
val real = cards.filter { (r, n) -> CatanRules.bankSupply(s, r) >= n }
if (real.isNotEmpty()) s = s.copy(resources = CatanRules.withCounts(s.resources, seat, real, 1))
}
var choose = 0
val said = when (chest) {
Treasure.BRICK_WOOL_GRAIN -> {
pay(mapOf(Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1))
"a brick, a wool and a grain"
}
Treasure.ROADS -> {
pay(mapOf(Resource.LUMBER to 2, Resource.BRICK to 2))
"materials for two roads"
}
Treasure.DEV_CARD -> if (!s.isCk && s.devDeck.isNotEmpty()) {
val card = s.devDeck.first().copy(boughtTurn = s.turnNumber)
val hands = s.devHands.toMutableList().also { it[seat] = it[seat] + card }
s = s.copy(devDeck = s.devDeck.drop(1), devHands = hands, devHandCounts = hands.map { it.size })
"a development card"
} else {
choose = 1
"a resource of its choice"
}
else -> {
choose = 1
"a resource of its choice"
}
}
s = s.copy(log = s.log + "Seat $seat opens a treasure chest: $said.")
return s to choose
}
/** Queues [count] cards of [seat]'s choice, to be taken before play resumes where it left off. */
private fun owe(state: CatanState, seat: Int, count: Int): CatanState {
val sea = state.sea!!
@@ -208,7 +252,51 @@ internal object SeaRules {
(sea.raidPoints.getOrNull(seat) ?: 0) +
(sea.wonders.getOrNull(seat) ?: 0) +
clothOf(state, seat) / CLOTH_PER_POINT +
(sea.chipPoints.getOrNull(seat) ?: 0)
(sea.chipPoints.getOrNull(seat) ?: 0) +
(sea.canalDug.getOrNull(seat) ?: 0) / CANAL_UNITS_PER_POINT
}
// -----------------------------------------------------------------------
// The Great Canal
// -----------------------------------------------------------------------
val DIG_COST: Map<Resource, Int> = mapOf(Resource.BRICK to 1, Resource.ORE to 1)
/** Units that finish a canal tile. */
const val CANAL_TILE_UNITS = 3
/** Two units dug are a victory point. */
const val CANAL_UNITS_PER_POINT = 2
/** Tiles [seat] may dig a unit of: unfinished, with a settlement or city of the seat's on a corner. */
private fun digTargets(state: CatanState, seat: Int): List<Int> {
val sea = state.sea!!
if (sea.canalUnits.isEmpty()) return emptyList()
return sea.canalUnits.indices.filter { h ->
sea.canalNumbers[h] != 0 && sea.canalUnits[h] in 0 until CANAL_TILE_UNITS &&
state.terrains[h] == Terrain.DESERT &&
state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
}
}
fun applyDig(state: CatanState, seat: Int, move: DigCanal): CatanState {
val sea = state.sea!!
val hex = state.topo.hexIndex.getValue(move.hex)
val units = sea.canalUnits[hex] + 1
val dug = sea.canalDug.toMutableList().also { it[seat] += 1 }
var s = state.copy(
resources = CatanRules.withCounts(state.resources, seat, DIG_COST, -1),
sea = sea.copy(canalUnits = sea.canalUnits.toMutableList().also { it[hex] = units }, canalDug = dug),
log = state.log + "Seat $seat digs the canal at (${move.hex.q},${move.hex.r}): $units of $CANAL_TILE_UNITS.",
)
if (units == CANAL_TILE_UNITS) {
s = s.copy(
terrains = s.terrains.toMutableList().also { it[hex] = Terrain.FIELDS },
numbers = s.numbers.toMutableList().also { it[hex] = sea.canalNumbers[hex] },
log = s.log + "The tile is dug out: it is fields now, with a ${sea.canalNumbers[hex]}.",
)
}
return CatanRules.checkWin(s)
}
// -----------------------------------------------------------------------
@@ -328,6 +416,9 @@ internal object SeaRules {
if (CatanRules.affordable(state, seat, RAID_COST)) {
raidTargets(state, seat).forEach { moves += RaidFortress(state.topo.HEXES[it]) }
}
if (CatanRules.affordable(state, seat, DIG_COST)) {
digTargets(state, seat).forEach { moves += DigCanal(state.topo.HEXES[it]) }
}
nextStage(state, seat)?.let { if (CatanRules.affordable(state, seat, it)) moves += BuildWonder }
return moves
}
@@ -61,6 +61,7 @@ import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.SeaScenario
import org.prolibertate.games.game.catan.BaseScenario
import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions
@@ -442,6 +443,15 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
onSelect = { onChange(options.copy(scenario = it)) },
)
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
} else {
ChipRow(
label = "Island",
values = BaseScenario.entries,
selected = options.baseScenario,
display = { it.title },
onSelect = { onChange(options.copy(baseScenario = it)) },
)
Text(options.baseScenario.blurb, style = MaterialTheme.typography.bodySmall)
}
ChipRow(
label = "Players",
@@ -33,6 +33,7 @@ import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.SeaRules
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.catan.TribeChip
@@ -170,6 +171,8 @@ private fun DrawScope.drawHexes(
val sea = state.sea
val note = when {
state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth"
sea != null && sea.canalNumbers.getOrElse(hex) { 0 } != 0 && state.terrains[hex] == Terrain.DESERT ->
"dug ${sea.canalUnits.getOrElse(hex) { 0 }}/${SeaRules.CANAL_TILE_UNITS}"
state.terrains[hex] == Terrain.FORTRESS && sea != null -> "fort ${sea.fortress.getOrElse(hex) { 0 }}"
else -> when (sea?.chips?.getOrNull(hex)) {
TribeChip.POINT -> "+1 VP"
@@ -174,6 +174,7 @@ object CatanHudGlyphs {
BuildKind.KNIGHT -> "♞"
BuildKind.SHIP -> "⛵"
BuildKind.RAID -> "⚔"
BuildKind.DIG -> "⛏"
BuildKind.WONDER -> "✦"
}
@@ -185,6 +186,7 @@ object CatanHudGlyphs {
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
BuildKind.RAID -> CatanText.RAID
BuildKind.DIG -> CatanText.DIG
BuildKind.WONDER -> CatanText.WONDER
}
}
@@ -212,7 +214,7 @@ fun HudConfirmButtons(
fun ZoomPanBox(
modifier: Modifier = Modifier,
minScale: Float = 1f,
maxScale: Float = 3f,
maxScale: Float = 6f,
content: @Composable () -> Unit,
) {
var scale by remember { mutableFloatStateOf(1f) }
@@ -7,6 +7,7 @@ import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildShip
import org.prolibertate.games.game.catan.BuildWonder
import org.prolibertate.games.game.catan.DigCanal
import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.RaidFortress
import org.prolibertate.games.game.catan.MoveShip
@@ -74,6 +75,9 @@ enum class BuildKind {
/** Pirate Islands: raid a fortress; the target is the fortress hex. */
RAID,
/** Great Canal: dig a canal tile; the target is the tile. */
DIG,
/** Wonders of Catan: the next stage of the wonder, which needs no tap on the board. */
WONDER;
@@ -88,10 +92,11 @@ enum class BuildKind {
val sea = state.sea
val extras = buildList {
if (sea != null && sea.fortress.isNotEmpty()) add(RAID)
if (sea != null && sea.canalUnits.isNotEmpty()) add(DIG)
if (sea != null && sea.wonders.isNotEmpty()) add(WONDER)
}
val usual = when {
state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, WONDER)
state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, DIG, WONDER)
state.isSea -> SEAFARERS
else -> BASE
}
@@ -278,6 +283,9 @@ object CatanInteraction {
BuildKind.RAID -> BoardTargets(
hexes = legal.filterIsInstance<RaidFortress>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
)
BuildKind.DIG -> BoardTargets(
hexes = legal.filterIsInstance<DigCanal>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
)
BuildKind.WONDER -> BoardTargets()
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
@@ -546,7 +554,7 @@ object CatanInteraction {
val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
legal.firstOrNull { it is RaidFortress && it.hex == coord }?.let { return TapOutcome.Submit(it) }
legal.firstOrNull { (it is RaidFortress && it.hex == coord) || (it is DigCanal && it.hex == coord) }?.let { return TapOutcome.Submit(it) }
return when {
moves.isNotEmpty() -> if (moves.size == 1) {
TapOutcome.Submit(moves.first())
@@ -615,6 +623,7 @@ object CatanInteraction {
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
if (legal.any { it is RaidFortress }) add(BuildKind.RAID)
if (legal.any { it is DigCanal }) add(BuildKind.DIG)
if (legal.any { it is BuildWonder }) add(BuildKind.WONDER)
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
@@ -256,6 +256,7 @@ fun CatanPlayerCard(
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
}
if (ck.merchant?.seat == seat) add("Merchant")
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
addAll(seaAwards(state, seat, compact))
}
CardLine(
@@ -281,6 +282,7 @@ fun CatanPlayerCard(
val awards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
addAll(seaAwards(state, seat, compact))
}
// Always drawn, so the card does not grow when somebody takes an award.
@@ -601,6 +603,7 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
BuildKind.RAID -> CatanText.RAID
BuildKind.DIG -> CatanText.DIG
BuildKind.WONDER -> CatanText.WONDER
}
@@ -612,6 +615,7 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
BuildKind.RAID -> CatanText.RAID_COST
BuildKind.DIG -> CatanText.DIG_COST
BuildKind.WONDER -> CatanText.WONDER_COST
}
@@ -646,6 +650,7 @@ private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List<Stri
val sea = state.sea ?: return emptyList()
return buildList {
if (sea.cloth.isNotEmpty()) add(if (compact) "${sea.cloth[seat]}c" else "Cloth ${sea.cloth[seat]}")
if (sea.canalDug.isNotEmpty()) add(if (compact) "${sea.canalDug[seat]}⛏" else "Dug ${sea.canalDug[seat]}")
if (sea.wonders.isNotEmpty()) add(if (compact) "W${sea.wonders[seat]}" else "Wonder ${sea.wonders[seat]}/4")
if (seat in sea.harbourSeats) add(if (compact) "3:1" else "Harbour 3:1")
}
@@ -379,7 +379,8 @@ fun CatanScreen(
players(false)
if (current.isCk) CatanBarbarians(current, style = style)
promptRow()
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
// Pinch to zoom and drag to pan, as in landscape: a wide Seafarers map is small at fit.
ZoomPanBox(Modifier.weight(1f).fillMaxWidth()) {
board(Modifier)
}
handAndActions()
@@ -118,6 +118,8 @@ object CatanText {
const val SHIP_COST_TEXT = "1 lumber + 1 wool"
const val RAID = "Raid"
const val RAID_COST = "1 ore + 1 wool + 1 grain"
const val DIG = "Dig"
const val DIG_COST = "1 brick + 1 ore"
const val WONDER = "Wonder"
const val WONDER_COST = "next stage"
const val GOLD_TITLE = "Gold field"
@@ -254,6 +256,7 @@ object CatanText {
movingShip -> "Tap the sea edge to sail the ship to."
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it."
building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit."
building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
@@ -459,6 +462,7 @@ object CatanText {
movingShip -> "Tap the sea edge to sail the ship to."
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it."
building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit."
building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
@@ -0,0 +1,115 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
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
/** The base-island scenarios: First Island's fixed board and the Harbormaster. */
class CatanBaseScenarioTest {
private val json = Json { encodeDefaults = true }
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 game(
scenario: BaseScenario,
seed: Long = 5L,
expansions: Set<CatanExpansion> = emptySet(),
players: Int = 3,
) = CatanRules.initialState(config(CatanOptions(players, 10, expansions, baseScenario = scenario), seed))
@Test
fun `first island is the same board for every seed and the random map is not`() {
val a = game(BaseScenario.FIRST_ISLAND, 1L)
val b = game(BaseScenario.FIRST_ISLAND, 99L)
assertEquals(a.terrains, b.terrains)
assertEquals(a.numbers, b.numbers)
assertEquals(a.ports, b.ports)
assertNotEquals(game(BaseScenario.RANDOM_MAP, 1L).terrains, game(BaseScenario.RANDOM_MAP, 99L).terrains)
// The deck and the seating still follow the table's own seed.
assertNotEquals(a.devDeck, b.devDeck)
}
@Test
fun `a save from before base scenarios reads as a random map`() {
val old = """{"playerCount":3,"pointsToWin":10,"expansions":[],"scenario":"NEW_SHORES"}"""
assertEquals(BaseScenario.RANDOM_MAP, json.decodeFromString<CatanOptions>(old).baseScenario)
val options = CatanOptions(3, 10, baseScenario = BaseScenario.HARBORMASTER)
assertEquals(options, json.decodeFromString<CatanOptions>(json.encodeToString(options)))
}
/** Vertices of the harbours, one per harbour, in board order. */
private fun CatanState.harbourVertices(): List<Int> = ports.map { topo.EDGE_VERTICES[it.edge].first }
private fun CatanState.with(seat: Int, vertex: Int, type: BuildingType) =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) })
@Test
fun `three harbour points make a harbormaster and more takes the title`() {
var s = game(BaseScenario.HARBORMASTER).copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
val v = s.harbourVertices()
s = s.with(0, v[0], BuildingType.SETTLEMENT).with(0, v[1], BuildingType.SETTLEMENT)
assertEquals(2, CatanRules.harbourPoints(s, 0))
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
// A city counts two: 1 + 2 = 3.
s = CatanRules.refreshHarbormaster(s.with(0, v[1], BuildingType.CITY))
assertEquals(3, CatanRules.harbourPoints(s, 0))
assertEquals(0, s.harbormaster)
assertEquals(CatanRules.publicVictoryPoints(s.copy(harbormaster = null))[0] + 2, CatanRules.publicVictoryPoints(s)[0])
// Equal points do not take it; strictly more does.
s = CatanRules.refreshHarbormaster(
s.with(1, v[2], BuildingType.SETTLEMENT).with(1, v[3], BuildingType.SETTLEMENT).with(1, v[4], BuildingType.SETTLEMENT),
)
assertEquals(0, s.harbormaster)
s = CatanRules.refreshHarbormaster(s.with(1, v[5], BuildingType.SETTLEMENT))
assertEquals(1, s.harbormaster)
assertTrue(s.log.any { "taken the title harbormaster from seat 0" in it })
}
@Test
fun `the title does not exist in other scenarios or in a Seafarers game`() {
var s = game(BaseScenario.RANDOM_MAP).copy(phase = CatanPhase.PLAYING)
val v = s.harbourVertices()
for (i in 0..3) s = s.with(0, v[i], BuildingType.SETTLEMENT)
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
}
@Test
fun `engines finish games on the base scenarios, with and without Cities and Knights`() {
var titles = 0
for (scenario in listOf(BaseScenario.FIRST_ISLAND, BaseScenario.HARBORMASTER)) {
for (expansions in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
for ((players, seed) in listOf(3 to 31L, 4 to 32L)) {
val opts = CatanOptions(players, if (expansions.isEmpty()) 10 else 13, expansions, baseScenario = scenario)
var state = CatanRules.initialState(config(opts, seed))
val ai = CatanAi()
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("$scenario $expansions seed $seed ran long", moves++ < 20_000)
val seat = CatanRules.currentSeat(state)!!
val legal = CatanRules.legalMoves(state, seat)
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
if (state.harbormaster != null) titles++
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
}
}
assertTrue("nobody ever became harbormaster", titles > 0)
}
}
@@ -241,4 +241,96 @@ class CatanSeaExtrasTest {
val s = wonderGame().copy(resources = List(3) { Resource.entries.associateWith { 0 } })
assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildWonder })
}
// ---- Treasure Islands, Oases ----
@Test
fun `chests lie under the fog and are hidden from other views`() {
for (scenario in listOf(SeaScenario.TREASURE_ISLANDS, SeaScenario.OASES)) {
val s = start(scenario)
val chests = s.sea!!.treasures.indices.filter { s.sea!!.treasures[it] != Treasure.NONE }
assertEquals(minOf(5, s.terrains.count { it == Terrain.FOG }), chests.size)
assertTrue(chests.all { s.terrains[it] == Terrain.FOG })
assertTrue(CatanRules.viewFor(s, 1).sea!!.treasures.isEmpty())
}
}
@Test
fun `an oasis map starts everyone on the desert island, never beside an oasis`() {
val s = start(SeaScenario.OASES)
assertEquals(37, s.topo.islandOf.count { it == s.sea!!.home })
val moves = CatanRules.legalMoves(s.copy(phase = CatanPhase.SETUP_FIRST), s.firstSeat).filterIsInstance<SetupPlace>()
assertTrue(moves.isNotEmpty())
assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } })
}
/** A chest of [kind] under the first fog hex, with a road of seat 0's laid up to the edge that will reveal it. */
private fun chestGame(kind: Treasure): Triple<CatanState, Int, Int> {
val s = start(SeaScenario.OASES).copy(phase = CatanPhase.PLAYING)
val topo = s.topo
val hex = s.terrains.indexOf(Terrain.FOG)
val chests = List(topo.HEX_COUNT) { if (it == hex) kind else Treasure.NONE }
// Seat 0 owns the town beside a road that reaches the oasis; building the road finds it.
val edge = topo.EDGE_VERTICES.indices.first { e -> hex in topo.EDGE_HEXES[e] && topo.edgeLand[e] }
val staged = s.copy(
resources = List(3) { Resource.entries.associateWith { 5 } },
sea = s.sea!!.copy(treasures = chests),
)
return Triple(staged, hex, edge)
}
@Test
fun `opening a chest pays its reward`() {
val kinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.ROADS, Treasure.DEV_CARD, Treasure.CHOICE)
for (kind in kinds) {
val (s, _, _) = chestGame(kind)
// Stage the find directly: reveal as if a road had just reached the hex.
val topo = s.topo
val hex = s.terrains.indexOf(Terrain.FOG)
val edge = (0 until topo.EDGE_COUNT).first { hex in topo.EDGE_HEXES[it] && topo.edgeLand[it] }
val after = SeaRules.reveal(s, 0, edge)
assertTrue("$kind: the chest is announced", after.log.any { "treasure chest" in it })
when (kind) {
Treasure.DEV_CARD -> assertEquals(1, after.devHands[0].size)
Treasure.CHOICE -> assertTrue(after.phase == CatanPhase.GOLD || after.log.any { "uncovers" in it })
Treasure.ROADS -> assertTrue(after.resources[0].getValue(Resource.LUMBER) >= 7)
else -> assertTrue(after.resources[0].getValue(Resource.WOOL) >= 6)
}
}
}
// ---- The Great Canal ----
private fun canalGame(): Triple<CatanState, Int, CatanState> {
val s = start(SeaScenario.GREAT_CANAL).copy(
phase = CatanPhase.PLAYING,
resources = List(3) { Resource.entries.associateWith { 9 } },
)
val tile = s.sea!!.canalNumbers.indexOfFirst { it != 0 }
val staged = s.copy(
buildings = s.buildings.toMutableList().also {
it[s.topo.HEX_CORNERS[tile][0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
},
)
return Triple(staged, tile, s)
}
@Test
fun `a canal tile hides its number, and three digs turn it into fields`() {
var (s, tile, _) = canalGame()
assertEquals(0, s.numbers[tile])
assertEquals(Terrain.DESERT, s.terrains[tile])
val number = s.sea!!.canalNumbers[tile]
val dig = DigCanal(s.topo.HEXES[tile])
assertTrue(dig in CatanRules.legalMoves(s, 0))
assertTrue(CatanRules.legalMoves(s.copy(turn = 1), 1).none { it is DigCanal })
val before = CatanRules.publicVictoryPoints(s)[0]
repeat(2) { s = CatanRules.applyMove(s, 0, dig) }
assertEquals(Terrain.DESERT, s.terrains[tile])
assertEquals(before + 1, CatanRules.publicVictoryPoints(s)[0])
s = CatanRules.applyMove(s, 0, dig)
assertEquals(Terrain.FIELDS, s.terrains[tile])
assertEquals(number, s.numbers[tile])
assertTrue(CatanRules.legalMoves(s, 0).none { it is DigCanal && it.hex == dig.hex })
}
}
@@ -43,6 +43,9 @@ class CatanSeaScenariosTest {
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))
assertEquals(listOf(1, 3, 4, 15), sizes(SeaScenario.TREASURE_ISLANDS))
assertEquals(listOf(37), sizes(SeaScenario.OASES))
assertEquals(listOf(2, 25), sizes(SeaScenario.GREAT_CANAL))
assertEquals(listOf(4, 5, 18), sizes(SeaScenario.PIRATE_ISLANDS))
assertEquals(listOf(1, 3, 12, 13), sizes(SeaScenario.WONDERS_OF_CATAN))
assertEquals(listOf(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN))
@@ -64,7 +67,8 @@ class CatanSeaScenariosTest {
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
}
val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN &&
scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS
scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS &&
scenario != SeaScenario.TREASURE_ISLANDS
assertTrue("$scenario has no desert", deserts > 0 || !deserted)
// 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 }
@@ -219,6 +223,8 @@ class CatanSeaScenariosTest {
var chipsTaken = 0
var fortsTaken = 0
var wonderStages = 0
var chestsOpened = 0
var canalDug = 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)
@@ -238,6 +244,10 @@ class CatanSeaScenariosTest {
if (scenario == SeaScenario.FOG_ISLANDS) fogFound += 14 - state.sea!!.fogDeck.size
if (scenario == SeaScenario.CLOTH_FOR_CATAN) clothPaid += state.sea!!.cloth.sum()
if (scenario == SeaScenario.PIRATE_ISLANDS) fortsTaken += state.sea!!.raidPoints.sum() / 2
if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) {
chestsOpened += state.log.count { "treasure chest" in it }
}
if (scenario == SeaScenario.GREAT_CANAL) canalDug += state.sea!!.canalDug.sum()
if (scenario == SeaScenario.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum()
if (scenario == SeaScenario.FORGOTTEN_TRIBE) {
chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE }
@@ -249,6 +259,8 @@ class CatanSeaScenariosTest {
assertTrue("no village ever paid cloth", clothPaid > 0)
assertTrue("no tribe chip was ever taken", chipsTaken > 0)
assertTrue("no fortress ever fell", fortsTaken > 0)
assertTrue("no chest was ever opened", chestsOpened > 0)
assertTrue("no canal unit was ever dug", canalDug > 0)
assertTrue("no wonder stage was ever built", wonderStages > 0)
}
}
+7 -1
View File
@@ -66,7 +66,13 @@ A full-width panel that slides up over the bottom of the screen (`Animator`, col
* **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a
road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`.
* **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are
**not** part of the physical Seafarers rules; they are out of scope here.
**not** part of the physical Seafarers rules. Names and texts only (rule values are compiled code):
`TreasureRule` with chest outcomes (bricks, wool, grain; a resource; a development card; two roads),
`SeafarerOasis` (no rule class of its own, base-game rules), and `GreatCanalRule` with `BuildCanal`,
`CanalVictoryPoints`, "The canal was completely dug. Now grain can be grown." and a target of 21 / 18 points.
The base-game scenario `baseharbormaster`: "2 victory points. This card goes to the first player to acquire at
least 3 harbors. If another player acquires more, he will immediately receive this card."
Implemented as inferred rules; see `docs/RULES-catan-seafarers.md`.
* **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip`
(number tokens placed per scenario), `Shuffle_Harbors`.
* **Island bonus:** special victory points for the first settlement on a new island
+32 -1
View File
@@ -36,6 +36,11 @@ Pick the map at setup; each scenario names where the starting settlements may go
fortress. See *Pirate Islands* below.
* **Wonders of Catan.** A 13-hex island with a desert, a 12-hex island, and gold islets. See *Wonders* below.
* **The Treasure Islands** *(Catan Universe extra)*. A 15-hex home island and four fog isles, eight hexes of fog in
all. See *Catan Universe extras* below.
* **The Oases** *(Catan Universe extra)*. One 37-hex island that is mostly desert, with three oases hidden in it.
* **The Great Canal** *(Catan Universe extra)*. A 25-hex island split by a row of five canal tiles, and gold islets.
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,
@@ -139,6 +144,30 @@ board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain
1 grain, 1 lumber; 4: 3 ore, 2 grain, 1 brick). Each stage built is **one victory point**, and the seat that
builds the fourth stage **wins at once**, whatever the point target. Wonders are not shared: each seat has its own.
## Catan Universe extras (inferred)
These three exist in the Catan Universe app but not in the physical set. Only their names, menu texts and log
lines could be read (`docs/RE-catan-universe.md`); the rule values are compiled code that was not recovered. **Every
rule below is inferred from those names and texts**, not copied.
* **Treasure Islands.** The app's text: "Treasures await the Catanians on neighboring, fog-covered islands.
Discoveries are rewarded!" Its chest rewards (log texts and action names) are bricks, wool and grain; a resource of
choice; a development card; two roads. Here: up to five chests are dealt face down to random fog hexes at the
start. A seat that uncovers the hex (the fog rules) opens the chest at once and takes: *a brick, a wool and a grain*;
*one resource of its choice* (a gold pick); *a development card* (a resource of choice if the deck is empty or it is
Cities & Knights, which has no deck); or *two lumber and two brick* standing in for two free roads (the free-roads
phase would have to nest inside a ship move). The app's Cities & Knights version gives a progress card; that is
not modelled. The app's fixed treasure bonus (`SEAFARER_TREASURE_BONUS`) is not modelled either.
* **The Oases.** The app's text: an enormous desert with rumoured oases and treasures, base-game rules. Here: the
oases are fog hexes inside the desert, uncovered by a road reaching them, and hold the same chests as above.
There is no ship rule on the map itself.
* **The Great Canal.** The app's text, for Cities & Knights plus Seafarers: build a canal; first to 21 (3 players) or
18 (4 players) wins; the canal's parts earn victory points; when a tile is dug out, grain grows there. Its knights dig
in the app; here digging is cheaper to implement and play without Cities & Knights: a canal tile is a desert hex with
its number token hidden. A seat with a settlement or city on a corner of the tile digs one **unit** for 1 brick and
1 ore; **three units** finish the tile, which becomes **fields** with the hidden number. Every **two units** a seat
has dug are a **victory point**. The app's gold river, harbour changes and target scores are not modelled.
## With Cities & Knights
Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map
@@ -161,8 +190,10 @@ Where the two meet, this game decides as follows:
## Not included
The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**:
treasure islands, the oasis, the great canal, the Explorers & Pirates combination,
the Explorers & Pirates combination,
the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships.
Drought and the app's other scenarios (Six Islands, Oceania, Great Catan, Enchanted Land, the Inka and Big Game sets)
are not built.
A map-only scenario is a new text map and a new `SeaScenario` entry.
## How the AI plays it
+20
View File
@@ -23,6 +23,26 @@ A table plays one of two rule sets, chosen on the setup screen
building, robber and trade rules and changes or replaces the rest; its state lives
behind `CatanState.ck`, which is `null` in the base game.
## Base-island scenarios
Without Seafarers the setup screen offers an **Island** choice (`CatanOptions.baseScenario`; it works with Cities &
Knights too). Both extras are the author's reading, not transcriptions of the printed scenarios:
- **Random map** (default) — the usual shuffled island.
- **First Island** — the same island every game: one fixed shuffle (`CatanBoard.FIRST_ISLAND_SEED`) of the standard
tiles, numbers and harbours, so a first game or a rematch has no surprise set-up. It is not the printed beginner
layout. The development deck and the seating still follow the table's own seed.
- **The Harbormaster** — the Catan Universe menu text is "Because trade becomes more and more important, harbours
have an ever increasing significance." Its card: 2 victory points to the first player with at least three harbour
points; another player with more takes it at once. What a harbour point is was not in the app's texts, so it is
inferred: a settlement on a harbour counts **1**, a city **2**, for each harbour it touches. The title is checked after
every settlement, city and set-up placement; **equal points do not take it**, only strictly more (like Longest
Road); it is **+2 victory points** to the holder (`CatanState.harbormaster`). The AI values a harbour spot a
little more here (`CatanAi.HARBOR_LURE`).
Seafarers games choose a map instead (see [RULES-catan-seafarers.md](RULES-catan-seafarers.md)); the Harbormaster does not apply there.
The app's other base scenario, Ore for Wool, is not built: its rules are not in the texts that could be read.
## Table
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).