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