Compare commits

...
2 Commits
Author SHA1 Message Date
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
14 changed files with 298 additions and 59 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) {
@@ -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,10 @@ 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)
}
if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) {
moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) }
}
@@ -380,7 +392,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 +435,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 +474,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 +524,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 +559,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 +588,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 +625,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 +1529,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 }
@@ -1546,7 +1575,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,12 @@ internal class CatanCkAi(
settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.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 +234,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 +373,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
}
@@ -192,8 +192,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
@@ -601,7 +601,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
/** 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 +708,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 = ""
@@ -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> {
@@ -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 {
@@ -198,7 +198,7 @@ fun CatanScreen(
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions(
buildKinds = when {
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP || current.isSea }
current.isSea -> BuildKind.SEAFARERS
else -> BuildKind.BASE
},
@@ -194,7 +194,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
@@ -401,6 +401,7 @@ object CatanText {
building: BuildKind?,
movingKnight: Boolean,
armed: ProgressType?,
movingShip: Boolean,
): String {
val me = seats.me
val onClock = CatanRules.currentSeat(state)
@@ -409,7 +410,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 +441,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 +449,8 @@ 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.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)
}
}
@@ -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.
+21 -2
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.
@@ -85,10 +85,29 @@ 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.
## 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,
treasure islands, the oasis, the great canal, the Explorers & Pirates combination,
the scenarios that need new rules (cloth, wonders, the forgotten tribe, pirate islands) and trade ships.
A map-only scenario is a new text map and a new `SeaScenario` entry.