Compare commits

...
Author SHA1 Message Date
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
Claude 3315790688 Merge origin/dev
CI / Build and check (pull_request) Successful in 11m33s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-12 05:21:47 +00:00
Claude 20b6cb4b5f Seafarers: Pirate Islands and Wonders of Catan
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-12 05:21:46 +00:00
devadmin eef024848e Merge pull request 'Seafarers: Cloth for Catan and The Forgotten Tribe (phase 5a)' (#37) from feature/catan-cloth-tribe into dev
CI / Build and check (push) Successful in 11m16s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-11-11 17:05:22 -06:00
Claude c3f2efa313 Seafarers: Cloth for Catan and The Forgotten Tribe
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-11 22:53:25 +00:00
devadmin 385262515e Merge pull request 'Katan: Cities & Knights on Seafarers maps (phase 4)' (#36) from feature/catan-seafarers-ck into dev
CI / Build and check (push) Successful in 11m9s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-11-11 14:46:41 -06:00
Claude 7f3c2fe0e9 Katan: Cities & Knights on Seafarers maps
CI / Build and check (pull_request) Successful in 11m6s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Lift the one-expansion limit. Cities & Knights now reads the board from
state.topo, so it runs on any Seafarers map. Ships, knights (on land
posts reached by road or ship), the pirate (after the first barbarian
attack, robbing goods) and gold (chosen after the roll, before chores)
are wired into the C&K turn; first-landing bonuses count toward points.
Setup toggles stack, with a 10-16 point menu when both are on.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-11 20:35:19 +00:00
devadmin f8750684e6 Merge pull request 'Seafarers: The Fog Islands (phase 3)' (#35) from feature/catan-fog-islands into dev
CI / Build and check (push) Successful in 11m8s
CI / Deploy to Cloudflare Pages (push) Successful in 34s
2026-11-11 13:51:37 -06:00
22 changed files with 1121 additions and 81 deletions
+2 -2
View File
@@ -56,9 +56,9 @@ The island game is catalogue id `catan`, shown as **Katan**. It has two expansio
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`CatanState.ck`, which is null in the base game) and Seafarers
(`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind
`CatanState.sea`). The base and Seafarers rules read the board from `state.topo`, not from
`CatanState.sea`); the two can be switched on together. The base and Seafarers rules read the board from `state.topo`, not from
`CatanBoard` directly, because a Seafarers map is not the 19-hex island
(Cities & Knights is always the standard island and still uses `CatanBoard`). The
(Cities & Knights also reads `state.topo`, so it runs on a Seafarers map too). The
Catan Universe reverse engineering that shaped the landscape HUD and trade board
is in `docs/RE-catan-universe.md`. The screen is layered; see
`docs/UI-catan.md` before changing it.
@@ -189,7 +189,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
have.toDouble() / cost.values.sum() + if (covers(hand, cost)) 1.0 else 0.0
}
private fun chooseGold(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
internal fun chooseGold(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val picks = legal.filterIsInstance<PickGold>()
if (picks.isEmpty()) return legal.first()
return when (profile.level) {
@@ -277,6 +277,10 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
pickBest(settlements, profile) { spotValue(state, it.vertex) }?.let { return it }
}
// A raid on a fortress, or the next stage of a wonder, beats a card.
legal.filterIsInstance<RaidFortress>().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.
legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.5 }
@@ -592,6 +596,20 @@ 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 {
val sea = state.sea ?: return 0.0
var lure = 0.0
for (h in state.topo.VERTEX_HEXES[vertex]) {
// A cloth token is a quarter-point, rolled as often as its number comes up.
if (state.terrains[h] == Terrain.VILLAGE && sea.villageCloth.getOrElse(h) { 0 } > 0) {
lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH
}
if (sea.chips.getOrNull(h).let { it != null && it != TribeChip.NONE }) lure += CHIP_LURE
}
return lure
}
internal fun spotValue(state: CatanState, vertex: Int): Double {
if (state.buildings[vertex] != null) return -1.0
var value = 0.0
@@ -602,6 +620,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
kinds += resource
}
value += 0.12 * kinds.size // variety keeps a build plan alive
value += scenarioLure(state, vertex)
for (e in state.topo.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) 0.1 else 0.2
@@ -646,6 +665,11 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
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()))
// A fortress is worth a raid once a ship lies beside it.
if (sea.fortress.isNotEmpty() && topo.VERTEX_EDGES[v].any { e ->
topo.edgeSea[e] && topo.EDGE_HEXES[e].any { sea.fortress.getOrElse(it) { 0 } > 0 }
}
) best = maxOf(best, FORT_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
@@ -678,5 +702,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
/** What a ship end beside undiscovered land is worth, on the scale of [spotValue]. */
const val FOG_LURE = 0.6
const val FORT_LURE = 1.2
const val CLOTH_WORTH = 2.5
const val CHIP_LURE = 0.5
}
}
@@ -171,18 +171,22 @@ object CatanCk {
/** Cities of [seat] that can carry a metropolis or fall to the barbarians' rival: all but metropolises. */
private fun plainCities(state: CatanState, seat: Int): List<Int> =
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
(0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && !hasMetropolis(state, v)
}
private fun ownsCity(state: CatanState, seat: Int): Boolean =
state.buildings.any { it == CatanBuilding(BuildingType.CITY, seat) }
/** Empty crossings at the end of one of [seat]'s roads: where a knight can be put. */
/** Whether [seat] has a road, or a ship in Seafarers, on [edge]: the network a knight moves along. */
private fun holds(state: CatanState, seat: Int, edge: Int): Boolean =
state.roads[edge] == seat || state.sea?.ships?.get(edge) == seat
/** Empty crossings at the end of one of [seat]'s roads or ships on land: where a knight can be put. */
private fun knightSpots(state: CatanState, seat: Int): List<Int> =
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
state.buildings[v] == null && knightAt(state, v) == null &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
(0 until state.topo.VERTEX_COUNT).filter { v ->
state.topo.vertexLand[v] && state.buildings[v] == null && knightAt(state, v) == null &&
state.topo.VERTEX_EDGES[v].any { holds(state, seat, it) }
}
/**
@@ -196,9 +200,9 @@ object CatanCk {
val queue = ArrayDeque<Int>().apply { add(from) }
while (queue.isNotEmpty()) {
val v = queue.removeFirst()
for (e in CatanBoard.VERTEX_EDGES[v]) {
if (state.roads[e] != seat) continue
val w = CatanBoard.otherEnd(e, v)
for (e in state.topo.VERTEX_EDGES[v]) {
if (!holds(state, seat, e)) continue
val w = state.topo.otherEnd(e, v)
if (!seen.add(w)) continue
val building = state.buildings[w]
val knight = knightAt(state, w)
@@ -215,14 +219,14 @@ object CatanCk {
private fun destinations(state: CatanState, k: Knight): List<Int> =
walk(state, k.owner, k.vertex).filter { w ->
val there = knightAt(state, w)
if (state.buildings[w] != null) return@filter false
if (state.buildings[w] != null || !state.topo.vertexLand[w]) return@filter false
there == null || (there.owner != k.owner && there.level < k.level)
}.sorted()
/** Empty crossings a driven-off [knight] of its owner could still reach. */
private fun refuges(state: CatanState, knight: Knight): List<Int> =
walk(state, knight.owner, knight.vertex).filter { w ->
state.buildings[w] == null && knightAt(state, w) == null
state.buildings[w] == null && state.topo.vertexLand[w] && knightAt(state, w) == null
}.sorted()
private fun promotable(state: CatanState, k: Knight): Boolean {
@@ -251,6 +255,7 @@ object CatanCk {
points[seat] += ck.defenderVp[seat] + ck.progressVp[seat]
}
ck.merchant?.let { points[it.seat] += 1 }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList()
}
@@ -266,15 +271,18 @@ object CatanCk {
addAll(actionMoves(state, seat))
}
CatanPhase.ROBBER -> robberMoves(state, seat)
// The bishop moves the robber alone; a seven may send the pirate instead.
CatanPhase.ROBBER -> robberMoves(state, seat) +
(if (state.isSea && !state.ck!!.bishop) SeaRules.pirateMoves(state, seat) else emptyList())
CatanPhase.PLAYING -> playingMoves(state, seat)
CatanPhase.FREE_ROADS ->
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
CatanRules.roadMoves(state, seat) +
(if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) +
listOf<CatanMove>(EndFreeRoads)
// Seafarers is never combined with Cities & Knights.
CatanPhase.GOLD -> emptyList()
CatanPhase.GOLD -> SeaRules.goldMoves(state)
CatanPhase.TRADE_PENDING -> {
val offer = state.ck!!.goodsOffer
@@ -327,7 +335,7 @@ object CatanCk {
if (!k.active && affordable(state, seat, ACTIVATE_COST)) moves += ActivateKnight(k.vertex)
if (canAct(state, k)) {
destinations(state, k).forEach { moves += MoveKnight(k.vertex, it) }
if (ck.attacked && k.vertex in CatanBoard.HEX_CORNERS[state.robber]) {
if (ck.attacked && k.vertex in state.topo.HEX_CORNERS[state.robber]) {
moves += ChaseRobber(k.vertex)
}
}
@@ -340,6 +348,11 @@ object CatanCk {
val moves = mutableListOf<CatanMove>()
if (affordable(state, seat, CatanRules.ROAD_COST)) moves += CatanRules.roadMoves(state, seat)
if (state.isSea) {
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
moves += SeaRules.moveShipMoves(state, seat)
moves += SeaRules.scenarioMoves(state, seat)
}
if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) {
moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) }
}
@@ -380,7 +393,7 @@ object CatanCk {
/** Own cities with no wall, while the seat has a wall left to build. */
private fun wallSites(state: CatanState, seat: Int): List<Int> {
if (wallsOf(state, seat) >= MAX_WALLS) return emptyList()
return (0 until CatanBoard.VERTEX_COUNT).filter { v ->
return (0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && v !in state.ck!!.walls
}
}
@@ -423,20 +436,37 @@ object CatanCk {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val bishop = state.ck!!.bishop
val moves = mutableListOf<MoveRobber>()
for (h in 0 until CatanBoard.HEX_COUNT) {
if (h == state.robber) continue
for (h in 0 until state.topo.HEX_COUNT) {
if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG) continue
val victims = victimsAt(state, seat, h)
when {
bishop -> moves += MoveRobber(CatanBoard.HEXES[h])
victims.isEmpty() -> moves += MoveRobber(CatanBoard.HEXES[h])
else -> victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
bishop -> moves += MoveRobber(state.topo.HEXES[h])
victims.isEmpty() -> moves += MoveRobber(state.topo.HEXES[h])
else -> victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
}
}
return moves
}
/** The pirate, sent by a seven once the barbarians have landed: it robs a seat with a ship beside it. */
fun applyPirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1)
var stole = ""
val hand = move.victim?.let { goodsList(s, it) }.orEmpty()
if (move.victim != null && hand.isNotEmpty()) {
val card = hand[Random(state.seed * 1_000_003L + state.rngStep).nextInt(hand.size)]
s = give(s, move.victim, seat, listOf(card))
stole = " and takes a ${good(card)} from seat ${move.victim}"
}
return s.copy(
phase = if (state.ck!!.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING,
log = s.log + "Seat $seat sends the pirate to (${move.hex.q},${move.hex.r})$stole.",
)
}
private fun victimsAt(state: CatanState, seat: Int, hex: Int): List<Int> =
CatanBoard.HEX_CORNERS[hex]
state.topo.HEX_CORNERS[hex]
.mapNotNull { state.buildings[it] }
.map { it.owner }
.filter { it != seat }
@@ -445,7 +475,7 @@ object CatanCk {
fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
val ck = state.ck!!
val hex = CatanBoard.hexIndex[move.hex]
val hex = state.topo.hexIndex[move.hex]
?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
val rng = Random(state.seed * 1_000_003L + state.rngStep)
@@ -495,7 +525,7 @@ object CatanCk {
ProgressType.ENGINEER -> wallSites(state, seat).map { PlayProgress(id, vertex = it) }
ProgressType.INVENTOR -> {
val hexes = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] in INVENTOR_NUMBERS }
val hexes = (0 until state.topo.HEX_COUNT).filter { state.numbers[it] in INVENTOR_NUMBERS }
hexes.flatMap { a ->
hexes.filter { b -> b > a && state.numbers[b] != state.numbers[a] }
.map { b -> PlayProgress(id, hexA = a, hexB = b) }
@@ -530,7 +560,7 @@ object CatanCk {
ProgressType.INTRIGUE ->
ck.knights.filter { k ->
k.owner != seat && CatanBoard.VERTEX_EDGES[k.vertex].any { state.roads[it] == seat }
k.owner != seat && state.topo.VERTEX_EDGES[k.vertex].any { holds(state, seat, it) }
}.map { PlayProgress(id, vertex = it.vertex) }
ProgressType.SABOTEUR -> listOf(PlayProgress(id))
@@ -559,9 +589,9 @@ object CatanCk {
}
ProgressType.MERCHANT ->
(0 until CatanBoard.HEX_COUNT).filter { h ->
state.terrains[h] != Terrain.DESERT &&
CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
(0 until state.topo.HEX_COUNT).filter { h ->
state.terrains[h] != Terrain.DESERT && state.topo.land[h] && state.terrains[h] != Terrain.FOG &&
state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
}.map { PlayProgress(id, hexA = it) }
ProgressType.MERCHANT_FLEET -> Good.entries.map { PlayProgress(id, good = it) }
@@ -596,19 +626,19 @@ object CatanCk {
}
private fun hexesNextTo(state: CatanState, seat: Int, terrain: Terrain): List<Int> =
(0 until CatanBoard.HEX_COUNT).filter { h ->
(0 until state.topo.HEX_COUNT).filter { h ->
state.terrains[h] == terrain &&
CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
}
/** Roads with a loose end: one end touching nothing else of anyone's. */
private fun openRoads(state: CatanState): List<Int> =
(0 until CatanBoard.EDGE_COUNT).filter { e ->
(0 until state.topo.EDGE_COUNT).filter { e ->
if (state.roads[e] == NO_PLAYER) return@filter false
val (a, b) = CatanBoard.EDGE_VERTICES[e]
val (a, b) = state.topo.EDGE_VERTICES[e]
listOf(a, b).any { v ->
state.buildings[v] == null && knightAt(state, v) == null &&
CatanBoard.VERTEX_EDGES[v].none { it != e && state.roads[it] != NO_PLAYER }
state.topo.VERTEX_EDGES[v].none { it != e && state.roads[it] != NO_PLAYER }
}
}
@@ -1500,12 +1530,12 @@ object CatanCk {
private fun production(state: CatanState, number: Int): CatanState {
val gains = List(state.playerCount) { mutableMapOf<Good, Int>() }
for (h in 0 until CatanBoard.HEX_COUNT) {
for (h in 0 until state.topo.HEX_COUNT) {
if (state.numbers[h] != number || h == state.robber) continue
val terrain = state.terrains[h]
val resource = terrain.produces ?: continue
val commodity = commodityOf(terrain)
for (v in CatanBoard.HEX_CORNERS[h]) {
for (v in state.topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
val mine = gains[building.owner]
fun add(good: Good, n: Int) { mine[good] = (mine[good] ?: 0) + n }
@@ -1539,6 +1569,7 @@ object CatanCk {
}
val said = if (notes.isEmpty()) "nothing for anyone" else notes.joinToString(", ")
s = s.copy(log = s.log + "The dice pay: $said.")
if (state.isSea) s = SeaRules.payCloth(s, number)
// The aqueduct: a seat that was paid nothing may take a resource of its choice.
val order = (0 until s.playerCount).map { (s.turn + it) % s.playerCount }
@@ -1546,7 +1577,11 @@ object CatanCk {
improvement(s, seat, Track.SCIENCE) >= IMPROVEMENT_POWER_LEVEL && gains[seat].values.sum() == 0
}
val queued = enqueue(s, dry.map { AqueductChore(it) }, Back.AFTER_PRODUCTION)
return if (queued.ck!!.chores.isNotEmpty()) queued else queued.copy(phase = CatanPhase.PLAYING)
val next = if (queued.ck!!.chores.isNotEmpty()) queued else queued.copy(phase = CatanPhase.PLAYING)
// Gold is chosen before anything else; the chores (and then play) wait behind it.
val gold = if (state.isSea) CatanRules.goldOwed(state, number) else emptyList()
if (gold.isEmpty()) return next
return SeaRules.settleGold(next.copy(sea = next.sea!!.copy(gold = gold, afterGold = next.phase)))
}
// -----------------------------------------------------------------------
@@ -53,7 +53,9 @@ internal class CatanCkAi(
CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile)
CatanPhase.DISCARDING, CatanPhase.GOLD, CatanPhase.GAME_OVER -> legal.firstOrNull()
CatanPhase.GOLD -> ai.chooseGold(state, seat, legal, profile)
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
@@ -131,9 +133,9 @@ internal class CatanCkAi(
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
val victimWeight = 0.3 + 0.1 * profile.style.boldness
fun score(move: MoveRobber): Double {
val hex = CatanBoard.hexIndex.getValue(move.hex)
val hex = state.topo.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
for (v in state.topo.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
@@ -178,7 +180,7 @@ internal class CatanCkAi(
// Chase the robber off a hex it is squatting on.
if (ck.attacked) {
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
val robberHurtsMe = state.topo.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
if (onMine != null && robberHurtsMe) return onMine
}
@@ -212,6 +214,15 @@ internal class CatanCkAi(
settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it }
}
legal.filterIsInstance<RaidFortress>().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.
legal.filterIsInstance<BuildShip>()
.filter { ai.shipValue(state, seat, it.edge) > 0.5 }
.maxByOrNull { ai.shipValue(state, seat, it.edge) }
?.let { return it }
// Improvements spend cards nothing else needs. Favour the track furthest along.
legal.filterIsInstance<BuyImprovement>()
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
@@ -226,6 +237,11 @@ internal class CatanCkAi(
if (CatanCk.goodsCount(state, seat) >= (if (boldness < 0) 5 else 7)) return it
}
legal.filterIsInstance<BuildShip>()
.filter { ai.shipValue(state, seat, it.edge) > 0.15 }
.maxByOrNull { ai.shipValue(state, seat, it.edge) }
?.let { return it }
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.1 - 0.04 * boldness }
.maxByOrNull { ai.roadValue(state, it) }
@@ -360,7 +376,7 @@ internal class CatanCkAi(
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
private fun production(state: CatanState, vertex: Int): Double =
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
state.topo.VERTEX_HEXES[vertex].sumOf { h ->
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
}
@@ -60,6 +60,15 @@ enum class Terrain(val produces: Resource?) {
* (`SeaState.fogDeck`). Until then nothing is built on it, nothing produces, and the robber keeps off.
*/
FOG(null),
/** Seafarers, Cloth for Catan: a village. It pays cloth, not cards, to the settlements around it when its number rolls. */
VILLAGE(null),
/**
* Seafarers, Pirate Islands: a pirate fortress. It stands until ships raid it (`SeaState.fortress`), and
* nobody settles its island meanwhile. Once taken it becomes desert.
*/
FORTRESS(null),
}
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
@@ -192,8 +201,6 @@ data class CatanOptions(
init {
require(playerCount in 3..4) { "The island seats three or four players" }
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
// Lifted once Cities & Knights runs on a Seafarers map.
require(expansions.size <= 1) { "Combining expansions is not supported yet" }
}
val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions
@@ -484,8 +491,39 @@ data class SeaState(
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,
/** Cloth tokens each seat holds (Cloth for Catan); empty when the scenario has none. Two make a victory point. */
val cloth: List<Int> = emptyList(),
/** Cloth still on each hex's village, by hex (Cloth for Catan); empty when the scenario has none. */
val villageCloth: List<Int> = emptyList(),
/** The tribe chip waiting on each hex (Forgotten Tribe), by hex; empty when the scenario has none. */
val chips: List<TribeChip> = emptyList(),
/** Seats that took a harbour chip and so trade 3:1 anywhere. */
val harbourSeats: List<Int> = emptyList(),
/** Victory points each seat holds from tribe chips; empty until one is taken. */
val chipPoints: List<Int> = emptyList(),
/** Strength still left in each hex's pirate fortress, by hex (Pirate Islands); empty when the map has none. */
val fortress: List<Int> = emptyList(),
/** Victory points each seat holds for taking fortresses; empty until one falls. */
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(),
)
/** What the first settler beside a tribe hex takes (Forgotten Tribe). */
@Serializable
enum class TribeChip {
NONE,
/** One victory point. */
POINT,
/** A 3:1 harbour, good wherever the seat trades. */
HARBOUR,
/** Two resources of the seat's choice. */
GOLD,
}
/** 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)
@@ -515,3 +553,13 @@ data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
@Serializable
@SerialName("gold")
data class PickGold(val cards: List<Resource>) : CatanMove
/** Raid the pirate fortress on [hex] from a ship beside it: 1 ore, 1 wool and 1 grain take one point of its strength. */
@Serializable
@SerialName("raid")
data class RaidFortress(val hex: HexCoord) : CatanMove
/** Build the next stage of your wonder, which needs a city of yours; the stage's cost is in `SeaRules.WONDER_STAGES`. */
@Serializable
@SerialName("wonder")
data object BuildWonder : CatanMove
@@ -121,6 +121,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
home = it.home,
starts = it.starts,
fogDeck = it.fogDeck,
cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 },
villageCloth = it.villageCloth,
chips = it.chips,
fortress = it.fortress,
wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(),
)
},
)
@@ -248,7 +253,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] || state.terrains[h] == Terrain.FOG) continue
if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG || state.terrains[h] == Terrain.FORTRESS) continue
val victims = state.topo.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] }
.map { it.owner }
@@ -280,6 +285,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.isSea) {
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
moves += SeaRules.moveShipMoves(state, seat)
moves += SeaRules.scenarioMoves(state, seat)
}
if (!state.devCardPlayedThisTurn) {
@@ -414,6 +420,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
/** An empty vertex with no building on it or one edge away. */
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
!SeaRules.fortressHolds(state, v) &&
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/**
@@ -467,6 +474,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is MoveShip -> applyMoveShip(state, seat, move)
is MovePirate -> applyMovePirate(state, seat, move)
is PickGold -> applyPickGold(state, seat, move)
is RaidFortress -> SeaRules.applyRaid(state, seat, move)
BuildWonder -> SeaRules.applyWonder(state, seat)
is BuildRoad -> applyBuildRoad(state, seat, move)
is BuildSettlement -> applyBuildSettlement(state, seat, move)
is BuildCity -> applyBuildCity(state, seat, move)
@@ -596,12 +605,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
)
if (state.isSea) s = SeaRules.payCloth(s, number)
if (goldOwed.isEmpty()) return s
return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed)))
}
/** What each seat is owed for gold fields showing [number], in seat order from the roller. */
private fun goldOwed(state: CatanState, number: Int): List<GoldOwed> {
internal fun goldOwed(state: CatanState, number: Int): List<GoldOwed> {
val topo = state.topo
val owed = IntArray(state.playerCount)
for (h in 0 until topo.HEX_COUNT) {
@@ -708,6 +718,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
private fun applyMovePirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
if (state.isCk) return CatanCk.applyPirate(state, seat, move)
val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1)
var stolen = ""
@@ -742,7 +753,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
},
)
s = payCost(s, seat, SETTLEMENT_COST)
s = SeaRules.claimIsland(s, seat, move.vertex)
s = SeaRules.settled(s, seat, move.vertex)
// A settlement can break another player's road, so the award is
// recomputed for everyone.
s = refreshLongestRoad(s)
@@ -1183,6 +1194,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
if (mine) best = port.ratio
}
if (best > 3 && state.sea?.harbourSeats?.contains(seat) == true) best = 3
return best
}
@@ -1193,7 +1205,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
}
return 4
return if (state.sea?.harbourSeats?.contains(seat) == true) 3 else 4
}
// -----------------------------------------------------------------------
@@ -24,6 +24,26 @@ 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.",
),
PIRATE_ISLANDS(
"Pirate Islands",
"Two isles are held by pirate fortresses. Sail ships beside one and raid it with ore, wool and grain; " +
"nobody may settle the isle until the fortress falls, and the one who takes it earns two points.",
),
WONDERS_OF_CATAN(
"Wonders of Catan",
"Two big islands and room to grow. Raise a city, then build a wonder in four stages; each stage is a " +
"victory point and the first to finish all four wins.",
),
CLOTH_FOR_CATAN(
"Cloth for Catan",
"Four villages weave cloth. Settle beside one and it pays you a cloth token whenever its number rolls; " +
"every two tokens are a victory point.",
),
FORGOTTEN_TRIBE(
"The Forgotten Tribe",
"Four small isles hold the chips of a lost tribe. The first to settle beside one takes it: " +
"a victory point, a harbour, or gold.",
),
}
/** Where the starting settlements may go. */
@@ -92,11 +112,62 @@ object SeaBoards {
"............",
)
/** `V` is a village; `t` is a resource hex that carries a tribe chip. */
private val CLOTH_FOR_CATAN_MAP = listOf(
"............",
"..V###..V##.",
"..#####..##.",
"..####V..#..",
"...###......",
".......##V..",
"............",
"...##.......",
"............",
)
private val FORGOTTEN_TRIBE_MAP = listOf(
"............",
"..###..t#...",
"..#####..#t.",
"..#####..#..",
"..####......",
"........t##.",
".#t.........",
"............",
)
/** `P` is a pirate fortress. */
private val PIRATE_ISLANDS_MAP = listOf(
"............",
"..####.P##..",
"..####..##..",
"..####......",
"..####..P#..",
"...##...##..",
"............",
"............",
)
private val WONDERS_OF_CATAN_MAP = listOf(
"............",
"..####.###..",
".#####.####.",
"..###D.###..",
".......##...",
"..$......$..",
"...##.......",
"............",
)
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
SeaScenario.PIRATE_ISLANDS -> PIRATE_ISLANDS_MAP
SeaScenario.WONDERS_OF_CATAN -> WONDERS_OF_CATAN_MAP
SeaScenario.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP
SeaScenario.FORGOTTEN_TRIBE -> FORGOTTEN_TRIBE_MAP
}
private val topologies = HashMap<SeaScenario, Topology>()
@@ -138,11 +209,23 @@ object SeaBoards {
val starts: List<Int> = listOf(home),
/** The tiles under the fog, in the order they will be found. */
val fogDeck: List<FogTile> = emptyList(),
/** The tribe chip on each hex, or [TribeChip.NONE]; empty when the map has none. */
val chips: List<TribeChip> = emptyList(),
/** Cloth waiting on each village, by hex; empty when the map has none. */
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(),
)
/** An island of this many land hexes or more can be started on. */
private const val MAIN_ISLAND_HEXES = 4
/** Cloth each village starts with. */
const val VILLAGE_CLOTH = 6
/** Ships' raids a pirate fortress takes to fall. */
const val FORTRESS_STRENGTH = 3
private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
)
@@ -157,21 +240,23 @@ object SeaBoards {
val random = Random(seed)
// Resources fill the '#' hexes as evenly as the count allows, in shuffled order.
val plain = kinds.indices.filter { kinds[it] == '#' }
val plain = kinds.indices.filter { kinds[it] == '#' || kinds[it] == 't' }
val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random)
val terrains = MutableList(kinds.size) { Terrain.SEA }
kinds.forEachIndexed { h, ch ->
terrains[h] = when (ch) {
'$' -> Terrain.GOLD
'D' -> Terrain.DESERT
'#' -> pool[plain.indexOf(h)]
'#', 't' -> pool[plain.indexOf(h)]
'V' -> Terrain.VILLAGE
'P' -> Terrain.FORTRESS
'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 }
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS }
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)
@@ -228,6 +313,27 @@ object SeaBoards {
}.ifEmpty { listOf(home) }
}
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck)
// Tribe chips: one of each kind in turn, dealt to the marked hexes in random order.
val chipHexes = kinds.indices.filter { kinds[it] == 't' }
val chips = if (chipHexes.isEmpty()) {
emptyList()
} else {
val kindsOfChip = listOf(TribeChip.POINT, TribeChip.HARBOUR, TribeChip.GOLD)
val dealt = List(chipHexes.size) { kindsOfChip[it % kindsOfChip.size] }.shuffled(random)
List(terrains.size) { h -> chipHexes.indexOf(h).let { if (it < 0) TribeChip.NONE else dealt[it] } }
}
val villages = if (kinds.none { it == 'V' }) {
emptyList()
} else {
List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 }
}
val fortress = if (kinds.none { it == 'P' }) {
emptyList()
} else {
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)
}
}
@@ -83,7 +83,7 @@ internal object SeaRules {
.map { sea.ships[it] }
.filter { it != NO_PLAYER && it != thief }
.distinct()
.filter { state.resourceCount(it) > 0 }
.filter { if (state.isCk) CatanCk.goodsCount(state, it) > 0 else state.resourceCount(it) > 0 }
}
fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> {
@@ -184,18 +184,210 @@ internal object SeaRules {
}
}
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)))
return owe(s, seat, owed.sumOf { it.count })
}
/** 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!!
val queued = sea.copy(
gold = sea.gold + GoldOwed(seat, count),
afterGold = state.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold,
)
return settleGold(state.copy(sea = queued))
}
// -----------------------------------------------------------------------
// Islands and scoring
// -----------------------------------------------------------------------
/** The bonus points [seat] holds for first landings. */
fun bonusPoints(state: CatanState, seat: Int): Int =
(state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS
/** The bonus points [seat] holds: first landings, tribe chips, and one for every two cloth. */
fun bonusPoints(state: CatanState, seat: Int): Int {
val sea = state.sea ?: return 0
return sea.islandBonus.count { it == seat } * ISLAND_BONUS +
(sea.raidPoints.getOrNull(seat) ?: 0) +
(sea.wonders.getOrNull(seat) ?: 0) +
clothOf(state, seat) / CLOTH_PER_POINT +
(sea.chipPoints.getOrNull(seat) ?: 0)
}
// -----------------------------------------------------------------------
// Cloth for Catan
// -----------------------------------------------------------------------
const val CLOTH_PER_POINT = 2
fun clothOf(state: CatanState, seat: Int): Int = state.sea?.cloth?.getOrNull(seat) ?: 0
/**
* After the dice pay: every village showing [number] pays one cloth to each settlement or city beside it
* (a city is not worth more here), until its stock runs out. A robber on the village stops it.
*/
fun payCloth(state: CatanState, number: Int): CatanState {
val sea = state.sea ?: return state
if (sea.villageCloth.isEmpty()) return state
var s = state
val topo = state.topo
for (h in 0 until topo.HEX_COUNT) {
if (state.terrains[h] != Terrain.VILLAGE || state.numbers[h] != number || h == state.robber) continue
val takers = topo.HEX_CORNERS[h].mapNotNull { state.buildings[it]?.owner }
if (takers.isEmpty()) continue
// Seats in turn order from the roller get the last of the stock.
val order = takers.sortedBy { (it - state.turn + state.playerCount) % state.playerCount }
val current = s.sea!!
var stock = current.villageCloth[h]
val cloth = current.cloth.toMutableList()
val said = mutableListOf<String>()
for (seat in order) {
if (stock == 0) break
cloth[seat] += 1
stock--
said += "seat $seat"
}
if (said.isEmpty()) continue
val stocks = current.villageCloth.toMutableList().also { it[h] = stock }
s = s.copy(
sea = current.copy(cloth = cloth, villageCloth = stocks),
log = s.log + "The village at (${topo.HEXES[h].q},${topo.HEXES[h].r}) pays cloth to ${said.joinToString(", ")}.",
)
}
return s
}
// -----------------------------------------------------------------------
// Pirate Islands
// -----------------------------------------------------------------------
val RAID_COST: Map<Resource, Int> = mapOf(Resource.ORE to 1, Resource.WOOL to 1, Resource.GRAIN to 1)
const val FORTRESS_POINTS = 2
/** Whether a pirate fortress still stands on the island [vertex] is part of, so nobody may settle there. */
fun fortressHolds(state: CatanState, vertex: Int): Boolean {
val sea = state.sea ?: return false
if (sea.fortress.isEmpty()) return false
val island = state.topo.vertexIsland[vertex]
return island >= 0 && sea.fortress.indices.any { sea.fortress[it] > 0 && state.topo.islandOf[it] == island }
}
/** Fortress hexes [seat] has a ship beside. */
private fun raidTargets(state: CatanState, seat: Int): List<Int> {
val sea = state.sea!!
if (sea.fortress.isEmpty()) return emptyList()
val topo = state.topo
return sea.fortress.indices.filter { h ->
sea.fortress[h] > 0 && (0 until topo.EDGE_COUNT).any { h in topo.EDGE_HEXES[it] && sea.ships[it] == seat }
}
}
fun applyRaid(state: CatanState, seat: Int, move: RaidFortress): CatanState {
val sea = state.sea!!
val hex = state.topo.hexIndex.getValue(move.hex)
val left = sea.fortress[hex] - 1
var s = state.copy(resources = CatanRules.withCounts(state.resources, seat, RAID_COST, -1))
val strength = sea.fortress.toMutableList().also { it[hex] = left }
if (left > 0) {
return s.copy(
sea = sea.copy(fortress = strength),
log = s.log + "Seat $seat raids the fortress at (${move.hex.q},${move.hex.r}); $left strength left.",
)
}
val points = sea.raidPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList().also { it[seat] += FORTRESS_POINTS }
s = s.copy(
terrains = s.terrains.toMutableList().also { it[hex] = Terrain.DESERT },
sea = sea.copy(fortress = strength, raidPoints = points),
log = s.log + "Seat $seat takes the fortress at (${move.hex.q},${move.hex.r}): +$FORTRESS_POINTS victory points, " +
"and the island is open.",
)
return CatanRules.checkWin(s)
}
// -----------------------------------------------------------------------
// Wonders of Catan
// -----------------------------------------------------------------------
/** What each of the four stages of a wonder costs. */
val WONDER_STAGES: List<Map<Resource, Int>> = listOf(
mapOf(Resource.BRICK to 2, Resource.LUMBER to 1, Resource.GRAIN to 1),
mapOf(Resource.BRICK to 1, Resource.ORE to 2, Resource.WOOL to 1),
mapOf(Resource.ORE to 3, Resource.GRAIN to 1, Resource.LUMBER to 1),
mapOf(Resource.ORE to 3, Resource.GRAIN to 2, Resource.BRICK to 1),
)
/** The next stage [seat] could build, if the scenario has wonders and the seat has a city to raise it in. */
private fun nextStage(state: CatanState, seat: Int): Map<Resource, Int>? {
val stage = state.sea?.wonders?.getOrNull(seat) ?: return null
if (stage >= WONDER_STAGES.size) return null
if (state.buildings.none { it?.owner == seat && it.type == BuildingType.CITY }) return null
return WONDER_STAGES[stage]
}
/** The extra moves a scenario offers during play: raids and wonder stages. */
fun scenarioMoves(state: CatanState, seat: Int): List<CatanMove> {
val moves = mutableListOf<CatanMove>()
if (CatanRules.affordable(state, seat, RAID_COST)) {
raidTargets(state, seat).forEach { moves += RaidFortress(state.topo.HEXES[it]) }
}
nextStage(state, seat)?.let { if (CatanRules.affordable(state, seat, it)) moves += BuildWonder }
return moves
}
fun applyWonder(state: CatanState, seat: Int): CatanState {
val sea = state.sea!!
val stage = sea.wonders[seat] + 1
val built = sea.wonders.toMutableList().also { it[seat] = stage }
val s = state.copy(
resources = CatanRules.withCounts(state.resources, seat, WONDER_STAGES[stage - 1], -1),
sea = sea.copy(wonders = built),
log = state.log + "Seat $seat builds stage $stage of a wonder.",
)
if (stage < WONDER_STAGES.size) return CatanRules.checkWin(s)
return s.copy(
phase = CatanPhase.GAME_OVER,
winner = seat,
log = s.log + "Seat $seat completes a wonder of Catan and wins.",
)
}
// -----------------------------------------------------------------------
// The Forgotten Tribe
// -----------------------------------------------------------------------
/** After a settlement on [vertex]: take any tribe chip lying on a hex beside it. */
fun takeChips(state: CatanState, seat: Int, vertex: Int): CatanState {
var s = state
if (s.sea?.chips.isNullOrEmpty()) return s
var gold = 0
for (hex in state.topo.VERTEX_HEXES[vertex]) {
val sea = s.sea!!
val chip = sea.chips.getOrNull(hex) ?: continue
if (chip == TribeChip.NONE) continue
val rest = sea.chips.toMutableList().also { it[hex] = TribeChip.NONE }
var next = sea.copy(chips = rest)
val said = when (chip) {
TribeChip.POINT -> {
val points = next.chipPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList()
points[seat] += 1
next = next.copy(chipPoints = points)
"a victory point"
}
TribeChip.HARBOUR -> {
next = next.copy(harbourSeats = (next.harbourSeats + seat).distinct())
"a harbour that trades 3:1"
}
else -> {
gold += 2
"two resources of its choice"
}
}
s = s.copy(sea = next, log = s.log + "Seat $seat takes the tribe's chip beside it: $said.")
}
return if (gold > 0) owe(s, seat, gold) else s
}
/** What happens to a new settlement of [seat]'s on [vertex] beyond the base game. */
fun settled(state: CatanState, seat: Int, vertex: Int): CatanState =
takeChips(claimIsland(state, seat, vertex), seat, vertex)
/** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
@@ -376,6 +376,8 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
/** The targets the setup screen offers for a Katan game with [expansions] on. */
internal fun catanPointsChoices(expansions: Set<CatanExpansion>): List<Int> = when {
// Both: Cities & Knights' long game plus the new-island points.
expansions.size > 1 -> listOf(10, 12, 14, 16)
// Seafarers adds two points for each new island, so the target is higher.
CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14)
// Cities & Knights is played to thirteen, which is also why it runs longer.
@@ -386,6 +388,7 @@ internal fun catanPointsChoices(expansions: Set<CatanExpansion>): List<Int> = wh
/** The published target for a game with [expansions] on. */
internal fun catanDefaultPoints(expansions: Set<CatanExpansion>): Int = when {
expansions.size > 1 -> 14
CatanExpansion.SEAFARERS in expansions -> 12
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> 13
else -> 10
@@ -393,11 +396,10 @@ internal fun catanDefaultPoints(expansions: Set<CatanExpansion>): Int = when {
/**
* [options] with [expansion] switched on or off. The target moves to the new combination's own
* default unless the one already chosen is on its menu too. Expansions cannot be combined yet,
* so switching one on switches the other off.
* default unless the one already chosen is on its menu too.
*/
internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions {
val expansions = if (on) setOf(expansion) else options.expansions - expansion
val expansions = if (on) options.expansions + expansion else options.expansions - expansion
return options.copy(
expansions = expansions,
pointsToWin = options.pointsToWin.takeIf {
@@ -34,6 +34,7 @@ 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.Terrain
import org.prolibertate.games.game.catan.TribeChip
/**
* The island, drawn and tappable.
@@ -166,6 +167,27 @@ private fun DrawScope.drawHexes(
weight = FontWeight.Bold,
)
}
val sea = state.sea
val note = when {
state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth"
state.terrains[hex] == Terrain.FORTRESS && sea != null -> "fort ${sea.fortress.getOrElse(hex) { 0 }}"
else -> when (sea?.chips?.getOrNull(hex)) {
TribeChip.POINT -> "+1 VP"
TribeChip.HARBOUR -> "3:1"
TribeChip.GOLD -> "gold"
else -> null
}
}
if (note != null) {
drawCentered(
measurer = measurer,
text = note,
at = Pt(centre.x, centre.y + board.radius * 0.62f),
fontPx = board.radius * 0.26f,
color = style.tokenInk,
weight = FontWeight.Bold,
)
}
}
}
@@ -173,6 +173,8 @@ object CatanHudGlyphs {
BuildKind.WALL -> "▦"
BuildKind.KNIGHT -> "♞"
BuildKind.SHIP -> "⛵"
BuildKind.RAID -> "⚔"
BuildKind.WONDER -> "✦"
}
fun label(kind: BuildKind): String = when (kind) {
@@ -182,6 +184,8 @@ object CatanHudGlyphs {
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
BuildKind.RAID -> CatanText.RAID
BuildKind.WONDER -> CatanText.WONDER
}
}
@@ -6,7 +6,9 @@ import org.prolibertate.games.game.catan.ActivateKnight
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.MovePirate
import org.prolibertate.games.game.catan.RaidFortress
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.Topology
import org.prolibertate.games.game.catan.BuildSettlement
@@ -67,13 +69,34 @@ enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
/** Seafarers: a ship, laid on a sea edge. */
SHIP;
SHIP,
/** Pirate Islands: raid a fortress; the target is the fortress hex. */
RAID,
/** Wonders of Catan: the next stage of the wonder, which needs no tap on the board. */
WONDER;
companion object {
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
/** Seafarers adds the ship, listed after the road it sails beside. */
val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, SETTLEMENT, CITY)
/** The kinds a table's build menu lists: the scenario's own raid or wonder follows the usual ones. */
fun of(state: CatanState): List<BuildKind> {
val sea = state.sea
val extras = buildList {
if (sea != null && sea.fortress.isNotEmpty()) add(RAID)
if (sea != null && sea.wonders.isNotEmpty()) add(WONDER)
}
val usual = when {
state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, WONDER)
state.isSea -> SEAFARERS
else -> BASE
}
return usual + extras
}
}
}
@@ -252,6 +275,10 @@ object CatanInteraction {
return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SHIP -> BoardTargets(edges = ships)
BuildKind.RAID -> BoardTargets(
hexes = legal.filterIsInstance<RaidFortress>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
)
BuildKind.WONDER -> BoardTargets()
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
@@ -519,6 +546,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) }
return when {
moves.isNotEmpty() -> if (moves.size == 1) {
TapOutcome.Submit(moves.first())
@@ -586,6 +614,8 @@ object CatanInteraction {
if (legal.any { it is BuildShip }) add(BuildKind.SHIP)
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 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")
addAll(seaAwards(state, seat, compact))
}
CardLine(
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
@@ -280,6 +281,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")
addAll(seaAwards(state, seat, compact))
}
// Always drawn, so the card does not grow when somebody takes an award.
CardLine(
@@ -598,6 +600,8 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
BuildKind.RAID -> CatanText.RAID
BuildKind.WONDER -> CatanText.WONDER
}
private fun buildCost(kind: BuildKind): String = when (kind) {
@@ -607,6 +611,8 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
BuildKind.RAID -> CatanText.RAID_COST
BuildKind.WONDER -> CatanText.WONDER_COST
}
@Composable
@@ -634,3 +640,13 @@ fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
}
}
}
/** Seafarers scenario holdings worth a word on a player's card: cloth, and a tribe harbour. */
private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List<String> {
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.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")
}
}
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.AqueductChore
import org.prolibertate.games.game.catan.BuildWonder
import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove
@@ -159,6 +160,14 @@ fun CatanScreen(
}
}
// A wonder stage needs no tap on the board: choosing it from the menu builds it.
LaunchedEffect(building) {
if (building == BuildKind.WONDER) {
legal.firstOrNull { it is BuildWonder }?.let { controller.submit(it) }
building = null
}
}
val submit: (CatanMove) -> Unit = { move ->
controller.submit(move)
building = null
@@ -197,11 +206,7 @@ fun CatanScreen(
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions(
buildKinds = when {
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
current.isSea -> BuildKind.SEAFARERS
else -> BuildKind.BASE
},
buildKinds = BuildKind.of(current),
ck = current.isCk,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable },
@@ -126,6 +126,10 @@ private val LightStyle = CatanStyle(
Terrain.GOLD to Color(0xFFF2B01E),
// Land not yet found: a pale grey bank of fog with a question mark.
Terrain.FOG to Color(0xFFB9C2C9),
// Cloth villages: a dyed-cloth purple.
Terrain.VILLAGE to Color(0xFF9B6BB5),
// A pirate fortress: dark stone.
Terrain.FORTRESS to Color(0xFF4A4F57),
),
hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9),
@@ -75,6 +75,8 @@ object CatanText {
Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field"
Terrain.FOG -> "Fog"
Terrain.VILLAGE -> "Village"
Terrain.FORTRESS -> "Pirate fortress"
}
fun devCard(type: DevCardType): String = when (type) {
@@ -114,6 +116,10 @@ object CatanText {
const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)."
const val SHIP = "Ship"
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 WONDER = "Wonder"
const val WONDER_COST = "next stage"
const val GOLD_TITLE = "Gold field"
const val GOLD_TAKE = "Take"
const val ROLL = "Roll dice"
@@ -194,7 +200,7 @@ object CatanText {
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */
movingShip: Boolean = false,
): String {
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed, movingShip)
val me = seats.me
val onClock = CatanRules.currentSeat(state)
val mine = onClock == me
@@ -247,6 +253,7 @@ object CatanText {
!mine -> "$turn is playing…"
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.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."
@@ -401,6 +408,7 @@ object CatanText {
building: BuildKind?,
movingKnight: Boolean,
armed: ProgressType?,
movingShip: Boolean,
): String {
val me = seats.me
val onClock = CatanRules.currentSeat(state)
@@ -409,7 +417,12 @@ object CatanText {
val ck = state.ck!!
return when (state.phase) {
CatanPhase.GOLD -> ""
CatanPhase.GOLD ->
if (mine) {
"Gold! Choose ${state.sea?.gold?.firstOrNull()?.count ?: 1} resource cards."
} else {
"${seats.label(onClock ?: state.turn)} is choosing from the gold…"
}
CatanPhase.GAME_OVER -> {
val winner = state.winner
@@ -435,6 +448,7 @@ object CatanText {
CatanPhase.ROBBER -> when {
!mine -> "$turn is moving the robber…"
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
state.isSea -> SEA_ROBBER
else -> "Move the robber. Tap a hex."
}
@@ -442,6 +456,9 @@ object CatanText {
!mine -> "$turn is playing…"
armed != null -> armedPrompt(armed)
movingKnight -> "Tap where the knight should go."
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.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,154 @@
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.assertNotNull
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
/** Cities & Knights played on a Seafarers map. */
class CatanSeaCkTest {
private val json = Json { encodeDefaults = true }
private fun options(scenario: SeaScenario = SeaScenario.NEW_SHORES, players: Int = 3) = CatanOptions(
playerCount = players,
pointsToWin = 14,
expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS, 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 start(scenario: SeaScenario = SeaScenario.NEW_SHORES, seed: Long = 7L) =
CatanRules.initialState(config(options(scenario), seed))
private fun CatanState.city(vertex: Int, seat: Int) =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(BuildingType.CITY, seat) })
@Test
fun `both expansions switch on together and the state carries both`() {
val s = start()
assertNotNull(s.ck)
assertNotNull(s.sea)
assertTrue(s.devDeck.isEmpty())
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), s.options.expansions)
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
}
@Test
fun `a ship is on offer in play, and a knight can stand at the end of a ship route on land`() {
val s0 = start()
val topo = s0.topo
// A coastal home town with a sea edge leaving it.
val town = (0 until topo.VERTEX_COUNT).first { v ->
topo.vertexIsland[v] == s0.sea!!.home &&
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
val s = s0.city(town, 0).copy(
phase = CatanPhase.PLAYING, turn = 0, turnNumber = 3,
resources = List(3) { Resource.entries.associateWith { 5 } },
)
val moves = CatanRules.legalMoves(s, 0)
assertTrue("no ship on offer", moves.any { it is BuildShip })
assertTrue("sea vertices are never knight posts", moves.filterIsInstance<HireKnight>().all { topo.vertexLand[it.vertex] })
}
@Test
fun `a gold field pays its owner a choice after the roll, then play goes on`() {
val s0 = start()
val topo = s0.topo
val gold = s0.terrains.indexOf(Terrain.GOLD)
val number = s0.numbers[gold]
val vertex = topo.HEX_CORNERS[gold].first { topo.vertexLand[it] }
val dice = (1..6).flatMap { a -> (1..6).map { b -> listOf(a, b) } }.first { it.sum() == number }
val s = s0.city(vertex, 0).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 3, robber = 0)
val rolled = CatanRules.applyMove(s, 0, RollDice(forced = dice, event = EventFace.TRADE))
assertEquals(CatanPhase.GOLD, rolled.phase)
assertEquals(listOf(GoldOwed(0, 2)), rolled.sea!!.gold)
val pick = CatanRules.legalMoves(rolled, 0).filterIsInstance<PickGold>().first { it.cards == listOf(Resource.ORE, Resource.ORE) }
val after = CatanRules.applyMove(rolled, 0, pick)
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(2, after.resources[0].getValue(Resource.ORE) - s.resources[0].getValue(Resource.ORE))
}
@Test
fun `once the barbarians have landed a seven may send the pirate, which robs goods`() {
val s0 = start()
val topo = s0.topo
val seaHex = (0 until topo.HEX_COUNT).first { !topo.land[it] && it != s0.sea!!.pirate &&
(0 until topo.EDGE_COUNT).any { e -> it in topo.EDGE_HEXES[e] && topo.edgeLand[e] } }
val edge = (0 until topo.EDGE_COUNT).first { seaHex in topo.EDGE_HEXES[it] }
val s = s0.copy(
phase = CatanPhase.ROBBER, turn = 0, turnNumber = 3,
ck = s0.ck!!.copy(attacked = true),
sea = s0.sea!!.copy(ships = s0.sea!!.ships.toMutableList().also { it[edge] = 1 }),
resources = s0.resources.toMutableList().also { it[1] = it[1] + (Resource.GRAIN to 2) },
)
val pirate = CatanRules.legalMoves(s, 0).filterIsInstance<MovePirate>()
.first { it.hex == topo.HEXES[seaHex] && it.victim == 1 }
val after = CatanRules.applyMove(s, 0, pirate)
assertEquals(seaHex, after.sea!!.pirate)
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(CatanCk.goodsCount(s, 0) + 1, CatanCk.goodsCount(after, 0))
assertEquals(CatanCk.goodsCount(s, 1) - 1, CatanCk.goodsCount(after, 1))
// The robber itself is kept off the sea and the fog.
val robber = CatanRules.legalMoves(s, 0).filterIsInstance<MoveRobber>()
assertTrue(robber.all { topo.land[topo.hexIndex.getValue(it.hex)] })
}
@Test
fun `first landings count toward the target`() {
val s0 = start()
val sea = s0.sea!!
val islet = (0 until s0.topo.islandCount).first { !sea.isStart(it) }
val withBonus = s0.copy(sea = sea.copy(islandBonus = sea.islandBonus.toMutableList().also { it[islet] = 2 }))
assertEquals(CatanCk.publicVictoryPoints(s0)[2] + SeaRules.ISLAND_BONUS, CatanCk.publicVictoryPoints(withBonus)[2])
}
@Test
fun `engines finish a Cities and Knights game on three maps`() {
var ships = 0
var knights = 0
var barbarianAttacks = 0
for ((scenario, players, seed) in listOf(
Triple(SeaScenario.NEW_SHORES, 3, 21L),
Triple(SeaScenario.FOUR_ISLANDS, 4, 22L),
Triple(SeaScenario.FOG_ISLANDS, 3, 23L),
)) {
val ai = CatanAi()
var state = CatanRules.initialState(config(options(scenario, players), seed))
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 40_000)
val seat = CatanRules.currentSeat(state) ?: error("$scenario: nobody to move in ${state.phase}; ${state.log.takeLast(4)}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("$scenario: no legal move for $seat in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue("$scenario: illegal $move in ${state.phase}", CatanRules.isLegalMove(state, seat, move))
state = CatanRules.applyMove(state, seat, move)
if (moves % 40 == 0) assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
assertFalse(state.winner == null)
ships += state.sea!!.ships.count { it != NO_PLAYER }
knights += state.ck!!.knights.size
if (state.ck!!.attacked) barbarianAttacks++
}
assertTrue("no ships were built", ships > 0)
assertTrue("no knights were hired", knights > 0)
assertTrue("the barbarians never landed", barbarianAttacks > 0)
}
}
@@ -0,0 +1,244 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
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 scenarios with rules of their own: cloth villages and tribe chips. */
class CatanSeaExtrasTest {
private val json = Json { encodeDefaults = true }
private fun start(scenario: SeaScenario, seed: Long = 5L, players: Int = 3): CatanState {
val options = CatanOptions(
playerCount = players,
pointsToWin = 14,
expansions = setOf(CatanExpansion.SEAFARERS),
scenario = scenario,
)
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(options),
seed = seed,
)
return CatanRules.initialState(config).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
}
private fun dice(total: Int): List<Int> = if (total <= 7) listOf(1, total - 1) else listOf(total - 6, 6)
/** A cloth game with seat 0 and seat 1 both settled beside the first village, the robber elsewhere. */
private fun clothGame(): Pair<CatanState, Int> {
val s = start(SeaScenario.CLOTH_FOR_CATAN)
val village = s.terrains.indexOf(Terrain.VILLAGE)
val corners = s.topo.HEX_CORNERS[village]
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
val staged = s.copy(
robber = elsewhere,
buildings = s.buildings.toMutableList().also {
it[corners[0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
it[corners[3]] = CatanBuilding(BuildingType.CITY, 1)
},
)
return staged to village
}
private fun roll(s: CatanState, number: Int) = CatanRules.applyMove(s, s.turn, RollDice(dice(number)))
@Test
fun `a village pays one cloth to every settlement beside it, a city no more`() {
val (s, village) = clothGame()
val number = s.numbers[village]
assertTrue(number in 2..12 && number != 7)
val after = roll(s, number)
assertEquals(listOf(1, 1, 0), after.sea!!.cloth)
assertEquals(SeaBoards.VILLAGE_CLOTH - 2, after.sea!!.villageCloth[village])
assertTrue(after.log.any { "village" in it && "cloth" in it })
}
@Test
fun `an empty village pays nothing and the last token goes to the seat nearest the roller`() {
val (s, village) = clothGame()
val low = s.copy(sea = s.sea!!.copy(villageCloth = s.sea!!.villageCloth.toMutableList().also { it[village] = 1 }))
val after = roll(low.copy(turn = 1), low.numbers[village])
assertEquals(listOf(0, 1, 0), after.sea!!.cloth)
assertEquals(0, after.sea!!.villageCloth[village])
val again = roll(after.copy(phase = CatanPhase.AWAIT_ROLL), after.numbers[village])
assertEquals(listOf(0, 1, 0), again.sea!!.cloth)
}
@Test
fun `the robber on a village stops it`() {
val (s, village) = clothGame()
val after = roll(s.copy(robber = village), s.numbers[village])
assertEquals(listOf(0, 0, 0), after.sea!!.cloth)
}
@Test
fun `every two cloth are a victory point, and the whole thing survives the wire format`() {
val (s, _) = clothGame()
val rich = s.copy(sea = s.sea!!.copy(cloth = listOf(5, 1, 0)))
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 2, CatanRules.publicVictoryPoints(rich)[0])
assertEquals(CatanRules.publicVictoryPoints(s)[1], CatanRules.publicVictoryPoints(rich)[1])
assertEquals(rich, CatanRules.decodeState(CatanRules.encodeState(rich)))
}
@Test
fun `villages pay cloth with Cities and Knights too`() {
val options = CatanOptions(
playerCount = 3,
pointsToWin = 14,
expansions = setOf(CatanExpansion.SEAFARERS, CatanExpansion.CITIES_AND_KNIGHTS),
scenario = SeaScenario.CLOTH_FOR_CATAN,
)
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(options),
seed = 5L,
)
val s = CatanRules.initialState(config)
.copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
val village = s.terrains.indexOf(Terrain.VILLAGE)
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
val staged = s.copy(
robber = elsewhere,
buildings = s.buildings.toMutableList().also {
it[s.topo.HEX_CORNERS[village][0]] = CatanBuilding(BuildingType.SETTLEMENT, 2)
},
)
val after = roll(staged, staged.numbers[village])
assertEquals(1, after.sea!!.cloth[2])
}
// ---- The Forgotten Tribe ----
/** A tribe game, seat 0 with a road reaching a settlement site beside [chipHex], and plenty of cards. */
private fun tribeGame(chip: TribeChip): Triple<CatanState, Int, Int> {
val s = start(SeaScenario.FORGOTTEN_TRIBE)
val hex = s.sea!!.chips.indexOfFirst { it != TribeChip.NONE }
val chips = s.sea!!.chips.toMutableList().also { it[hex] = chip }
val topo = s.topo
val vertex = topo.HEX_CORNERS[hex].first { v -> topo.VERTEX_NEIGHBORS[v].all { s.buildings[it] == null } }
val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeLand[it] }
val staged = s.copy(
phase = CatanPhase.PLAYING,
roads = s.roads.toMutableList().also { it[edge] = 0 },
resources = List(3) { Resource.entries.associateWith { 4 } },
sea = s.sea!!.copy(chips = chips),
)
return Triple(staged, hex, vertex)
}
@Test
fun `the first settler beside a point chip takes the point`() {
val (s, hex, vertex) = tribeGame(TribeChip.POINT)
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(1, after.sea!!.chipPoints[0])
assertEquals(TribeChip.NONE, after.sea!!.chips[hex])
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 1 + 1 + 2, CatanRules.publicVictoryPoints(after)[0])
}
@Test
fun `a harbour chip trades three to one anywhere`() {
val (s, _, vertex) = tribeGame(TribeChip.HARBOUR)
assertEquals(4, CatanRules.bestPortRatio(s, 0, Resource.WOOL))
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(listOf(0), after.sea!!.harbourSeats)
assertEquals(3, CatanRules.bestPortRatio(after, 0, Resource.WOOL))
assertEquals(4, CatanRules.bestPortRatio(after, 1, Resource.WOOL))
assertTrue(CatanRules.legalMoves(after, 0).any { it is MaritimeTrade })
}
@Test
fun `a gold chip asks for two resources of the settler's choice`() {
val (s, _, vertex) = tribeGame(TribeChip.GOLD)
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(CatanPhase.GOLD, after.phase)
assertEquals(listOf(GoldOwed(0, 2)), after.sea!!.gold)
val done = CatanRules.applyMove(after, 0, CatanRules.legalMoves(after, 0).first())
assertEquals(CatanPhase.PLAYING, done.phase)
}
// ---- Pirate Islands ----
private fun fortGame(): Triple<CatanState, Int, Int> {
val s = start(SeaScenario.PIRATE_ISLANDS).copy(
phase = CatanPhase.PLAYING,
resources = List(3) { Resource.entries.associateWith { 6 } },
)
val fort = s.terrains.indexOf(Terrain.FORTRESS)
val edge = (0 until s.topo.EDGE_COUNT).first { s.topo.edgeSea[it] && fort in s.topo.EDGE_HEXES[it] }
return Triple(s, fort, edge)
}
@Test
fun `nobody settles a fortress island until the fortress falls`() {
val (s, fort, _) = fortGame()
val corner = s.topo.HEX_CORNERS[fort].first()
assertTrue(SeaRules.fortressHolds(s, corner))
assertTrue(!CatanRules.vertexOpenForSettlement(s, corner))
val fort2 = s.terrains.indices.first { s.terrains[it] == Terrain.FORTRESS && it != fort }
assertTrue(s.topo.islandOf[fort] != s.topo.islandOf[fort2])
}
@Test
fun `a raid needs a ship beside the fortress and three cards, and three raids take it`() {
val (s, fort, edge) = fortGame()
val coord = s.topo.HEXES[fort]
assertTrue(CatanRules.legalMoves(s, 0).none { it is RaidFortress })
var state = s.copy(sea = s.sea!!.copy(ships = s.sea!!.ships.toMutableList().also { it[edge] = 0 }))
assertTrue(RaidFortress(coord) in CatanRules.legalMoves(state, 0))
val before = state.resources[0].getValue(Resource.ORE)
repeat(3) { state = CatanRules.applyMove(state, 0, RaidFortress(coord)) }
assertEquals(before - 3, state.resources[0].getValue(Resource.ORE))
assertEquals(Terrain.DESERT, state.terrains[fort])
assertEquals(2, state.sea!!.raidPoints[0])
assertTrue(!SeaRules.fortressHolds(state, s.topo.HEX_CORNERS[fort].first()))
assertTrue(CatanRules.legalMoves(state, 0).none { it is RaidFortress })
// Seat 1 has no ship there and cannot raid.
assertTrue(CatanRules.legalMoves(state.copy(turn = 1), 1).none { it is RaidFortress })
}
// ---- Wonders of Catan ----
private fun wonderGame(city: Boolean = true): CatanState {
val s = start(SeaScenario.WONDERS_OF_CATAN).copy(
phase = CatanPhase.PLAYING,
resources = List(3) { Resource.entries.associateWith { 9 } },
)
return if (!city) s else s.copy(
buildings = s.buildings.toMutableList().also { it[0] = CatanBuilding(BuildingType.CITY, 0) },
)
}
@Test
fun `a wonder needs a city, each stage is a point, and the fourth stage wins`() {
assertTrue(CatanRules.legalMoves(wonderGame(city = false), 0).none { it is BuildWonder })
var s = wonderGame()
val base = CatanRules.publicVictoryPoints(s)[0]
for (stage in 1..3) {
assertTrue(BuildWonder in CatanRules.legalMoves(s, 0))
s = CatanRules.applyMove(s, 0, BuildWonder)
assertEquals(stage, s.sea!!.wonders[0])
assertEquals(base + stage, CatanRules.publicVictoryPoints(s)[0])
assertEquals(CatanPhase.PLAYING, s.phase)
}
s = CatanRules.applyMove(s, 0, BuildWonder)
assertEquals(CatanPhase.GAME_OVER, s.phase)
assertEquals(0, s.winner)
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
}
@Test
fun `a wonder stage is not offered without the cards`() {
val s = wonderGame().copy(resources = List(3) { Resource.entries.associateWith { 0 } })
assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildWonder })
}
}
@@ -43,6 +43,10 @@ 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(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))
assertEquals(listOf(2, 3, 5, 17), sizes(SeaScenario.FORGOTTEN_TRIBE))
}
@Test
@@ -55,10 +59,13 @@ 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)
val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG
val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG ||
s.terrains[h] == Terrain.FORTRESS
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
}
assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS)
val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN &&
scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_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 }
assertEquals(fog.size, s.sea!!.fogDeck.size)
@@ -208,6 +215,10 @@ class CatanSeaScenariosTest {
@Test
fun `engines finish a game on every map at three and four players`() {
var fogFound = 0
var clothPaid = 0
var chipsTaken = 0
var fortsTaken = 0
var wonderStages = 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)
@@ -225,9 +236,19 @@ class CatanSeaScenariosTest {
}
assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase)
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.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum()
if (scenario == SeaScenario.FORGOTTEN_TRIBE) {
chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE }
}
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
}
assertTrue("no engine ever uncovered a fog hex", fogFound > 0)
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 wonder stage was ever built", wonderStages > 0)
}
}
@@ -72,8 +72,11 @@ class CatanCkTextTest {
assertEquals(8, short.pointsToWin)
assertEquals(3, ck.playerCount)
// Expansions cannot be combined yet: switching one on switches the other off.
val sea = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true)
assertEquals(setOf(CatanExpansion.SEAFARERS), sea.expansions)
// Expansions stack: both on has its own menu, and turning one off leaves the other.
val both = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true)
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), both.expansions)
assertEquals(listOf(10, 12, 14, 16), catanPointsChoices(both.expansions))
assertEquals(14, both.pointsToWin)
assertEquals(setOf(CatanExpansion.SEAFARERS), catanToggleExpansion(both, CatanExpansion.CITIES_AND_KNIGHTS, false).expansions)
}
}
+7
View File
@@ -220,3 +220,10 @@ game in `CatanCkSelfPlayTest` ends in a win at thirteen points.
- No animation for the event die, the barbarian landing or a knight's move.
- The Commercial Harbor offers a single resource type (see above).
- The AI is not a strong opponent and makes no claim to be.
## On a Seafarers map
Cities & Knights can be played on a Seafarers map; the rules for how the two meet are in
[RULES-catan-seafarers.md](RULES-catan-seafarers.md#with-cities--knights). The code reads the board from
`state.topo` throughout, so nothing here assumes the 19-hex island.
+79 -4
View File
@@ -4,7 +4,7 @@ Seafarers is an expansion switched on at setup (`CatanOptions.expansions`; the o
game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to
settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map),
`CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea`
(null in the other rule sets). It cannot yet be combined with Cities & Knights; the setup screen switches one off when the other is turned on.
(null in the other rule sets). It can be combined with Cities & Knights; see *With Cities & Knights* below.
Where the published rules leave room, or where this game differs, it says so below.
@@ -27,6 +27,15 @@ Pick the map at setup; each scenario names where the starting settlements may go
* **The Fog Islands.** A 13-hex home island (one desert), a two-hex islet and a 14-hex bank of fog.
See *Fog* below.
* **Cloth for Catan.** A 17-hex main island, an eastern isle, two islets, and four **villages** on the coast.
See *Cloth* below.
* **The Forgotten Tribe.** A 17-hex home island and three isles carrying four tribe chips. See *The Forgotten
Tribe* below.
* **Pirate Islands.** An 18-hex home island and two isles (five and four hexes) each held by a pirate
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.
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,
@@ -85,16 +94,82 @@ pile is hidden from every seat but the host's, like the development deck.
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.
## Cloth
The published scenario puts cloth villages between the islands and pays cloth when a village's number is rolled.
This implementation is the author's reading of that, not a transcription of the printed rules:
* A **village** is a land hex with a number token and a stock of cloth (6). It produces no cards and nothing may
be built on it, but its corners can hold settlements like any other hex's.
* When its number rolls, the village pays **one cloth token** to each settlement or city beside it (a city is
worth no more than a settlement here), until the stock is gone. If the stock is short, the seats nearest the
roller, in turn order, take what is left. The robber on a village stops it; the pirate does not.
* **Two cloth are a victory point** (an odd token is worth nothing alone). Tokens are scored, not spent, and not
tradeable.
* Works with Cities & Knights: the village pays after the dice, before the aqueduct and gold.
## The Forgotten Tribe
Also the author's reading. A chip lies face up on four hexes of the small isles (the hexes are marked on the
board). The chips are dealt at the start of the game, one of each kind in turn:
* **The first settlement** built on a crossing beside a chip hex takes that chip; it is gone for everyone else.
(The setup settlements are never beside one, since the home island is the only start.)
* A **point** chip is one victory point. A **harbour** chip lets that seat trade 3:1 at the bank, wherever it is.
A **gold** chip pays two resources of the settler's choice, taken at once like gold.
* The chips come on top of the island bonus for being first to a new island.
## Pirate Islands
The author's reading of the scenario; the printed one uses knights and a roaming pirate fleet, which this does not.
* A **fortress** is a land hex with no number and a strength of 3. Nobody may build a settlement on any crossing of
an island that still has a standing fortress. Roads and ships may go there.
* A seat with a **ship beside** a fortress may **raid** it in its turn for 1 ore, 1 wool and 1 grain, as many times as
it can pay. Each raid takes one point of strength.
* The raid that takes the last point **takes the fortress**: the hex becomes desert (no number, robber may sit there),
the island is open to settle for everyone, and the taker gets **2 victory points**. The robber never sits on a
standing fortress.
* The island still counts as a new island for the first-landing bonus.
## Wonders
Also the author's reading. Every seat may build one wonder in four stages, once it has a **city** anywhere on the
board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain; 2: 1 brick, 2 ore, 1 wool; 3: 3 ore,
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.
## With Cities & Knights
Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map
(`CatanState.ck` and `CatanState.sea` both set; the target menu is 10, 12, 14 or 16, default 14).
Where the two meet, this game decides as follows:
* **Ships** cost 1 lumber and 1 wool as before and are built and sailed in a turn like a road, including
with the Road Building progress card (roads and ships, as in Seafarers).
* **Knights** stand on land crossings at the end of one of your roads *or ships*, and walk along that same
network. A crossing that touches only sea is never a knight post.
* **The pirate** sleeps with the robber: only after the first barbarian attack does a seven let the player
move the robber (to land) *or* the pirate (to sea). The pirate robs one good, resource or commodity, from a
seat with a ship beside it. The bishop moves the robber only.
* **Gold** pays after the roll and before the aqueduct and any other chore: a settlement beside it chooses one
resource, a city two. (A city on gold takes two resources, not a resource and a commodity.)
* **Scoring.** First-landing bonus points are added to a seat's points like any other; the barbarians count
cities wherever they stand, island or not.
* **Merchant** goes only on a land hex; **Fog** works as in Seafarers (a find pays a resource card).
## 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 and Cities & Knights combinations,
the scenarios that need new rules (cloth, wonders, the forgotten tribe, pirate islands) and trade ships.
treasure islands, the oasis, the great canal, the Explorers & Pirates combination,
the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships.
A map-only scenario is a new text map and a new `SeaScenario` entry.
## How the AI plays it
It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its
open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that
bonus is in play. It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
bonus is in play. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is
worth more (`CatanAi.scenarioLure`), a ship that heads for a standing fortress is worth building, and it raids and builds
wonder stages whenever it can pay (`CatanAi.FORT_LURE`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
not sail ships it has already launched. See `CatanAi.shipValue`.