Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3c2fe0e9 | ||
|
|
f8750684e6 | ||
|
|
87cf8decaa | ||
|
|
dcf553a8b1 | ||
|
|
5e9bb2feaf | ||
|
|
cc33dffd9a | ||
|
|
6c311ad304 | ||
|
|
f8c04cec88 | ||
|
|
97728daf0a | ||
|
|
a30b1dfcd0 |
@@ -51,14 +51,14 @@ integration:
|
||||
|
||||
## Katan
|
||||
|
||||
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
|
||||
sets chosen at setup: the base game (`docs/RULES-catan.md`), Cities & Knights
|
||||
The island game is catalogue id `catan`, shown as **Katan**. It has two expansions
|
||||
(toggles, `CatanOptions.expansions`; none on is the base game): the base game (`docs/RULES-catan.md`), Cities & Knights
|
||||
(`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) {
|
||||
@@ -644,9 +644,11 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
val next = mutableListOf<Int>()
|
||||
for (v in frontier) {
|
||||
if (!seen.add(v)) continue
|
||||
// Fog is worth a look: a card on arrival, and land to settle that nobody has seen.
|
||||
if (topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) best = maxOf(best, FOG_LURE * Math.pow(0.85, depth.toDouble()))
|
||||
if (CatanRules.vertexOpenForSettlement(state, v)) {
|
||||
val island = topo.vertexIsland[v]
|
||||
val fresh = island >= 0 && island != sea.home && sea.islandBonus[island] == NO_PLAYER
|
||||
val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER
|
||||
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
|
||||
best = maxOf(best, value)
|
||||
}
|
||||
@@ -673,5 +675,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
private companion object {
|
||||
/** A seven makes a seat holding more than this discard half. */
|
||||
const val DISCARD_LIMIT = 7
|
||||
|
||||
/** What a ship end beside undiscovered land is worth, on the scale of [spotValue]. */
|
||||
const val FOG_LURE = 0.6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
|
||||
const val NO_PLAYER = -1
|
||||
@@ -45,6 +54,12 @@ enum class Terrain(val produces: Resource?) {
|
||||
|
||||
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
|
||||
GOLD(null),
|
||||
|
||||
/**
|
||||
* Seafarers: land not yet found. It becomes a resource or gold when a road or ship first reaches it
|
||||
* (`SeaState.fogDeck`). Until then nothing is built on it, nothing produces, and the robber keeps off.
|
||||
*/
|
||||
FOG(null),
|
||||
}
|
||||
|
||||
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||
@@ -151,12 +166,9 @@ enum class CatanPhase {
|
||||
CHORE,
|
||||
}
|
||||
|
||||
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
|
||||
/** An expansion a table can switch on. The base game is what is left with none selected. */
|
||||
@Serializable
|
||||
enum class CatanRuleSet {
|
||||
/** The base game: development cards, Largest Army, a robber from the start. */
|
||||
BASE,
|
||||
|
||||
enum class CatanExpansion {
|
||||
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
|
||||
CITIES_AND_KNIGHTS,
|
||||
|
||||
@@ -164,16 +176,57 @@ enum class CatanRuleSet {
|
||||
SEAFARERS,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
/**
|
||||
* What a table plays: the expansions switched on, and the Seafarers map when that one is.
|
||||
*
|
||||
* Saves written before expansions became toggles carry `"ruleSet"` instead of `"expansions"`;
|
||||
* [CatanOptionsSerializer] reads either.
|
||||
*/
|
||||
@Serializable(with = CatanOptionsSerializer::class)
|
||||
data class CatanOptions(
|
||||
val playerCount: Int = 3,
|
||||
val pointsToWin: Int = 10,
|
||||
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
|
||||
val expansions: Set<CatanExpansion> = emptySet(),
|
||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
|
||||
}
|
||||
|
||||
val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions
|
||||
val citiesAndKnights: Boolean get() = CatanExpansion.CITIES_AND_KNIGHTS in expansions
|
||||
}
|
||||
|
||||
/** The wire shape of [CatanOptions]. */
|
||||
@Serializable
|
||||
private class CatanOptionsWire(
|
||||
val playerCount: Int = 3,
|
||||
val pointsToWin: Int = 10,
|
||||
val expansions: Set<CatanExpansion> = emptySet(),
|
||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||
)
|
||||
|
||||
/** Reads the legacy single `ruleSet` field as an expansion set. */
|
||||
object CatanOptionsSerializer : KSerializer<CatanOptions> {
|
||||
override val descriptor: SerialDescriptor = CatanOptionsWire.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
|
||||
CatanOptionsWire.serializer(),
|
||||
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario),
|
||||
)
|
||||
|
||||
override fun deserialize(decoder: Decoder): CatanOptions {
|
||||
val input = decoder as JsonDecoder
|
||||
var obj = input.decodeJsonElement().jsonObject
|
||||
val legacy = (obj["ruleSet"] as? JsonPrimitive)?.content
|
||||
if (legacy != null) {
|
||||
val expansions = if (legacy == "BASE") emptyList() else listOf(JsonPrimitive(legacy))
|
||||
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
|
||||
}
|
||||
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
|
||||
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,6 +251,8 @@ data class CatanState(
|
||||
val turn: Int,
|
||||
/** How many setup placements are done, 0 until 2 * playerCount. */
|
||||
val setupIndex: Int,
|
||||
/** The seat that places first and takes the first turn; drawn from the table seed. */
|
||||
val firstSeat: Int = 0,
|
||||
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
|
||||
val turnNumber: Int,
|
||||
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
|
||||
@@ -414,12 +469,28 @@ data class SeaState(
|
||||
val shipMoved: Boolean = false,
|
||||
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
|
||||
val islandBonus: List<Int>,
|
||||
/** The island the players start on. */
|
||||
/** The island that counts as home: the largest, and the pirate-free start of the original map. */
|
||||
val home: Int,
|
||||
/** The islands the starting settlements may go on. Empty in a save from before there were several: just [home]. */
|
||||
val starts: List<Int> = emptyList(),
|
||||
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
|
||||
val gold: List<GoldOwed> = emptyList(),
|
||||
/**
|
||||
* The tiles still face down, next to be found first. Hidden from every view but the host's, like the
|
||||
* development deck.
|
||||
*/
|
||||
val fogDeck: List<FogTile> = emptyList(),
|
||||
/** Where the game goes when the gold queue empties: [CatanPhase.PLAYING] unless a find interrupted something else. */
|
||||
val afterGold: CatanPhase? = null,
|
||||
)
|
||||
|
||||
/** One tile of the fog: what the hex turns out to be and the number token that comes with it. */
|
||||
@Serializable
|
||||
data class FogTile(val terrain: Terrain, val number: Int)
|
||||
|
||||
/** Whether [island] is one the game starts on, and so never earns the new-island bonus. */
|
||||
fun SeaState.isStart(island: Int): Boolean = island == home || island in starts
|
||||
|
||||
@Serializable
|
||||
data class GoldOwed(val seat: Int, val count: Int)
|
||||
|
||||
|
||||
@@ -54,15 +54,15 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
|
||||
val sea = options.ruleSet == CatanRuleSet.SEAFARERS
|
||||
val scenario = SeaScenario.NEW_SHORES
|
||||
val sea = options.seafarers
|
||||
val scenario = options.scenario
|
||||
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
|
||||
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
|
||||
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
|
||||
?: CatanBoard.generate(config.seed)
|
||||
// A different random stream than the island, so the same seed still
|
||||
// produces both a fixed island and a fixed deck.
|
||||
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
|
||||
val ck = options.citiesAndKnights
|
||||
// Cities & Knights has no development cards: knights and progress
|
||||
// cards take their place.
|
||||
val devDeck = if (ck) {
|
||||
@@ -73,12 +73,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
.shuffled(Random(config.seed + 1))
|
||||
}
|
||||
|
||||
// Seat 0 used to lead every game; a separate stream from the board and
|
||||
// the deck keeps the same seed giving the same table.
|
||||
val firstSeat = Random(config.seed + 2).nextInt(options.playerCount)
|
||||
|
||||
return CatanState(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
phase = CatanPhase.SETUP_FIRST,
|
||||
turn = 0,
|
||||
turn = firstSeat,
|
||||
setupIndex = 0,
|
||||
firstSeat = firstSeat,
|
||||
turnNumber = 0,
|
||||
terrains = board.terrains,
|
||||
numbers = board.numbers,
|
||||
@@ -114,6 +119,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
pirate = it.pirate,
|
||||
islandBonus = List(topo.islandCount) { NO_PLAYER },
|
||||
home = it.home,
|
||||
starts = it.starts,
|
||||
fogDeck = it.fogDeck,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -121,8 +128,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
|
||||
override fun currentSeat(state: CatanState): Int? = when (state.phase) {
|
||||
CatanPhase.GAME_OVER -> null
|
||||
CatanPhase.SETUP_FIRST -> state.setupIndex
|
||||
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
|
||||
CatanPhase.SETUP_FIRST -> (state.firstSeat + state.setupIndex) % state.playerCount
|
||||
CatanPhase.SETUP_SECOND ->
|
||||
(state.firstSeat + 2 * state.playerCount - 1 - state.setupIndex) % state.playerCount
|
||||
CatanPhase.DISCARDING -> state.discardSeat
|
||||
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
|
||||
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
|
||||
@@ -201,8 +209,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val moves = mutableListOf<SetupPlace>()
|
||||
for (v in 0 until state.topo.VERTEX_COUNT) {
|
||||
if (!vertexOpenForSettlement(state, v)) continue
|
||||
// Seafarers: everyone starts on the home island.
|
||||
if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue
|
||||
// Seafarers: the starting settlements go on a starting island.
|
||||
if (state.sea != null && !state.sea.isStart(state.topo.vertexIsland[v])) continue
|
||||
// Nobody starts beside land that has not been found.
|
||||
if (state.topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) continue
|
||||
for (e in state.topo.VERTEX_EDGES[v]) {
|
||||
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
|
||||
moves += SetupPlace(v, e)
|
||||
@@ -238,7 +248,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
|
||||
val moves = mutableListOf<MoveRobber>()
|
||||
for (h in 0 until state.topo.HEX_COUNT) {
|
||||
if (h == state.robber || !state.topo.land[h]) continue
|
||||
if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG) continue
|
||||
val victims = state.topo.HEX_CORNERS[h]
|
||||
.mapNotNull { state.buildings[it] }
|
||||
.map { it.owner }
|
||||
@@ -512,9 +522,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
return when {
|
||||
newIndex >= 2 * n -> s.copy(
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
turn = 0,
|
||||
turn = state.firstSeat,
|
||||
turnNumber = 1,
|
||||
log = s.log + "Setup done — seat 0 leads.",
|
||||
log = s.log + "Setup done — seat ${state.firstSeat} leads.",
|
||||
)
|
||||
|
||||
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
|
||||
@@ -591,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) {
|
||||
@@ -654,7 +664,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
s = s.copy(pendingFreeRoads = left)
|
||||
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
|
||||
}
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
|
||||
s = s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")
|
||||
if (state.sea != null) s = SeaRules.reveal(s, seat, move.edge)
|
||||
return checkWin(s)
|
||||
}
|
||||
|
||||
private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState {
|
||||
@@ -673,7 +685,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
s = s.copy(pendingFreeRoads = left)
|
||||
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
|
||||
}
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."))
|
||||
return checkWin(SeaRules.reveal(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."), seat, move.edge))
|
||||
}
|
||||
|
||||
private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState {
|
||||
@@ -686,12 +698,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val s = state.copy(
|
||||
sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to),
|
||||
)
|
||||
return checkWin(refreshLongestRoad(s).let {
|
||||
it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.")
|
||||
})
|
||||
return checkWin(
|
||||
SeaRules.reveal(
|
||||
refreshLongestRoad(s).let { it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.") },
|
||||
seat,
|
||||
move.to,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyMovePirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
|
||||
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 = ""
|
||||
@@ -1129,7 +1146,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
cards.groupingBy { it }.eachCount()
|
||||
|
||||
/** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */
|
||||
private fun withCounts(
|
||||
internal fun withCounts(
|
||||
resources: List<Map<Resource, Int>>,
|
||||
seat: Int,
|
||||
delta: Map<Resource, Int>,
|
||||
@@ -1198,6 +1215,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
state.copy(
|
||||
// Hidden information stays with the holder and the host.
|
||||
devDeck = emptyList(),
|
||||
sea = state.sea?.copy(fogDeck = emptyList()),
|
||||
devHands = state.devHands.mapIndexed { index, hand ->
|
||||
if (index == seat) hand else emptyList()
|
||||
},
|
||||
|
||||
@@ -5,11 +5,34 @@ import kotlin.random.Random
|
||||
|
||||
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
|
||||
@Serializable
|
||||
enum class SeaScenario(val title: String, val blurb: String) {
|
||||
enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart = SeaStart.HOME_ONLY) {
|
||||
NEW_SHORES(
|
||||
"Heading for New Shores",
|
||||
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
|
||||
),
|
||||
FOUR_ISLANDS(
|
||||
"Four Islands",
|
||||
"Four equal islands, one start each, and two gold islets in the middle. Settle your own island, then sail.",
|
||||
SeaStart.MAIN_ISLANDS,
|
||||
),
|
||||
THROUGH_THE_DESERT(
|
||||
"Through the Desert",
|
||||
"One island cut by a broad desert that yields nothing. Reach the far side by road, or sail round to the islets.",
|
||||
),
|
||||
FOG_ISLANDS(
|
||||
"The Fog Islands",
|
||||
"A home island, an islet, and a bank of fog. Land in the fog is face down until a road or ship reaches it; " +
|
||||
"the finder takes a card of what lies there.",
|
||||
),
|
||||
}
|
||||
|
||||
/** Where the starting settlements may go. */
|
||||
enum class SeaStart {
|
||||
/** The largest island only. */
|
||||
HOME_ONLY,
|
||||
|
||||
/** Any island big enough to live on (four hexes or more); the small ones are prizes to sail to. */
|
||||
MAIN_ISLANDS,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +56,47 @@ object SeaBoards {
|
||||
"..........",
|
||||
)
|
||||
|
||||
private val FOUR_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
"..###..###..",
|
||||
"..###..###..",
|
||||
"............",
|
||||
"..$.....$...",
|
||||
"............",
|
||||
"..###..###..",
|
||||
"..###..###..",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val THROUGH_THE_DESERT_MAP = listOf(
|
||||
"............",
|
||||
".###DD###...",
|
||||
".###DDD###..",
|
||||
"..###DD###..",
|
||||
"............",
|
||||
"..$.....$...",
|
||||
"............",
|
||||
"...##...#...",
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `F` is fog: land that is face down until found. */
|
||||
private val FOG_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
".###....FF..",
|
||||
".####..FFF..",
|
||||
"..#D#..FFFF.",
|
||||
".###...FFF..",
|
||||
".......FF...",
|
||||
"...##.......",
|
||||
"............",
|
||||
)
|
||||
|
||||
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
|
||||
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
|
||||
SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP
|
||||
SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP
|
||||
SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP
|
||||
}
|
||||
|
||||
private val topologies = HashMap<SeaScenario, Topology>()
|
||||
@@ -72,8 +134,15 @@ object SeaBoards {
|
||||
val pirate: Int,
|
||||
val ports: List<Port>,
|
||||
val home: Int,
|
||||
/** The islands the starting settlements may go on; always includes [home]. */
|
||||
val starts: List<Int> = listOf(home),
|
||||
/** The tiles under the fog, in the order they will be found. */
|
||||
val fogDeck: List<FogTile> = emptyList(),
|
||||
)
|
||||
|
||||
/** An island of this many land hexes or more can be started on. */
|
||||
private const val MAIN_ISLAND_HEXES = 4
|
||||
|
||||
private val RESOURCE_TILES = listOf(
|
||||
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
|
||||
)
|
||||
@@ -96,10 +165,12 @@ object SeaBoards {
|
||||
'$' -> Terrain.GOLD
|
||||
'D' -> Terrain.DESERT
|
||||
'#' -> pool[plain.indexOf(h)]
|
||||
'F' -> Terrain.FOG
|
||||
else -> Terrain.SEA
|
||||
}
|
||||
}
|
||||
|
||||
// The fog hexes take part as slots, so the tokens they hide are dealt from the same pile.
|
||||
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT }
|
||||
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
|
||||
val tokens = TOKENS.take(numbered.size).shuffled(random)
|
||||
@@ -119,6 +190,14 @@ object SeaBoards {
|
||||
attempt++
|
||||
}
|
||||
|
||||
// What lies under the fog: mostly resources, one gold field in five, drawn from the same tokens.
|
||||
val fogHexes = terrains.indices.filter { terrains[it] == Terrain.FOG }
|
||||
val fogKinds = List(fogHexes.size) { i ->
|
||||
if (i % 5 == 4) Terrain.GOLD else RESOURCE_TILES[i % RESOURCE_TILES.size]
|
||||
}.shuffled(random)
|
||||
val fogDeck = fogHexes.mapIndexed { i, h -> FogTile(fogKinds[i], numbers[h]) }.shuffled(random)
|
||||
fogHexes.forEach { numbers[it] = 0 }
|
||||
|
||||
val portTypes = (
|
||||
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
|
||||
).shuffled(random)
|
||||
@@ -129,7 +208,10 @@ object SeaBoards {
|
||||
|
||||
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
|
||||
?: numbered.first()
|
||||
val home = (0 until topo.islandCount).maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
|
||||
// Home is the largest island that is not under the fog: nobody starts on land they cannot see.
|
||||
val fogged = fogHexes.map { topo.islandOf[it] }.toSet()
|
||||
val home = (0 until topo.islandCount).filter { it !in fogged }
|
||||
.maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
|
||||
// The pirate starts in the open sea: the sea hex farthest from every shore.
|
||||
val sea = terrains.indices.filter { terrains[it] == Terrain.SEA }
|
||||
val pirate = sea.maxByOrNull { h ->
|
||||
@@ -139,6 +221,13 @@ object SeaBoards {
|
||||
} ?: 0
|
||||
} ?: sea.first()
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home)
|
||||
val starts = when (scenario.start) {
|
||||
SeaStart.HOME_ONLY -> listOf(home)
|
||||
SeaStart.MAIN_ISLANDS -> (0 until topo.islandCount).filter { island ->
|
||||
topo.islandOf.count { it == island } >= MAIN_ISLAND_HEXES
|
||||
}.ifEmpty { listOf(home) }
|
||||
}
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
@@ -135,12 +135,60 @@ internal object SeaRules {
|
||||
val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) }
|
||||
val queue = if (supply > 0) sea.gold else emptyList()
|
||||
return if (queue.isEmpty()) {
|
||||
state.copy(sea = sea.copy(gold = emptyList()), phase = CatanPhase.PLAYING)
|
||||
state.copy(sea = sea.copy(gold = emptyList(), afterGold = null), phase = sea.afterGold ?: CatanPhase.PLAYING)
|
||||
} else {
|
||||
state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fog
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Hidden hexes a piece on [edge] reaches: any that touches either end. */
|
||||
private fun fogReached(state: CatanState, edge: Int): List<Int> {
|
||||
val topo = state.topo
|
||||
return topo.EDGE_VERTICES[edge].toList()
|
||||
.flatMap { topo.VERTEX_HEXES[it] }
|
||||
.filter { state.terrains[it] == Terrain.FOG }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
|
||||
/**
|
||||
* After a road or ship lands on [edge]: turn each fog hex it now touches face up. The finder takes one card
|
||||
* of what it yields, or chooses any resource if it is gold, supply permitting. The caller's phase is kept.
|
||||
*/
|
||||
fun reveal(state: CatanState, seat: Int, edge: Int): CatanState {
|
||||
var s = state
|
||||
val found = fogReached(state, edge)
|
||||
if (found.isEmpty()) return s
|
||||
val owed = mutableListOf<GoldOwed>()
|
||||
for (hex in found) {
|
||||
val sea = s.sea!!
|
||||
val tile = sea.fogDeck.firstOrNull() ?: break
|
||||
val name = tile.terrain.name.lowercase()
|
||||
s = s.copy(
|
||||
terrains = s.terrains.toMutableList().also { it[hex] = tile.terrain },
|
||||
numbers = s.numbers.toMutableList().also { it[hex] = tile.number },
|
||||
sea = sea.copy(fogDeck = sea.fogDeck.drop(1)),
|
||||
log = s.log + "Seat $seat uncovers ${if (tile.terrain == Terrain.GOLD) "a gold field" else "the $name"} " +
|
||||
"(${s.topo.HEXES[hex].q},${s.topo.HEXES[hex].r})" +
|
||||
if (tile.number != 0) " with a ${tile.number}." else ".",
|
||||
)
|
||||
val resource = tile.terrain.produces
|
||||
if (resource != null && CatanRules.bankSupply(s, resource) > 0) {
|
||||
s = s.copy(resources = CatanRules.withCounts(s.resources, seat, mapOf(resource to 1), 1))
|
||||
} else if (tile.terrain == Terrain.GOLD) {
|
||||
owed += GoldOwed(seat, 1)
|
||||
}
|
||||
}
|
||||
if (owed.isEmpty()) return s
|
||||
val merged = GoldOwed(seat, owed.sumOf { it.count })
|
||||
val sea = s.sea!!
|
||||
return settleGold(s.copy(sea = sea.copy(gold = sea.gold + merged, afterGold = s.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold)))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Islands and scoring
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -153,7 +201,7 @@ internal object SeaRules {
|
||||
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
|
||||
val sea = state.sea ?: return state
|
||||
val island = state.topo.vertexIsland[vertex]
|
||||
if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state
|
||||
if (island < 0 || sea.isStart(island) || sea.islandBonus[island] != NO_PLAYER) return state
|
||||
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
|
||||
return state.copy(
|
||||
sea = sea.copy(islandBonus = bonus),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
@@ -18,15 +19,13 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* plays the one whose *worst* possible answer leaves the smallest number of
|
||||
* candidates behind, so it cannot be given a reply that tells it little.
|
||||
*
|
||||
* The table already carries its own three-step level ([MastermindOptions.level]),
|
||||
* chosen at setup and shown to the player, and it means exactly what an
|
||||
* [org.prolibertate.games.game.engine.AiLevel] would here. Layering a second
|
||||
* knob on top would make the two disagree, so the shared [profiles] are
|
||||
* accepted for a uniform constructor but this game keeps playing at the level
|
||||
* the table names. [level] overrides that, which is what the tests use.
|
||||
* The computer difficulty chosen at setup picks the level: Easy plays Casual,
|
||||
* Medium plays whatever the table's saved [MastermindOptions.level] says (Club
|
||||
* for a new game) and Hard plays Strong. [level] overrides all of that, which is
|
||||
* what the tests use.
|
||||
*/
|
||||
class MastermindAi(
|
||||
@Suppress("unused") private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val level: MastermindLevel? = null,
|
||||
) : GameAi<MastermindState, MastermindMove> {
|
||||
|
||||
@@ -38,7 +37,12 @@ class MastermindAi(
|
||||
legal: List<MastermindMove>,
|
||||
): MastermindMove {
|
||||
require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
val profile = profiles.of(seat)
|
||||
val setting = level ?: when (profile.level) {
|
||||
AiLevel.EASY -> MastermindLevel.CASUAL
|
||||
AiLevel.MEDIUM -> state.options.level
|
||||
AiLevel.HARD -> MastermindLevel.STRONG
|
||||
}
|
||||
|
||||
// Deterministic for a given position, so a host and a client watching
|
||||
// the same game agree on what the computer did.
|
||||
|
||||
@@ -35,11 +35,8 @@ import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||
import org.prolibertate.games.game.checkers.CheckersLevel
|
||||
import org.prolibertate.games.game.checkers.CheckersOptions
|
||||
import org.prolibertate.games.game.chess.ChessLevel
|
||||
import org.prolibertate.games.game.chess.ChessOptions
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||
@@ -57,15 +54,13 @@ import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||
import org.prolibertate.games.game.morris.MorrisLevel
|
||||
import org.prolibertate.games.game.morris.MorrisOptions
|
||||
import org.prolibertate.games.game.pirates.PiratesLevel
|
||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.SeaScenario
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
import org.prolibertate.games.game.tayu.TayuOptions
|
||||
@@ -379,47 +374,50 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
|
||||
else -> seat
|
||||
}
|
||||
|
||||
/** The targets the setup screen offers for a Katan rule set. */
|
||||
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) {
|
||||
// Five is a short game for trying things out; ten is the published target.
|
||||
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
|
||||
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
|
||||
/** 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.
|
||||
CatanRuleSet.SEAFARERS -> listOf(8, 10, 12, 14)
|
||||
CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14)
|
||||
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> listOf(8, 10, 13, 15)
|
||||
// Five is a short game for trying things out; ten is the published target.
|
||||
else -> listOf(5, 8, 10, 12)
|
||||
}
|
||||
|
||||
/** The published target for a rule set. */
|
||||
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
|
||||
CatanRuleSet.BASE -> 10
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
|
||||
CatanRuleSet.SEAFARERS -> 12
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] switched to [ruleSet]. The target moves to the new rule set's own
|
||||
* [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.
|
||||
*/
|
||||
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions =
|
||||
options.copy(
|
||||
ruleSet = ruleSet,
|
||||
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) }
|
||||
?: catanDefaultPoints(ruleSet),
|
||||
internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions {
|
||||
val expansions = if (on) options.expansions + expansion else options.expansions - expansion
|
||||
return options.copy(
|
||||
expansions = expansions,
|
||||
pointsToWin = options.pointsToWin.takeIf {
|
||||
it in catanPointsChoices(expansions) && it != catanDefaultPoints(options.expansions)
|
||||
} ?: catanDefaultPoints(expansions),
|
||||
)
|
||||
|
||||
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||
CatanRuleSet.BASE -> "Base game"
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||
CatanRuleSet.SEAFARERS -> "Seafarers"
|
||||
}
|
||||
|
||||
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||
CatanRuleSet.BASE ->
|
||||
"Settle the island, trade, and play development cards. Longest Road and Largest Army score."
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS ->
|
||||
private fun catanExpansionName(expansion: CatanExpansion): String = when (expansion) {
|
||||
CatanExpansion.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||
CatanExpansion.SEAFARERS -> "Seafarers"
|
||||
}
|
||||
|
||||
private fun catanExpansionBlurb(expansion: CatanExpansion): String = when (expansion) {
|
||||
CatanExpansion.CITIES_AND_KNIGHTS ->
|
||||
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
|
||||
"cards and metropolises. No development cards; the robber sleeps until the first attack."
|
||||
CatanRuleSet.SEAFARERS ->
|
||||
CatanExpansion.SEAFARERS ->
|
||||
"Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " +
|
||||
"and the first settlement on each new island scores two extra points."
|
||||
}
|
||||
@@ -427,14 +425,24 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||
@Composable
|
||||
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Rules",
|
||||
values = CatanRuleSet.entries,
|
||||
selected = options.ruleSet,
|
||||
display = ::catanRuleSetName,
|
||||
onSelect = { onChange(catanSwitchRules(options, it)) },
|
||||
)
|
||||
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall)
|
||||
CatanExpansion.entries.forEach { expansion ->
|
||||
ToggleRow(
|
||||
title = catanExpansionName(expansion),
|
||||
subtitle = catanExpansionBlurb(expansion),
|
||||
checked = expansion in options.expansions,
|
||||
onChange = { onChange(catanToggleExpansion(options, expansion, it)) },
|
||||
)
|
||||
}
|
||||
if (options.seafarers) {
|
||||
ChipRow(
|
||||
label = "Map",
|
||||
values = SeaScenario.entries,
|
||||
selected = options.scenario,
|
||||
display = { it.title },
|
||||
onSelect = { onChange(options.copy(scenario = it)) },
|
||||
)
|
||||
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(3, 4),
|
||||
@@ -444,7 +452,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
|
||||
)
|
||||
ChipRow(
|
||||
label = "Game to",
|
||||
values = catanPointsChoices(options.ruleSet),
|
||||
values = catanPointsChoices(options.expansions),
|
||||
selected = options.pointsToWin,
|
||||
display = { "$it points" },
|
||||
onSelect = { onChange(options.copy(pointsToWin = it)) },
|
||||
@@ -785,13 +793,6 @@ private fun ChessOptionsEditor(
|
||||
display = { if (it) "White" else "Black" },
|
||||
onSelect = onColour,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = ChessLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Fifty-move rule",
|
||||
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
|
||||
@@ -815,13 +816,6 @@ private fun ChessOptionsEditor(
|
||||
@Composable
|
||||
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = CheckersLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Flying kings",
|
||||
subtitle = "A king slides the length of a diagonal and takes from a distance, " +
|
||||
@@ -856,13 +850,6 @@ private fun BackgammonOptionsEditor(
|
||||
onChange: (BackgammonOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = BackgammonLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Count gammons",
|
||||
subtitle = "A loser who bore nothing off has lost double, and one still on the " +
|
||||
@@ -884,13 +871,6 @@ private fun MastermindOptionsEditor(
|
||||
onChange: (MastermindOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MastermindLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Colours",
|
||||
values = listOf(6, 8),
|
||||
@@ -955,13 +935,6 @@ private fun PiratesOptionsEditor(
|
||||
display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
|
||||
onSelect = onSide,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = PiratesLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "A pirate who can take must take",
|
||||
subtitle = "The rule that huffing exists to enforce, applied directly",
|
||||
@@ -987,13 +960,6 @@ private fun PiratesOptionsEditor(
|
||||
@Composable
|
||||
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MorrisLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Pieces each",
|
||||
values = listOf(3, 6, 9),
|
||||
|
||||
@@ -156,6 +156,16 @@ private fun DrawScope.drawHexes(
|
||||
|
||||
val number = state.numbers[hex]
|
||||
if (number != 0) drawToken(number, centre, board, style, measurer)
|
||||
if (state.terrains[hex] == Terrain.FOG) {
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = "?",
|
||||
at = centre,
|
||||
fontPx = board.radius * 0.9f,
|
||||
color = style.hexEdge.copy(alpha = 0.7f),
|
||||
weight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -124,6 +124,8 @@ private val LightStyle = CatanStyle(
|
||||
// Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow.
|
||||
Terrain.SEA to Color(0xFF3A7CA5),
|
||||
Terrain.GOLD to Color(0xFFF2B01E),
|
||||
// Land not yet found: a pale grey bank of fog with a question mark.
|
||||
Terrain.FOG to Color(0xFFB9C2C9),
|
||||
),
|
||||
hexEdge = Color(0x55000000),
|
||||
token = Color(0xFFF7EFD9),
|
||||
|
||||
@@ -74,6 +74,7 @@ object CatanText {
|
||||
Terrain.DESERT -> "Desert"
|
||||
Terrain.SEA -> "Sea"
|
||||
Terrain.GOLD -> "Gold field"
|
||||
Terrain.FOG -> "Fog"
|
||||
}
|
||||
|
||||
fun devCard(type: DevCardType): String = when (type) {
|
||||
@@ -193,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
|
||||
@@ -400,6 +401,7 @@ object CatanText {
|
||||
building: BuildKind?,
|
||||
movingKnight: Boolean,
|
||||
armed: ProgressType?,
|
||||
movingShip: Boolean,
|
||||
): String {
|
||||
val me = seats.me
|
||||
val onClock = CatanRules.currentSeat(state)
|
||||
@@ -408,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
|
||||
@@ -434,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."
|
||||
}
|
||||
|
||||
@@ -441,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."
|
||||
|
||||
@@ -55,15 +55,15 @@ class CatanAiProfilesTest {
|
||||
(0 until 3).any { p.of(it).style == style }
|
||||
}
|
||||
|
||||
private fun forEveryProfile(rules: CatanRuleSet, cap: Int) {
|
||||
private fun forEveryProfile(rules: Set<CatanExpansion>, cap: Int) {
|
||||
val options = CatanOptions(
|
||||
playerCount = 3,
|
||||
pointsToWin = when (rules) {
|
||||
CatanRuleSet.BASE -> 10
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
|
||||
else -> 12
|
||||
pointsToWin = when {
|
||||
CatanExpansion.CITIES_AND_KNIGHTS in rules -> 13
|
||||
CatanExpansion.SEAFARERS in rules -> 12
|
||||
else -> 10
|
||||
},
|
||||
ruleSet = rules,
|
||||
expansions = rules,
|
||||
)
|
||||
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) {
|
||||
for (style in AiStyle.entries) {
|
||||
@@ -78,14 +78,14 @@ class CatanAiProfilesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play the base game`() = forEveryProfile(CatanRuleSet.BASE, 1_500)
|
||||
fun `easy and hard seats of every style play the base game`() = forEveryProfile(emptySet(), 1_500)
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play Cities and Knights`() =
|
||||
forEveryProfile(CatanRuleSet.CITIES_AND_KNIGHTS, 1_500)
|
||||
forEveryProfile(setOf(CatanExpansion.CITIES_AND_KNIGHTS), 1_500)
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(CatanRuleSet.SEAFARERS, 1_500)
|
||||
fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(setOf(CatanExpansion.SEAFARERS), 1_500)
|
||||
|
||||
@Test
|
||||
fun `a mixed table of four finishes a base game`() {
|
||||
@@ -95,8 +95,8 @@ class CatanAiProfilesTest {
|
||||
|
||||
@Test
|
||||
fun `the default profile plays exactly as an explicit medium table does`() {
|
||||
for (rules in listOf(CatanRuleSet.BASE, CatanRuleSet.CITIES_AND_KNIGHTS)) {
|
||||
val options = CatanOptions(playerCount = 3, pointsToWin = 10, ruleSet = rules)
|
||||
for (rules in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
|
||||
val options = CatanOptions(playerCount = 3, pointsToWin = 10, expansions = rules)
|
||||
val plain = play(options, 9L, AiProfiles.DEFAULT, 600)
|
||||
val medium = play(options, 9L, AiProfiles(AiDifficulty.MEDIUM, 1234L), 600)
|
||||
assertEquals("$rules", plain, medium)
|
||||
|
||||
@@ -25,7 +25,7 @@ class CatanCkRulesTest {
|
||||
CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 13,
|
||||
ruleSet = if (ck) CatanRuleSet.CITIES_AND_KNIGHTS else CatanRuleSet.BASE,
|
||||
expansions = if (ck) setOf(CatanExpansion.CITIES_AND_KNIGHTS) else emptySet(),
|
||||
),
|
||||
),
|
||||
seed = seed,
|
||||
@@ -138,7 +138,7 @@ class CatanCkRulesTest {
|
||||
val base = CatanRules.initialState(config(ck = false))
|
||||
assertNull(base.ck)
|
||||
assertEquals(25, base.devDeck.size)
|
||||
assertEquals(CatanRuleSet.BASE, base.options.ruleSet)
|
||||
assertEquals(emptySet<CatanExpansion>(), base.options.expansions)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -157,7 +157,21 @@ class CatanCkRulesTest {
|
||||
fun `the options without a rule set still read as the base game`() {
|
||||
val old = """{"playerCount":4,"pointsToWin":10}"""
|
||||
val options = Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(old)
|
||||
assertEquals(CatanRuleSet.BASE, options.ruleSet)
|
||||
assertEquals(emptySet<CatanExpansion>(), options.expansions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `options saved with a rule set still read as the expansion they named`() {
|
||||
val read = { text: String -> Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(text) }
|
||||
assertEquals(
|
||||
setOf(CatanExpansion.CITIES_AND_KNIGHTS),
|
||||
read("""{"playerCount":3,"pointsToWin":13,"ruleSet":"CITIES_AND_KNIGHTS"}""").expansions,
|
||||
)
|
||||
assertEquals(
|
||||
setOf(CatanExpansion.SEAFARERS),
|
||||
read("""{"playerCount":3,"pointsToWin":12,"ruleSet":"SEAFARERS"}""").expansions,
|
||||
)
|
||||
assertEquals(emptySet<CatanExpansion>(), read("""{"ruleSet":"BASE"}""").expansions)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -152,7 +152,7 @@ class CatanCkSelfPlayTest {
|
||||
fun `three engines play Cities and Knights to a finish`() {
|
||||
val reports = (1L..12L).map { seed ->
|
||||
val (state, report) = playOut(
|
||||
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
CatanOptions(playerCount = 3, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
|
||||
seed,
|
||||
)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
@@ -171,7 +171,7 @@ class CatanCkSelfPlayTest {
|
||||
fun `four engines play Cities and Knights to a finish`() {
|
||||
val reports = (101L..108L).map { seed ->
|
||||
val (state, report) = playOut(
|
||||
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
CatanOptions(playerCount = 4, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
|
||||
seed,
|
||||
)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
|
||||
@@ -115,8 +115,9 @@ class CatanRulesTest {
|
||||
// -- Setup ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `setup runs in snake order and then seat zero leads`() {
|
||||
fun `setup runs in snake order from the first seat and then that seat leads`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
val first = state.firstSeat
|
||||
val order = mutableListOf<Int>()
|
||||
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||
val seat = CatanRules.currentSeat(state)!!
|
||||
@@ -126,12 +127,21 @@ class CatanRulesTest {
|
||||
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
|
||||
)
|
||||
}
|
||||
assertEquals(listOf(0, 1, 2, 2, 1, 0), order)
|
||||
val forward = (0 until 3).map { (first + it) % 3 }
|
||||
assertEquals(forward + forward.reversed(), order)
|
||||
assertEquals(CatanPhase.AWAIT_ROLL, state.phase)
|
||||
assertEquals(0, state.turn)
|
||||
assertEquals(first, state.turn)
|
||||
assertEquals(1, state.turnNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first seat comes from the seed and varies between tables`() {
|
||||
val firsts = (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat }
|
||||
assertEquals(firsts, (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat })
|
||||
assertEquals(setOf(0, 1, 2), firsts.toSet())
|
||||
assertEquals(firsts[0], CatanRules.initialState(config(seed = 1L)).turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the second settlement earns one card from each adjacent land`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
|
||||
@@ -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,233 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** What every Seafarers map owes the engine, and the shape of each one. */
|
||||
class CatanSeaScenariosTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun options(scenario: SeaScenario, players: Int = 3) = CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 12,
|
||||
expansions = setOf(CatanExpansion.SEAFARERS),
|
||||
scenario = scenario,
|
||||
)
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun sizes(scenario: SeaScenario): List<Int> {
|
||||
val topo = SeaBoards.topology(scenario)
|
||||
return (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each map has the islands it is named for`() {
|
||||
assertEquals(listOf(2, 3, 3, 21), sizes(SeaScenario.NEW_SHORES))
|
||||
assertEquals(listOf(1, 1, 6, 6, 6, 6), sizes(SeaScenario.FOUR_ISLANDS))
|
||||
assertEquals(listOf(1, 1, 1, 2, 25), sizes(SeaScenario.THROUGH_THE_DESERT))
|
||||
assertEquals(listOf(2, 13, 14), sizes(SeaScenario.FOG_ISLANDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every map deals numbers, harbours and a pirate at sea, with the reds apart`() {
|
||||
for (scenario in SeaScenario.entries) {
|
||||
repeat(5) { i ->
|
||||
val s = CatanRules.initialState(config(options(scenario), 300L + i))
|
||||
val topo = s.topo
|
||||
val deserts = s.terrains.count { it == Terrain.DESERT }
|
||||
for (h in 0 until topo.HEX_COUNT) {
|
||||
val land = s.terrains[h] != Terrain.SEA
|
||||
assertEquals("$scenario hex $h", topo.land[h], land)
|
||||
val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG
|
||||
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
|
||||
}
|
||||
assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS)
|
||||
// Under fog every hex is face down with no number; the tiles are in the pile.
|
||||
val fog = s.terrains.indices.filter { s.terrains[it] == Terrain.FOG }
|
||||
assertEquals(fog.size, s.sea!!.fogDeck.size)
|
||||
fog.forEach { assertEquals(0, s.numbers[it]) }
|
||||
assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate])
|
||||
assertEquals("$scenario harbours", 9, s.ports.size)
|
||||
val reds = (0 until topo.HEX_COUNT).filter { s.numbers[it] == 6 || s.numbers[it] == 8 }
|
||||
for (a in reds) for (b in reds) if (a < b) assertFalse("$scenario reds touch", topo.hexesAdjacent(a, b))
|
||||
assertEquals(scenario, s.sea!!.scenario)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four islands start on any main island and the islets earn the bonus`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.FOUR_ISLANDS), 5L))
|
||||
val sea = s.sea!!
|
||||
assertEquals(4, sea.starts.size)
|
||||
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertEquals(sea.starts.toSet(), moves.map { s.topo.vertexIsland[it.vertex] }.toSet())
|
||||
for (island in 0 until s.topo.islandCount) assertEquals(island in sea.starts, sea.isStart(island))
|
||||
// A settlement on a starting island scores no first-landing bonus.
|
||||
val onMain = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] == sea.starts.first() }
|
||||
assertEquals(s, SeaRules.claimIsland(s, 0, onMain))
|
||||
val islet = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] >= 0 && s.topo.vertexIsland[it] !in sea.starts }
|
||||
assertEquals(0, SeaRules.bonusPoints(s, 0))
|
||||
assertEquals(0, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, onMain), 0))
|
||||
assertEquals(SeaRules.ISLAND_BONUS, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, islet), 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `through the desert starts everyone on the one big island`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.THROUGH_THE_DESERT), 5L))
|
||||
assertEquals(listOf(s.sea!!.home), s.sea!!.starts)
|
||||
assertEquals(25, s.topo.islandOf.count { it == s.sea!!.home })
|
||||
assertTrue(s.terrains.count { it == Terrain.DESERT } >= 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save from before there were several starts still starts on the home island`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.NEW_SHORES), 5L))
|
||||
val old = s.copy(sea = s.sea!!.copy(starts = emptyList()))
|
||||
assertTrue(old.sea!!.isStart(old.sea!!.home))
|
||||
val moves = CatanRules.legalMoves(old, old.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.all { old.topo.vertexIsland[it.vertex] == old.sea!!.home })
|
||||
}
|
||||
|
||||
private fun fogGame(seed: Long = 5L) = CatanRules.initialState(config(options(SeaScenario.FOG_ISLANDS), seed)).let {
|
||||
it.copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1, setupIndex = 6)
|
||||
}
|
||||
|
||||
/** A town site on the home island, ships already laid toward the fog, and the one edge that will reach it. */
|
||||
private data class FogRoute(val town: Int, val laid: List<Int>, val last: Int)
|
||||
|
||||
private fun CatanState.fogRoute(): FogRoute {
|
||||
val topo = topo
|
||||
fun touchesFog(v: Int) = topo.VERTEX_HEXES[v].any { terrains[it] == Terrain.FOG }
|
||||
val seen = mutableSetOf<Int>()
|
||||
for (start in 0 until topo.VERTEX_COUNT) {
|
||||
if (topo.vertexIsland[start] != sea!!.home || touchesFog(start)) continue
|
||||
if (topo.VERTEX_EDGES[start].none { topo.edgeSea[it] && !topo.edgeLand[it] }) continue
|
||||
val queue = ArrayDeque<Pair<Int, List<Int>>>().apply { add(start to emptyList()) }
|
||||
seen.clear()
|
||||
while (queue.isNotEmpty()) {
|
||||
val (v, path) = queue.removeFirst()
|
||||
if (!seen.add(v)) continue
|
||||
for (e in topo.VERTEX_EDGES[v]) {
|
||||
if (!topo.edgeSea[e] || topo.edgeLand[e]) continue
|
||||
val far = topo.otherEnd(e, v)
|
||||
if (touchesFog(far)) return FogRoute(start, path, e)
|
||||
if (path.size < 6) queue.add(far to path + e)
|
||||
}
|
||||
}
|
||||
}
|
||||
error("no ship route from home reaches the fog")
|
||||
}
|
||||
|
||||
/** [fogRoute] laid on the board: the town and all but the last ship. */
|
||||
private fun CatanState.onTheShore(route: FogRoute): CatanState = copy(
|
||||
buildings = buildings.toMutableList().also { it[route.town] = CatanBuilding(BuildingType.SETTLEMENT, 0) },
|
||||
sea = sea!!.copy(ships = sea!!.ships.toMutableList().also { l -> route.laid.forEach { l[it] = 0 } }),
|
||||
resources = List(3) { Resource.entries.associateWith { 3 } },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fog is hidden at the start, never a place to begin, and keeps the robber off`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.FOG_ISLANDS), 5L))
|
||||
assertFalse(s.sea!!.isStart(s.topo.islandOf.indices.first { s.terrains[it] == Terrain.FOG }.let { s.topo.islandOf[it] }))
|
||||
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } })
|
||||
val robbing = fogGame().copy(phase = CatanPhase.ROBBER)
|
||||
val targets = CatanRules.legalMoves(robbing, 0).filterIsInstance<MoveRobber>()
|
||||
assertTrue(targets.isNotEmpty())
|
||||
assertTrue(targets.none { robbing.terrains[robbing.topo.hexIndex.getValue(it.hex)] == Terrain.FOG })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ship that reaches the fog turns the hex over and the finder takes a card`() {
|
||||
val base = fogGame()
|
||||
val route = base.fogRoute()
|
||||
val edge = route.last
|
||||
val s = base.onTheShore(route)
|
||||
val tile = s.sea!!.fogDeck.first()
|
||||
val after = CatanRules.applyMove(s, 0, BuildShip(edge))
|
||||
val found = s.topo.EDGE_VERTICES[edge].toList().flatMap { s.topo.VERTEX_HEXES[it] }
|
||||
.filter { s.terrains[it] == Terrain.FOG }.distinct().sorted()
|
||||
val hex = found.first()
|
||||
assertEquals(tile.terrain, after.terrains[hex])
|
||||
assertEquals(tile.number, after.numbers[hex])
|
||||
assertEquals(s.sea!!.fogDeck.size - found.size, after.sea!!.fogDeck.size)
|
||||
val produced = tile.terrain.produces
|
||||
if (produced != null) assertEquals(s.resources[0].getValue(produced) - (if (produced in SeaRules.SHIP_COST) 1 else 0) + 1,
|
||||
after.resources[0].getValue(produced) - (found.drop(1).count { after.terrains[it].produces == produced }))
|
||||
if (tile.terrain == Terrain.GOLD) assertEquals(CatanPhase.GOLD, after.phase)
|
||||
// The find is public: the log names it.
|
||||
assertTrue(after.log.last { "uncovers" in it }.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gold find during road building asks for the choice and then goes back to the free roads`() {
|
||||
val base = fogGame()
|
||||
val route = base.fogRoute()
|
||||
val edge = route.last
|
||||
val gold = FogTile(Terrain.GOLD, 6)
|
||||
val shore = base.onTheShore(route)
|
||||
val s = shore.copy(
|
||||
phase = CatanPhase.FREE_ROADS,
|
||||
pendingFreeRoads = 2,
|
||||
sea = shore.sea!!.copy(fogDeck = List(shore.sea!!.fogDeck.size) { gold }),
|
||||
)
|
||||
val after = CatanRules.applyMove(s, 0, BuildShip(edge))
|
||||
assertEquals(CatanPhase.GOLD, after.phase)
|
||||
val pick = CatanRules.legalMoves(after, 0).filterIsInstance<PickGold>().first()
|
||||
val resumed = CatanRules.applyMove(after, 0, pick)
|
||||
assertEquals(CatanPhase.FREE_ROADS, resumed.phase)
|
||||
assertEquals(1, resumed.pendingFreeRoads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other seats do not see the face-down tiles and the state survives its wire format`() {
|
||||
val s = fogGame()
|
||||
assertTrue(s.sea!!.fogDeck.isNotEmpty())
|
||||
assertTrue(CatanRules.viewFor(s, 1).sea!!.fogDeck.isEmpty())
|
||||
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engines finish a game on every map at three and four players`() {
|
||||
var fogFound = 0
|
||||
for (scenario in SeaScenario.entries) {
|
||||
for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) {
|
||||
val opts = options(scenario, players)
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(opts, seed))
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 20_000)
|
||||
val seat = CatanRules.currentSeat(state) ?: error("$scenario seed $seed: nobody to move in ${state.phase}")
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
assertTrue("$scenario seed $seed: no legal move for $seat in ${state.phase}", legal.isNotEmpty())
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("$scenario seed $seed: illegal $move", CatanRules.isLegalMove(state, seat, move))
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
if (scenario == SeaScenario.FOG_ISLANDS) fogFound += 14 - state.sea!!.fogDeck.size
|
||||
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||
}
|
||||
}
|
||||
assertTrue("no engine ever uncovered a fog hex", fogFound > 0)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class CatanSeafarersTest {
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun options(players: Int = 3) =
|
||||
CatanOptions(playerCount = players, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
|
||||
CatanOptions(playerCount = players, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS))
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
@@ -96,7 +96,7 @@ class CatanSeafarersTest {
|
||||
@Test
|
||||
fun `setup places both settlements on the home island, with roads rather than ships`() {
|
||||
val s = start()
|
||||
val moves = CatanRules.legalMoves(s, 0).filterIsInstance<SetupPlace>()
|
||||
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.isNotEmpty())
|
||||
assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home })
|
||||
assertTrue(moves.all { s.topo.edgeLand[it.edge] })
|
||||
|
||||
@@ -171,7 +171,7 @@ class SaveRoundTripAllGamesTest {
|
||||
GameCatalog.CATAN,
|
||||
CatanRules,
|
||||
CatanAi(),
|
||||
opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)),
|
||||
opts(CatanOptions(3, 13, setOf(org.prolibertate.games.game.catan.CatanExpansion.CITIES_AND_KNIGHTS))),
|
||||
steps = 600,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.ChaseRobber
|
||||
@@ -69,7 +69,7 @@ class CatanCkInteractionTest {
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(
|
||||
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
CatanOptions(players, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
|
||||
),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.ProgressType
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
@@ -16,7 +16,7 @@ import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.ui.catanDefaultPoints
|
||||
import org.prolibertate.games.ui.catanPointsChoices
|
||||
import org.prolibertate.games.ui.catanSwitchRules
|
||||
import org.prolibertate.games.ui.catanToggleExpansion
|
||||
|
||||
class CatanCkTextTest {
|
||||
|
||||
@@ -28,7 +28,7 @@ class CatanCkTextTest {
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(
|
||||
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
CatanOptions(3, 13, setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
|
||||
),
|
||||
seed = 31L,
|
||||
)
|
||||
@@ -56,20 +56,27 @@ class CatanCkTextTest {
|
||||
|
||||
@Test
|
||||
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
|
||||
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
|
||||
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
|
||||
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(emptySet()))
|
||||
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(setOf(CatanExpansion.CITIES_AND_KNIGHTS)))
|
||||
assertEquals(10, catanDefaultPoints(emptySet()))
|
||||
assertEquals(13, catanDefaultPoints(setOf(CatanExpansion.CITIES_AND_KNIGHTS)))
|
||||
|
||||
val base = CatanOptions()
|
||||
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
|
||||
val ck = catanToggleExpansion(base, CatanExpansion.CITIES_AND_KNIGHTS, true)
|
||||
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS), ck.expansions)
|
||||
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
|
||||
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
|
||||
assertEquals(10, catanToggleExpansion(ck, CatanExpansion.CITIES_AND_KNIGHTS, false).pointsToWin)
|
||||
|
||||
// A short game picked on purpose is kept when both menus have it.
|
||||
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||
val short = catanToggleExpansion(base.copy(pointsToWin = 8), CatanExpansion.CITIES_AND_KNIGHTS, true)
|
||||
assertEquals(8, short.pointsToWin)
|
||||
assertEquals(3, ck.playerCount)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ internal object CatanFixtures {
|
||||
|
||||
/** A fresh table, on the first setup placement. */
|
||||
fun start(players: Int = 3, seed: Long = 7L): CatanState =
|
||||
CatanRules.initialState(config(players, seed))
|
||||
// These tests are about the screen, not who starts: pin seat 0 first.
|
||||
CatanRules.initialState(config(players, seed)).let { it.copy(firstSeat = 0, turn = 0) }
|
||||
|
||||
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
|
||||
fun playing(
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanBuilding
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.MovePirate
|
||||
@@ -29,7 +29,7 @@ import org.prolibertate.games.game.engine.TableConfig
|
||||
class CatanSeafarersInteractionTest {
|
||||
|
||||
private fun fresh(): CatanState {
|
||||
val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
|
||||
val options = CatanOptions(playerCount = 3, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS))
|
||||
return CatanRules.initialState(
|
||||
TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
# Katan: Seafarers
|
||||
|
||||
The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. It is the base
|
||||
Seafarers is an expansion switched on at setup (`CatanOptions.expansions`; the old single `ruleSet` in saves is still read) and its map is chosen beside it (`CatanOptions.scenario`). It is the base
|
||||
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 be combined with Cities & Knights.
|
||||
(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.
|
||||
|
||||
## The map
|
||||
## The maps
|
||||
|
||||
One map, *Heading for New Shores*: a large **home island** (21 hexes, one desert) and three small
|
||||
islands (3, 3 and 2 hexes) across the water, inside a ring of sea. Which land hexes are gold, which
|
||||
are the desert and where the sea lies is fixed; the **resources on the other hexes, the number tokens
|
||||
Each map is a fixed arrangement of land and sea; the **resources on the plain hexes, the number tokens
|
||||
and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring
|
||||
hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14).
|
||||
Pick the map at setup; each scenario names where the starting settlements may go (`SeaStart`).
|
||||
|
||||
* **Heading for New Shores** (default). A large home island (21 hexes, one desert) and three small islands (3, 3
|
||||
and 2 hexes) across the water, with gold fields on two of the small ones. Start on the home island.
|
||||
* **Four Islands.** Four equal islands (six hexes each) and two one-hex gold islets between them. Each player
|
||||
may start on **any** of the four main islands, so a table can spread out or crowd one; the islets are the
|
||||
prize for the first ship to arrive.
|
||||
* **Through the Desert.** One 25-hex island cut across by a broad desert (seven hexes that pay nothing and
|
||||
hold the robber), with three islets and gold to sail to. Everyone starts on the island. The desert is
|
||||
land, so a road can cross it, but nothing beside it pays, which is what makes the far side a race.
|
||||
|
||||
* **The Fog Islands.** A 13-hex home island (one desert), a two-hex islet and a 14-hex bank of fog.
|
||||
See *Fog* below.
|
||||
|
||||
A *new island* for the bonus is any island that is not a starting island for that map.
|
||||
|
||||
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
|
||||
a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching
|
||||
@@ -56,12 +69,47 @@ through an opponent's town and changes between road and ship only at a town of y
|
||||
|
||||
Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game.
|
||||
|
||||
## Fog
|
||||
|
||||
Fog hexes (`Terrain.FOG`) are land that is face down. Under them is a shuffled pile of tiles
|
||||
(`SeaState.fogDeck`): resources, with one gold field in every five, each with its number token. The
|
||||
pile is hidden from every seat but the host's, like the development deck.
|
||||
|
||||
* **Finding.** When a road or ship is built (or a ship sails) and an end of it touches a fog hex, the hex is
|
||||
turned over, taking the next tile and its number. Several hexes can be found by one piece.
|
||||
* **The find pays.** The finder takes one card of what lies there, supply permitting; a gold field lets the
|
||||
finder choose any resource (the gold choice, resuming Road Building if that is what was under way).
|
||||
* **Until then** nothing produces there, nobody may start beside it, and the robber cannot go on it.
|
||||
* **Differences from the published scenario.** The fog here is always land: the physical tiles can turn out to
|
||||
be sea, but the board's land and sea are fixed in the geometry, so a found hex never turns to water. Fog is
|
||||
also on one bank rather than between several islands, and the number tokens are dealt with the tiles, so two
|
||||
red numbers can end up side by side once found.
|
||||
|
||||
## 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**:
|
||||
fog islands, treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations,
|
||||
the scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map
|
||||
is a new text map and a new enum entry.
|
||||
treasure islands, the oasis, the great canal, the Explorers & Pirates 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.
|
||||
|
||||
## How the AI plays it
|
||||
|
||||
|
||||
Reference in New Issue
Block a user