Merge pull request 'Katan: Cities & Knights on Seafarers maps (phase 4)' (#36) from feature/catan-seafarers-ck into dev
This commit was merged in pull request #36.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user