Compare commits

...
Author SHA1 Message Date
Claude 68cd799f89 Merge dev into Seafarers
CI / Build and check (pull_request) Successful in 9m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-29 03:04:13 +00:00
devadmin 6c549a8b76 Merge pull request 'Yahtzee: end-of-game stats dialog' (#23) from yahtzee-stats into dev
CI / Build and check (push) Successful in 8m46s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
CI / Build and check (pull_request) Successful in 8m46s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-26 22:36:19 -06:00
Claude 401ebe0118 Docs index: Seafarers
CI / Build and check (pull_request) Successful in 8m59s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 17:19:11 +00:00
Claude 3c0469fc81 Seafarers: interaction tests, rules and UI docs 2026-10-24 17:18:59 +00:00
Claude 5d36b7bd62 Seafarers: rules tests and engine self-play, AI heads for new islands 2026-10-24 17:15:16 +00:00
Claude b5b4afa731 Merge remote-tracking branch 'origin/dev' into Seafarers 2026-10-24 17:13:14 +00:00
Claude f88099f4e2 WIP Seafarers engine, board and screen plumbing 2026-10-24 17:13:14 +00:00
24 changed files with 1757 additions and 161 deletions
+8 -2
View File
@@ -52,9 +52,15 @@ integration:
## Katan ## Katan
The island game is catalogue id `catan`, shown as **Katan**. It has two rule The island game is catalogue id `catan`, shown as **Katan**. It has two rule
sets chosen at setup: the base game (`docs/RULES-catan.md`) and Cities & Knights sets chosen at setup: the base game (`docs/RULES-catan.md`), Cities & Knights
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind (`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`CatanState.ck`, which is null in the base game). The screen is layered; see `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
`CatanBoard` directly, because a Seafarers map is not the 19-hex island
(Cities & Knights is always the standard island and still uses `CatanBoard`). 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. `docs/UI-catan.md` before changing it.
## Bash output is filtered before it reaches the model ## Bash output is filtered before it reaches the model
@@ -34,6 +34,11 @@ class CatanAi : GameAi<CatanState, CatanMove> {
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice } CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
// Gold: take what the seat wants most.
CatanPhase.GOLD -> legal.filterIsInstance<PickGold>().maxByOrNull { pick ->
pick.cards.sumOf { want(it) }
} ?: legal.first()
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>()) CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>()) CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
@@ -70,9 +75,10 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* worth is what it reaches past it. * worth is what it reaches past it.
*/ */
private fun placeDirection(state: CatanState, place: SetupPlace): Double { private fun placeDirection(state: CatanState, place: SetupPlace): Double {
val far = CatanBoard.otherEnd(place.edge, place.vertex) val topo = state.topo
return CatanBoard.VERTEX_NEIGHBORS[far] val far = topo.otherEnd(place.edge, place.vertex)
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] } return topo.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in topo.VERTEX_NEIGHBORS[place.vertex] }
.maxOfOrNull { spotValue(state, it) } .maxOfOrNull { spotValue(state, it) }
?: 0.0 ?: 0.0
} }
@@ -94,9 +100,9 @@ class CatanAi : GameAi<CatanState, CatanMove> {
require(moves.isNotEmpty()) { "No legal robber move" } require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state) val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move -> return moves.maxByOrNull { move ->
val hex = CatanBoard.hexIndex.getValue(move.hex) val hex = state.topo.hexIndex.getValue(move.hex)
var score = 0.0 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 building = state.buildings[v] ?: continue
if (building.owner == seat) continue if (building.owner == seat) continue
val worth = if (building.type == BuildingType.CITY) 2 else 1 val worth = if (building.type == BuildingType.CITY) 2 else 1
@@ -124,6 +130,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
spotValue(state, it.vertex) spotValue(state, it.vertex)
}?.let { return it } }?.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 }
.maxByOrNull { shipValue(state, seat, it.edge) }
?.let { return it }
// A knight is worth playing whenever one is playable: it blocks an // A knight is worth playing whenever one is playable: it blocks an
// opponent's best hex and marches toward Largest Army. // opponent's best hex and marches toward Largest Army.
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it } legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
@@ -142,6 +154,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it } legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
// Seafarers: a ship that heads for a settlement spot worth having.
legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.15 }
.maxByOrNull { shipValue(state, seat, it.edge) }
?.let { return it }
// A paid road only when it reaches toward a spot worth settling. // A paid road only when it reaches toward a spot worth settling.
legal.filterIsInstance<BuildRoad>() legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.1 } .filter { roadValue(state, it) > 0.1 }
@@ -301,13 +319,13 @@ class CatanAi : GameAi<CatanState, CatanMove> {
if (state.buildings[vertex] != null) return -1.0 if (state.buildings[vertex] != null) return -1.0
var value = 0.0 var value = 0.0
val kinds = mutableSetOf<Resource>() val kinds = mutableSetOf<Resource>()
for (h in CatanBoard.VERTEX_HEXES[vertex]) { for (h in state.topo.VERTEX_HEXES[vertex]) {
val resource = state.terrains[h].produces ?: continue val resource = state.terrains[h].produces ?: continue
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource) value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
kinds += resource kinds += resource
} }
value += 0.12 * kinds.size // variety keeps a build plan alive value += 0.12 * kinds.size // variety keeps a build plan alive
for (e in CatanBoard.VERTEX_EDGES[vertex]) { for (e in state.topo.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) 0.1 else 0.2 value += if (port.resource == null) 0.1 else 0.2
} }
@@ -320,15 +338,52 @@ class CatanAi : GameAi<CatanState, CatanMove> {
*/ */
internal fun roadValue(state: CatanState, move: BuildRoad): Double { internal fun roadValue(state: CatanState, move: BuildRoad): Double {
var best = 0.0 var best = 0.0
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge] val (from, to) = state.topo.EDGE_VERTICES[move.edge]
for (endpoint in listOf(from, to)) { for (endpoint in listOf(from, to)) {
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) { for (near in state.topo.VERTEX_NEIGHBORS[endpoint]) {
best = maxOf(best, spotValue(state, near) * 0.8) best = maxOf(best, spotValue(state, near) * 0.8)
} }
} }
return best return best
} }
/**
* What a ship on [edge] is heading for: the best open settlement spot reachable by sea from
* its far end (the end the fleet does not already hold), discounted by how far off it is,
* with a bonus for an island nobody has landed on.
*/
internal fun shipValue(state: CatanState, seat: Int, edge: Int): Double {
val topo = state.topo
val sea = state.sea ?: return 0.0
val (a, b) = topo.EDGE_VERTICES[edge]
fun held(v: Int): Boolean =
state.buildings[v]?.owner == seat || topo.VERTEX_EDGES[v].any { sea.ships[it] == seat }
val front = listOf(a, b).filter { !held(it) }.ifEmpty { return 0.0 }
var best = 0.0
val seen = mutableSetOf<Int>()
var frontier = front
var depth = 0
while (frontier.isNotEmpty() && depth <= 5) {
val next = mutableListOf<Int>()
for (v in frontier) {
if (!seen.add(v)) continue
if (CatanRules.vertexOpenForSettlement(state, v)) {
val island = topo.vertexIsland[v]
val fresh = island >= 0 && island != sea.home && sea.islandBonus[island] == NO_PLAYER
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
best = maxOf(best, value)
}
for (e in topo.VERTEX_EDGES[v]) {
if (!topo.edgeSea[e] || sea.ships[e] != NO_PLAYER) continue
next += topo.otherEnd(e, v)
}
}
frontier = next
depth++
}
return best
}
/** How much the AI wants to hold one card of [resource]. */ /** How much the AI wants to hold one card of [resource]. */
internal fun want(resource: Resource): Double = when (resource) { internal fun want(resource: Resource): Double = when (resource) {
Resource.ORE -> 1.20 // the city Resource.ORE -> 1.20 // the city
@@ -5,13 +5,14 @@ import kotlin.math.atan2
import kotlin.random.Random import kotlin.random.Random
/** /**
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72 * The geometry of one board: its hexes, the shared vertices and edges between
* shared edges, the coast and the nine harbour positions. * them, and which hexes are land.
* *
* Everything is derived once, deterministically, from axial coordinates — * Everything is derived once, deterministically, from axial coordinates —
* there is no randomness and no state here; the layout of a particular * there is no randomness and no state here; the layout of a particular
* game (which terrain and number sits on which hex) is [generate], and the * game (which terrain and number sits on which hex) is generated elsewhere
* live state lives in [CatanState]. * ([CatanBoard.generate], [SeaBoards.generate]), and the live state lives in
* [CatanState].
* *
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every * Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
* position is kept on an exact integer lattice so vertices shared by * position is kept on an exact integer lattice so vertices shared by
@@ -20,41 +21,25 @@ import kotlin.random.Random
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are * six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
* in half-units of the hex grid; rendering scales and flips them (lattice * in half-units of the hex grid; rendering scales and flips them (lattice
* +Y is toward the top of the board). * +Y is toward the top of the board).
*
* The standard island ([CatanBoard.base]) is the 19-hex hexagon. A Seafarers
* map is a larger frame whose hexes are mostly sea; [land] says which.
*/ */
object CatanBoard { class Topology(
/** Every hex of the frame, land and sea alike. */
/** The 19 hexes: every (q, r) within distance 2 of the centre. */ val HEXES: List<HexCoord>,
val HEXES: List<HexCoord> = buildList { /** Whether each hex is land. The base island is all land; Seafarers maps are mostly sea. */
for (r in -2..2) { val land: List<Boolean> = List(HEXES.size) { true },
for (q in -2..2) { /** The standard 19-hex island: checks its counts and uses the fixed nine-harbour frame. */
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r)) private val standardFrame: Boolean = false,
} /** Harbours wanted on a non-standard map. */
} harbours: Int = 0,
} ) {
val HEX_COUNT: Int = HEXES.size val HEX_COUNT: Int = HEXES.size
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i } val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Vertices and edges // Vertices and edges
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -93,9 +78,28 @@ object CatanBoard {
/** The coastal edges (those with only one hex) in ring order. */ /** The coastal edges (those with only one hex) in ring order. */
val COASTAL: List<Int> val COASTAL: List<Int>
/** The nine fixed harbour positions, spread around the coast. */ /** The harbour positions: nine on the standard frame. */
val PORT_POSITIONS: List<Int> val PORT_POSITIONS: List<Int>
// --- Land and sea (all land on the standard island) --------------------
/** A settlement may stand here: at least one of its hexes is land. */
val vertexLand: List<Boolean>
/** A road may lie here: at least one of its hexes is land. */
val edgeLand: List<Boolean>
/** A ship may sail here: at least one of its hexes is sea. */
val edgeSea: List<Boolean>
/** Which island each hex is on (a connected group of land hexes), or -1 for sea. */
val islandOf: List<Int>
val islandCount: Int
/** Which island each vertex touches, or -1 for open water. */
val vertexIsland: List<Int>
init { init {
// Vertices: assign ids in first-seen order over hexes then corners. // Vertices: assign ids in first-seen order over hexes then corners.
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>() val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
@@ -169,27 +173,99 @@ object CatanBoard {
// The coast in walking order: sort the coastal edges by the angle of // The coast in walking order: sort the coastal edges by the angle of
// their midpoint around the board centre, which is the lattice // their midpoint around the board centre, which is the lattice
// origin. The board is a hexagon, so this yields one closed ring. // origin. The standard board is a hexagon, so this yields one closed ring.
COASTAL = EDGE_HEXES.withIndex() COASTAL = EDGE_HEXES.withIndex()
.filter { it.value.size == 1 } .filter { it.value.size == 1 }
.map { (e, _) -> e } .map { (e, _) -> e }
.sortedBy { e -> angleOf(midpoint(e)) } .sortedBy { e -> angleOf(midpoint(e)) }
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
PORT_POSITIONS = buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" } vertexLand = List(VERTEX_COUNT) { v -> VERTEX_HEXES[v].any { land[it] } }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" } edgeLand = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { land[it] } }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" } edgeSea = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { !land[it] } }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" } // Islands: connected groups of land hexes.
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" } val island = IntArray(HEX_COUNT) { -1 }
var islands = 0
for (h in 0 until HEX_COUNT) {
if (!land[h] || island[h] != -1) continue
val stack = ArrayDeque(listOf(h))
island[h] = islands
while (stack.isNotEmpty()) {
val at = stack.removeLast()
for (n in HEX_NEIGHBORS[at]) {
if (land[n] && island[n] == -1) {
island[n] = islands
stack.addLast(n)
}
}
}
islands++
}
islandOf = island.toList()
islandCount = islands
vertexIsland = List(VERTEX_COUNT) { v ->
VERTEX_HEXES[v].firstOrNull { land[it] }?.let { island[it] } ?: -1
}
PORT_POSITIONS = if (standardFrame) {
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
} else {
seaPorts(harbours)
}
if (standardFrame) {
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
}
}
/**
* Harbour sites on a map with islands: edges where land meets sea, taken
* round the map by angle about the land's centre, [count] of them as evenly
* spaced as the coast allows, never two sharing a corner.
*/
private fun seaPorts(count: Int): List<Int> {
if (count <= 0) return emptyList()
val shore = (0 until EDGE_COUNT).filter { e ->
EDGE_HEXES[e].size == 2 && EDGE_HEXES[e].any { land[it] } && EDGE_HEXES[e].any { !land[it] }
}
if (shore.isEmpty()) return emptyList()
val landCentre = HEXES.indices.filter { land[it] }.let { hs ->
val pts = hs.map { hexCenter(it) }
(pts.sumOf { it.first } / pts.size.toDouble()) to (pts.sumOf { it.second } / pts.size.toDouble())
}
val ring = shore.sortedBy { e ->
val m = midpoint(e)
atan2(m.second - landCentre.second, m.first - landCentre.first)
}
val picked = mutableListOf<Int>()
val used = mutableSetOf<Int>()
val step = ring.size.toDouble() / count
for (i in 0 until count) {
val start = (i * step).toInt()
// The first edge at or after the ideal spot whose corners are free.
val e = (0 until ring.size).map { ring[(start + it) % ring.size] }.firstOrNull { cand ->
val (a, b) = EDGE_VERTICES[cand]
a !in used && b !in used && cand !in picked
} ?: continue
picked += e
val (a, b) = EDGE_VERTICES[e]
used += a
used += b
}
return picked
} }
/** The other endpoint of [edge], given one [vertex] on it. */ /** The other endpoint of [edge], given one [vertex] on it. */
@@ -207,19 +283,14 @@ object CatanBoard {
/** Whether two hexes share an edge. */ /** Whether two hexes share an edge. */
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a] fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
/** Hex-grid distance between two axial positions. */ /** Lattice extent of the whole frame: min x, max x, min y, max y. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int { val lattice: IntArray by lazy {
val dq = q1 - q2 intArrayOf(
val dr = r1 - r2 VERTEX_POS.minOf { it.first }, VERTEX_POS.maxOf { it.first },
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr)) VERTEX_POS.minOf { it.second }, VERTEX_POS.maxOf { it.second },
)
} }
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
private fun midpoint(edge: Int): Pair<Double, Double> { private fun midpoint(edge: Int): Pair<Double, Double> {
val (a, b) = EDGE_VERTICES[edge] val (a, b) = EDGE_VERTICES[edge]
val pa = VERTEX_POS[a] val pa = VERTEX_POS[a]
@@ -230,6 +301,84 @@ object CatanBoard {
private fun angleOf(point: Pair<Double, Double>): Double = private fun angleOf(point: Pair<Double, Double>): Double =
atan2(point.second, point.first) atan2(point.second, point.first)
companion object {
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** Hex-grid distance between two axial positions. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
val dq = q1 - q2
val dr = r1 - r2
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
}
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
}
}
/**
* The standard island: the 19 hexes, the 54 shared vertices, the 72 shared
* edges, the coast and the nine harbour positions. Every table below is the
* standard frame's [Topology]; the rules read the topology of the *state* they
* are working on ([CatanState.topo]), which is this one in every game but Seafarers.
*/
object CatanBoard {
/** The standard island's geometry. */
val base: Topology = Topology(
HEXES = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (Topology.hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
},
standardFrame = true,
)
val HEXES: List<HexCoord> get() = base.HEXES
val HEX_COUNT: Int get() = base.HEX_COUNT
val hexIndex: Map<HexCoord, Int> get() = base.hexIndex
val VERTEX_POS: List<Pair<Int, Int>> get() = base.VERTEX_POS
val VERTEX_COUNT: Int get() = base.VERTEX_COUNT
val HEX_CORNERS: List<List<Int>> get() = base.HEX_CORNERS
val EDGE_VERTICES: List<Pair<Int, Int>> get() = base.EDGE_VERTICES
val EDGE_COUNT: Int get() = base.EDGE_COUNT
val VERTEX_EDGES: List<List<Int>> get() = base.VERTEX_EDGES
val VERTEX_NEIGHBORS: List<List<Int>> get() = base.VERTEX_NEIGHBORS
val VERTEX_HEXES: List<List<Int>> get() = base.VERTEX_HEXES
val EDGE_HEXES: List<List<Int>> get() = base.EDGE_HEXES
val EDGE_NEIGHBORS: List<List<Int>> get() = base.EDGE_NEIGHBORS
val HEX_NEIGHBORS: List<List<Int>> get() = base.HEX_NEIGHBORS
val COASTAL: List<Int> get() = base.COASTAL
val PORT_POSITIONS: List<Int> get() = base.PORT_POSITIONS
fun otherEnd(edge: Int, vertex: Int): Int = base.otherEnd(edge, vertex)
fun hexCenter(h: Int): Pair<Int, Int> = base.hexCenter(h)
fun hexesAdjacent(a: Int, b: Int): Boolean = base.hexesAdjacent(a, b)
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int = Topology.hexDistance(q1, r1, q2, r2)
fun numberOdds(number: Int): Int = Topology.numberOdds(number)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Island generation // Island generation
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -273,6 +273,9 @@ object CatanCk {
CatanPhase.FREE_ROADS -> CatanPhase.FREE_ROADS ->
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads) CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
// Seafarers is never combined with Cities & Knights.
CatanPhase.GOLD -> emptyList()
CatanPhase.TRADE_PENDING -> { CatanPhase.TRADE_PENDING -> {
val offer = state.ck!!.goodsOffer val offer = state.ck!!.goodsOffer
if (offer == null) { if (offer == null) {
@@ -35,7 +35,7 @@ internal class CatanCkAi(private val ai: CatanAi) {
CatanPhase.PLAYING -> choosePlaying(state, seat, legal) CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull() CatanPhase.DISCARDING, CatanPhase.GOLD, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}") ?: error("Nothing to choose in phase ${state.phase}")
} }
@@ -39,6 +39,12 @@ enum class Terrain(val produces: Resource?) {
HILLS(Resource.BRICK), HILLS(Resource.BRICK),
MOUNTAINS(Resource.ORE), MOUNTAINS(Resource.ORE),
DESERT(null), DESERT(null),
/** Seafarers: open water. Ships sail on it; nothing is built on it and nothing produces. */
SEA(null),
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
GOLD(null),
} }
/** Axial coordinate of one hex on the board. See [CatanBoard]. */ /** Axial coordinate of one hex on the board. See [CatanBoard]. */
@@ -114,6 +120,9 @@ enum class CatanPhase {
/** The player whose turn it is must roll the dice. */ /** The player whose turn it is must roll the dice. */
AWAIT_ROLL, AWAIT_ROLL,
/** Seafarers: the first seat in [SeaState.gold] is choosing the resources its gold fields paid. */
GOLD,
/** Players holding more than 7 resource cards discard half. */ /** Players holding more than 7 resource cards discard half. */
DISCARDING, DISCARDING,
@@ -150,6 +159,9 @@ enum class CatanRuleSet {
/** Cities & Knights: commodities, knights, barbarians, city improvements. */ /** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS, CITIES_AND_KNIGHTS,
/** Seafarers: ships, a pirate, gold fields and islands to settle (see `docs/RULES-catan-seafarers.md`). */
SEAFARERS,
} }
@Serializable @Serializable
@@ -230,9 +242,17 @@ data class CatanState(
val log: List<String>, val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */ /** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null, val ck: CkState? = null,
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
val sea: SeaState? = null,
) { ) {
val playerCount: Int get() = options.playerCount val playerCount: Int get() = options.playerCount
/** The geometry this game is played on: the standard island, or a Seafarers map. */
val topo: Topology get() = sea?.let { SeaBoards.topology(it.scenario) } ?: CatanBoard.base
/** Whether this table plays Seafarers. */
val isSea: Boolean get() = sea != null
/** Whether this table plays Cities & Knights. */ /** Whether this table plays Cities & Knights. */
val isCk: Boolean get() = ck != null val isCk: Boolean get() = ck != null
@@ -377,3 +397,48 @@ data object EndTurn : CatanMove
@Serializable @Serializable
@SerialName("endFreeRoads") @SerialName("endFreeRoads")
data object EndFreeRoads : CatanMove data object EndFreeRoads : CatanMove
/**
* Seafarers state: the ships, the pirate and the islands, alongside the base game's
* roads, robber and buildings (which keep working exactly as before).
*/
@Serializable
data class SeaState(
val scenario: SeaScenario,
/** Owning seat per edge, or [NO_PLAYER]: a ship on that edge. Roads stay in [CatanState.roads]. */
val ships: List<Int>,
/** The hex the pirate occupies: always a sea hex. */
val pirate: Int,
/** Ships built this turn (they may not sail until the next) and whether a ship has sailed this turn. */
val builtThisTurn: List<Int> = emptyList(),
val shipMoved: Boolean = false,
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
val islandBonus: List<Int>,
/** The island the players start on. */
val home: Int,
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
val gold: List<GoldOwed> = emptyList(),
)
@Serializable
data class GoldOwed(val seat: Int, val count: Int)
/** Build a ship on [edge]: 1 lumber and 1 wool, on a sea edge joined to your fleet or a harbour town. */
@Serializable
@SerialName("ship")
data class BuildShip(val edge: Int) : CatanMove
/** Sail the ship on [from] to [to]: once a turn, never the turn it was built, only from the open end of a route. */
@Serializable
@SerialName("moveShip")
data class MoveShip(val from: Int, val to: Int) : CatanMove
/** Move the pirate to the sea hex [hex] and rob [victim], a seat with a ship beside it, if there is one. */
@Serializable
@SerialName("pirate")
data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
/** Choose the [cards] a gold field paid. */
@Serializable
@SerialName("gold")
data class PickGold(val cards: List<Resource>) : CatanMove
@@ -54,7 +54,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
"Seat count ${config.seats.size} does not match ${options.playerCount} players" "Seat count ${config.seats.size} does not match ${options.playerCount} players"
} }
val board = CatanBoard.generate(config.seed) val sea = options.ruleSet == CatanRuleSet.SEAFARERS
val scenario = SeaScenario.NEW_SHORES
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
?: CatanBoard.generate(config.seed)
// A different random stream than the island, so the same seed still // A different random stream than the island, so the same seed still
// produces both a fixed island and a fixed deck. // produces both a fixed island and a fixed deck.
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
@@ -79,8 +84,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
numbers = board.numbers, numbers = board.numbers,
robber = board.robber, robber = board.robber,
ports = board.ports, ports = board.ports,
buildings = List(CatanBoard.VERTEX_COUNT) { null }, buildings = List(topo.VERTEX_COUNT) { null },
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER }, roads = List(topo.EDGE_COUNT) { NO_PLAYER },
resources = List(options.playerCount) { resources = List(options.playerCount) {
Resource.entries.associateWith { 0 } Resource.entries.associateWith { 0 }
}, },
@@ -98,10 +103,19 @@ object CatanRules : GameRules<CatanState, CatanMove> {
rngStep = 0, rngStep = 0,
winner = null, winner = null,
log = listOf( log = listOf(
"Catan${if (ck) " — Cities & Knights" else ""} — ${options.playerCount} players, " + "Catan${if (ck) " — Cities & Knights" else if (sea) " — Seafarers: ${scenario.title}" else ""} — ${options.playerCount} players, " +
"first to ${options.pointsToWin} victory points.", "first to ${options.pointsToWin} victory points.",
), ),
ck = if (ck) CatanCk.initial(options, config.seed) else null, ck = if (ck) CatanCk.initial(options, config.seed) else null,
sea = seaBoard?.let {
SeaState(
scenario = scenario,
ships = List(topo.EDGE_COUNT) { NO_PLAYER },
pirate = it.pirate,
islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home,
)
},
) )
} }
@@ -114,6 +128,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 } ?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
CatanPhase.TRADE_COUNTERS -> state.turn CatanPhase.TRADE_COUNTERS -> state.turn
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
CatanPhase.GOLD -> state.sea?.gold?.firstOrNull()?.seat
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER, CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS, CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn -> state.turn
@@ -162,12 +177,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.DISCARDING -> discardMoves(state, seat) CatanPhase.DISCARDING -> discardMoves(state, seat)
CatanPhase.ROBBER -> robberMoves(state, seat) CatanPhase.ROBBER -> robberMoves(state, seat) +
(if (state.isSea) SeaRules.pirateMoves(state, seat) else emptyList())
CatanPhase.PLAYING -> playingMoves(state, seat) CatanPhase.PLAYING -> playingMoves(state, seat)
CatanPhase.FREE_ROADS -> CatanPhase.FREE_ROADS ->
roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads) roadMoves(state, seat) +
(if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) +
listOf<CatanMove>(EndFreeRoads)
CatanPhase.GOLD -> SeaRules.goldMoves(state)
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat) CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
@@ -179,10 +199,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
internal fun setupMoves(state: CatanState): List<SetupPlace> { internal fun setupMoves(state: CatanState): List<SetupPlace> {
val moves = mutableListOf<SetupPlace>() val moves = mutableListOf<SetupPlace>()
for (v in 0 until CatanBoard.VERTEX_COUNT) { for (v in 0 until state.topo.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue if (!vertexOpenForSettlement(state, v)) continue
for (e in CatanBoard.VERTEX_EDGES[v]) { // Seafarers: everyone starts on the home island.
if (state.roads[e] != NO_PLAYER) continue if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
moves += SetupPlace(v, e) moves += SetupPlace(v, e)
} }
} }
@@ -215,18 +237,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> { private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val moves = mutableListOf<MoveRobber>() val moves = mutableListOf<MoveRobber>()
for (h in 0 until CatanBoard.HEX_COUNT) { for (h in 0 until state.topo.HEX_COUNT) {
if (h == state.robber) continue if (h == state.robber || !state.topo.land[h]) continue
val victims = CatanBoard.HEX_CORNERS[h] val victims = state.topo.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] } .mapNotNull { state.buildings[it] }
.map { it.owner } .map { it.owner }
.filter { it != seat } .filter { it != seat }
.distinct() .distinct()
.filter { state.resourceCount(it) > 0 } .filter { state.resourceCount(it) > 0 }
if (victims.isEmpty()) { if (victims.isEmpty()) {
moves += MoveRobber(CatanBoard.HEXES[h]) moves += MoveRobber(state.topo.HEXES[h])
} else { } else {
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) } victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
} }
} }
return moves return moves
@@ -245,6 +267,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) { if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
moves += BuyDevCard moves += BuyDevCard
} }
if (state.isSea) {
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
moves += SeaRules.moveShipMoves(state, seat)
}
if (!state.devCardPlayedThisTurn) { if (!state.devCardPlayedThisTurn) {
for (card in state.devHands[seat]) { for (card in state.devHands[seat]) {
@@ -301,8 +327,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.roads.count { it == seat } >= MAX_ROADS) { if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList() emptyList()
} else { } else {
(0 until CatanBoard.EDGE_COUNT) (0 until state.topo.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) } .filter {
state.roads[it] == NO_PLAYER && state.topo.edgeLand[it] &&
state.sea?.ships?.get(it).let { ship -> ship == null || ship == NO_PLAYER } &&
roadConnects(state, seat, it)
}
.map { BuildRoad(it) } .map { BuildRoad(it) }
} }
@@ -316,9 +346,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) { if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList() emptyList()
} else { } else {
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until state.topo.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) && vertexOpenForSettlement(state, v) &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } state.topo.VERTEX_EDGES[v].any { state.roads[it] == seat || state.sea?.ships?.get(it) == seat }
} }
} }
@@ -326,7 +356,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) { if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList() emptyList()
} else { } else {
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
} }
} }
@@ -373,8 +403,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
/** An empty vertex with no building on it or one edge away. */ /** An empty vertex with no building on it or one edge away. */
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && !hasKnight(state, v) && state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/** /**
* A road on [edge] would join [seat]'s network of roads and buildings. * A road on [edge] would join [seat]'s network of roads and buildings.
@@ -385,7 +415,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* of your own roads ending at the same crossing. * of your own roads ending at the same crossing.
*/ */
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean { private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
val (a, b) = CatanBoard.EDGE_VERTICES[edge] val (a, b) = state.topo.EDGE_VERTICES[edge]
return networkReaches(state, seat, a) || networkReaches(state, seat, b) return networkReaches(state, seat, a) || networkReaches(state, seat, b)
} }
@@ -397,7 +427,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (knight != null && knight.owner != seat) return false if (knight != null && knight.owner != seat) return false
// Your own building is always a way in; anyone else's is a wall. // Your own building is always a way in; anyone else's is a wall.
if (building != null) return building.owner == seat if (building != null) return building.owner == seat
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat } return state.topo.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
} }
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean { private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
@@ -423,6 +453,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is RollDice -> applyRoll(state, seat, move) is RollDice -> applyRoll(state, seat, move)
is Discard -> applyDiscard(state, seat, move) is Discard -> applyDiscard(state, seat, move)
is MoveRobber -> applyRobber(state, seat, move) is MoveRobber -> applyRobber(state, seat, move)
is BuildShip -> applyBuildShip(state, seat, move)
is MoveShip -> applyMoveShip(state, seat, move)
is MovePirate -> applyMovePirate(state, seat, move)
is PickGold -> applyPickGold(state, seat, move)
is BuildRoad -> applyBuildRoad(state, seat, move) is BuildRoad -> applyBuildRoad(state, seat, move)
is BuildSettlement -> applyBuildSettlement(state, seat, move) is BuildSettlement -> applyBuildSettlement(state, seat, move)
is BuildCity -> applyBuildCity(state, seat, move) is BuildCity -> applyBuildCity(state, seat, move)
@@ -461,7 +495,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (second) { if (second) {
// The second settlement earns one card from each adjacent // The second settlement earns one card from each adjacent
// producing hex, supply permitting. // producing hex, supply permitting.
val adjacent = CatanBoard.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces } val adjacent = state.topo.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) -> val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) ->
need <= bankSupply(state, res) need <= bankSupply(state, res)
} }
@@ -517,11 +551,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() } val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
for (resource in Resource.entries) { for (resource in Resource.entries) {
var demand = 0 var demand = 0
for (h in 0 until CatanBoard.HEX_COUNT) { for (h in 0 until state.topo.HEX_COUNT) {
if (state.numbers[h] != number) continue if (state.numbers[h] != number) continue
if (h == state.robber) continue if (h == state.robber) continue
if (state.terrains[h].produces != resource) continue if (state.terrains[h].produces != resource) continue
for (v in CatanBoard.HEX_CORNERS[h]) { for (v in state.topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue val building = state.buildings[v] ?: continue
val amount = if (building.type == BuildingType.CITY) 2 else 1 val amount = if (building.type == BuildingType.CITY) 2 else 1
gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount
@@ -535,6 +569,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
} }
// Seafarers: gold fields pay a card of the owner's choice, asked for after the rest is dealt.
val goldOwed = if (state.isSea) goldOwed(state, number) else emptyList()
var s = state.copy(phase = CatanPhase.PLAYING) var s = state.copy(phase = CatanPhase.PLAYING)
val whoGot = mutableListOf<String>() val whoGot = mutableListOf<String>()
for (seat in 0 until s.playerCount) { for (seat in 0 until s.playerCount) {
@@ -543,10 +580,31 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(resources = withCounts(s.resources, seat, got, 1)) s = s.copy(resources = withCounts(s.resources, seat, got, 1))
whoGot += "seat $seat takes ${describe(got)}" whoGot += "seat $seat takes ${describe(got)}"
} }
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ") val goldLine = goldOwed.joinToString("") { ", seat ${it.seat} is owed ${it.count} gold" }
return s.copy( val said = if (whoGot.isEmpty() && goldOwed.isEmpty()) "nothing for anyone" else
(whoGot.joinToString(", ") + goldLine).trimStart(',', ' ')
s = s.copy(
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.", log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
) )
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> {
val topo = state.topo
val owed = IntArray(state.playerCount)
for (h in 0 until topo.HEX_COUNT) {
if (state.terrains[h] != Terrain.GOLD || state.numbers[h] != number || h == state.robber) continue
for (v in topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
owed[building.owner] += if (building.type == BuildingType.CITY) 2 else 1
}
}
return (0 until state.playerCount)
.map { (state.turn + it) % state.playerCount }
.filter { owed[it] > 0 }
.map { GoldOwed(it, owed[it]) }
} }
private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState { private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState {
@@ -565,7 +623,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState { private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
if (state.isCk) return CatanCk.applyRobber(state, seat, move) if (state.isCk) return CatanCk.applyRobber(state, seat, move)
val hex = CatanBoard.hexIndex[move.hex] val hex = state.topo.hexIndex[move.hex]
?: throw IllegalArgumentException("No hex at ${move.hex}") ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1) var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
var stolen: String? = null var stolen: String? = null
@@ -599,6 +657,68 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")) return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
} }
private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState {
val free = state.phase == CatanPhase.FREE_ROADS
val sea = state.sea!!
var s = state.copy(
sea = sea.copy(
ships = sea.ships.toMutableList().also { it[move.edge] = seat },
builtThisTurn = sea.builtThisTurn + move.edge,
),
)
if (!free) s = payCost(s, seat, SeaRules.SHIP_COST)
s = refreshLongestRoad(s)
if (free) {
val left = s.pendingFreeRoads - 1
s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
}
return checkWin(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."))
}
private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState {
val sea = state.sea!!
val ships = sea.ships.toMutableList().also {
it[move.from] = NO_PLAYER
it[move.to] = seat
}
// The ship is on a new edge, but it has sailed for the turn and may not sail again.
val s = state.copy(
sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to),
)
return checkWin(refreshLongestRoad(s).let {
it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.")
})
}
private fun applyMovePirate(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 stolen = ""
if (move.victim != null) {
val rng = Random(state.seed * 1_000_003L + state.rngStep)
val victimHand = state.handOf(move.victim)
val card = victimHand[rng.nextInt(victimHand.size)]
val less = withCards(s.resources, move.victim, listOf(card), -1)
s = s.copy(resources = withCards(less, seat, listOf(card), 1))
stolen = " and takes a ${card.name.lowercase()} from seat ${move.victim}"
}
return s.copy(
phase = CatanPhase.PLAYING,
log = s.log + "Seat $seat sends the pirate to (${move.hex.q},${move.hex.r})$stolen.",
)
}
private fun applyPickGold(state: CatanState, seat: Int, move: PickGold): CatanState {
val sea = state.sea!!
val s = state.copy(
resources = withCards(state.resources, seat, move.cards, 1),
sea = sea.copy(gold = sea.gold.drop(1)),
log = state.log + "Seat $seat takes ${describe(countOf(move.cards))} from the gold.",
)
return SeaRules.settleGold(s)
}
private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState { private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState {
var s = state.copy( var s = state.copy(
buildings = state.buildings.toMutableList().also { buildings = state.buildings.toMutableList().also {
@@ -606,6 +726,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}, },
) )
s = payCost(s, seat, SETTLEMENT_COST) s = payCost(s, seat, SETTLEMENT_COST)
s = SeaRules.claimIsland(s, seat, move.vertex)
// A settlement can break another player's road, so the award is // A settlement can break another player's road, so the award is
// recomputed for everyone. // recomputed for everyone.
s = refreshLongestRoad(s) s = refreshLongestRoad(s)
@@ -831,6 +952,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
phase = CatanPhase.AWAIT_ROLL, phase = CatanPhase.AWAIT_ROLL,
devCardPlayedThisTurn = false, devCardPlayedThisTurn = false,
pendingTrade = null, pendingTrade = null,
sea = state.sea?.copy(builtThisTurn = emptyList(), shipMoved = false),
) )
// Someone can cross the target on another player's turn (a broken // Someone can cross the target on another player's turn (a broken
// Longest Road re-awarded to a third player); they win when their // Longest Road re-awarded to a third player); they win when their
@@ -869,6 +991,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
state.longestRoadOwner?.let { points[it] += 5 } state.longestRoadOwner?.let { points[it] += 5 }
state.largestArmyOwner?.let { points[it] += 5 } state.largestArmyOwner?.let { points[it] += 5 }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList() return points.toList()
} }
@@ -927,16 +1050,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* one road, not two — the intersection is a branch). * one road, not two — the intersection is a branch).
*/ */
fun longestRoadFor(state: CatanState, seat: Int): Int { fun longestRoadFor(state: CatanState, seat: Int): Int {
if (state.isSea) return SeaRules.longestRouteFor(state, seat)
var best = 0 var best = 0
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT) val blocked = BooleanArray(state.topo.VERTEX_COUNT)
for (v in 0 until CatanBoard.VERTEX_COUNT) { for (v in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[v] val building = state.buildings[v]
if (building != null && building.owner != seat) blocked[v] = true if (building != null && building.owner != seat) blocked[v] = true
} }
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true } state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
for (start in 0 until CatanBoard.VERTEX_COUNT) { for (start in 0 until state.topo.VERTEX_COUNT) {
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue if (state.topo.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(CatanBoard.VERTEX_COUNT) val visited = BooleanArray(state.topo.VERTEX_COUNT)
best = maxOf(best, roadDfs(state, seat, start, blocked, visited)) best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
} }
return best return best
@@ -951,9 +1075,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
): Int { ): Int {
visited[v] = true visited[v] = true
var best = 0 var best = 0
for (e in CatanBoard.VERTEX_EDGES[v]) { for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != seat) continue if (state.roads[e] != seat) continue
val w = CatanBoard.otherEnd(e, v) val w = state.topo.otherEnd(e, v)
if (visited[w]) continue if (visited[w]) continue
if (blocked[w]) { if (blocked[w]) {
// The segment may run up to an opponent's building, but not // The segment may run up to an opponent's building, but not
@@ -1039,7 +1163,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
for (port in state.ports) { for (port in state.ports) {
if (port.resource != null && port.resource != give) continue if (port.resource != null && port.resource != give) continue
if (port.ratio >= best) continue if (port.ratio >= best) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge] val (a, b) = state.topo.EDGE_VERTICES[port.edge]
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
if (mine) best = port.ratio if (mine) best = port.ratio
} }
@@ -1050,7 +1174,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
fun genericPortRatio(state: CatanState, seat: Int): Int { fun genericPortRatio(state: CatanState, seat: Int): Int {
for (port in state.ports) { for (port in state.ports) {
if (port.resource != null) continue if (port.resource != null) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge] val (a, b) = state.topo.EDGE_VERTICES[port.edge]
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
} }
return 4 return 4
@@ -0,0 +1,144 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.Serializable
import kotlin.random.Random
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
@Serializable
enum class SeaScenario(val title: String, val blurb: String) {
NEW_SHORES(
"Heading for New Shores",
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
),
}
/**
* The Seafarers maps as geometry plus a generator for one game's tiles.
*
* A map is drawn below as text: `.` is sea, `#` a land hex that gets a random resource,
* `$` a gold field, `D` the desert. Odd rows are shifted half a hex to the right (the usual
* "odd-r" offset layout). The outermost ring is always sea, so every coast is closed.
*/
object SeaBoards {
private val NEW_SHORES_MAP = listOf(
"..........",
"...##.....",
"..####.$#.",
".#####..#.",
".##D###...",
"..####..#.",
".......##.",
".$#.......",
"..........",
)
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
}
private val topologies = HashMap<SeaScenario, Topology>()
/** The frame of [scenario]: every hex, with its land mask. Built once and shared. */
fun topology(scenario: SeaScenario): Topology = synchronized(topologies) {
topologies.getOrPut(scenario) { build(scenario) }
}
/** Hex (row, col) of the text map as an axial coordinate (odd-r offset). */
private fun coord(row: Int, col: Int): HexCoord = HexCoord(col - (row - (row and 1)) / 2, row)
private fun build(scenario: SeaScenario): Topology {
val map = rows(scenario)
val hexes = mutableListOf<HexCoord>()
val land = mutableListOf<Boolean>()
map.forEachIndexed { row, text ->
text.forEachIndexed { col, ch ->
hexes += coord(row, col)
land += ch != '.'
}
}
return Topology(hexes, land, standardFrame = false, harbours = 9)
}
/** Terrain kind the map asks of each hex: sea, gold, desert, or "pick a resource". */
private fun kinds(scenario: SeaScenario): List<Char> = rows(scenario).flatMap { it.toList() }
/** One game's tiles on a Seafarers map. */
@Serializable
data class Generated(
val terrains: List<Terrain>,
val numbers: List<Int>,
val robber: Int,
val pirate: Int,
val ports: List<Port>,
val home: Int,
)
private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
)
/** Number tokens: the standard 18 plus ten more, so every land hex but the desert has one. */
private val TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12) +
listOf(3, 4, 5, 6, 8, 9, 10, 11, 5, 9)
fun generate(scenario: SeaScenario, seed: Long): Generated {
val topo = topology(scenario)
val kinds = kinds(scenario)
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 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)]
else -> Terrain.SEA
}
}
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT }
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
val tokens = TOKENS.take(numbered.size).shuffled(random)
val numbers = IntArray(terrains.size)
var placed = false
var attempt = 0
while (!placed && attempt < 200) {
val order = numbered.shuffled(random)
val assigned = IntArray(terrains.size)
order.forEachIndexed { i, h -> assigned[h] = tokens[i] }
val reds = numbered.filter { assigned[it] == 6 || assigned[it] == 8 }
val clash = reds.any { a -> reds.any { b -> a < b && topo.hexesAdjacent(a, b) } }
if (!clash || attempt == 199) {
assigned.copyInto(numbers)
placed = true
}
attempt++
}
val portTypes = (
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
).shuffled(random)
val ports = topo.PORT_POSITIONS.mapIndexed { i, edge ->
val resource = portTypes[i % portTypes.size]
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
}
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
?: numbered.first()
val home = (0 until topo.islandCount).maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
// The pirate starts in the open sea: the sea hex farthest from every shore.
val sea = terrains.indices.filter { terrains[it] == Terrain.SEA }
val pirate = sea.maxByOrNull { h ->
val c = topo.HEXES[h]
terrains.indices.filter { terrains[it] != Terrain.SEA }.minOfOrNull {
Topology.hexDistance(c.q, c.r, topo.HEXES[it].q, topo.HEXES[it].r)
} ?: 0
} ?: sea.first()
return Generated(terrains, numbers.toList(), desert, pirate, ports, home)
}
}
@@ -0,0 +1,205 @@
package org.prolibertate.games.game.catan
/**
* The rules Seafarers adds to the base game, kept apart from [CatanRules] so the base
* game's code reads as it always did. [CatanRules] calls in here only when a game has
* [CatanState.sea]. See `docs/RULES-catan-seafarers.md`.
*/
internal object SeaRules {
const val MAX_SHIPS = 15
/** Points for being the first to settle an island other than the home island. */
const val ISLAND_BONUS = 2
val SHIP_COST: Map<Resource, Int> = mapOf(Resource.LUMBER to 1, Resource.WOOL to 1)
// -----------------------------------------------------------------------
// Ships
// -----------------------------------------------------------------------
private fun fleetOf(state: CatanState, seat: Int): Int = state.sea!!.ships.count { it == seat }
/**
* Whether [seat]'s ships and towns reach [vertex], so a ship may start there. A town
* of anyone else is a wall; [ignoring] is a ship that is being lifted off the board.
*/
private fun shipReaches(state: CatanState, seat: Int, vertex: Int, ignoring: Int): Boolean {
val building = state.buildings[vertex]
if (building != null) return building.owner == seat
val sea = state.sea!!
return state.topo.VERTEX_EDGES[vertex].any { it != ignoring && sea.ships[it] == seat }
}
/** A sea edge a ship could sit on, joined to [seat]'s fleet or a town of theirs. */
private fun shipSites(state: CatanState, seat: Int, ignoring: Int = -1): List<Int> {
val topo = state.topo
val sea = state.sea!!
return (0 until topo.EDGE_COUNT).filter { e ->
topo.edgeSea[e] &&
state.roads[e] == NO_PLAYER &&
(sea.ships[e] == NO_PLAYER || e == ignoring) &&
sea.pirate !in topo.EDGE_HEXES[e] &&
topo.EDGE_VERTICES[e].let { (a, b) ->
shipReaches(state, seat, a, ignoring) || shipReaches(state, seat, b, ignoring)
}
}
}
fun buildShipMoves(state: CatanState, seat: Int): List<BuildShip> =
if (fleetOf(state, seat) >= MAX_SHIPS) emptyList() else shipSites(state, seat).map { BuildShip(it) }
/** Whether the ship on [edge] sits at the open end of a route: one end touches nothing else of [seat]'s. */
private fun atOpenEnd(state: CatanState, seat: Int, edge: Int): Boolean {
val topo = state.topo
val sea = state.sea!!
return topo.EDGE_VERTICES[edge].toList().any { v ->
state.buildings[v] == null &&
topo.VERTEX_EDGES[v].none { it != edge && (state.roads[it] == seat || sea.ships[it] == seat) }
}
}
/** A ship may sail once a turn, never the turn it was built, only from the open end of a route. */
fun moveShipMoves(state: CatanState, seat: Int): List<MoveShip> {
val sea = state.sea!!
if (sea.shipMoved) return emptyList()
val moves = mutableListOf<MoveShip>()
for (e in sea.ships.indices) {
if (sea.ships[e] != seat || e in sea.builtThisTurn || !atOpenEnd(state, seat, e)) continue
shipSites(state, seat, ignoring = e).filter { it != e }.forEach { moves += MoveShip(e, it) }
}
return moves
}
// -----------------------------------------------------------------------
// Pirate
// -----------------------------------------------------------------------
/** Seats with a ship beside [hex] that hold at least one card: whom the pirate can rob there. */
private fun pirateVictims(state: CatanState, thief: Int, hex: Int): List<Int> {
val topo = state.topo
val sea = state.sea!!
return (0 until topo.EDGE_COUNT).filter { hex in topo.EDGE_HEXES[it] }
.map { sea.ships[it] }
.filter { it != NO_PLAYER && it != thief }
.distinct()
.filter { state.resourceCount(it) > 0 }
}
fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> {
val topo = state.topo
val sea = state.sea!!
val moves = mutableListOf<MovePirate>()
for (h in 0 until topo.HEX_COUNT) {
if (topo.land[h] || h == sea.pirate) continue
val victims = pirateVictims(state, seat, h)
if (victims.isEmpty()) {
moves += MovePirate(topo.HEXES[h])
} else {
victims.forEach { moves += MovePirate(topo.HEXES[h], it) }
}
}
return moves
}
// -----------------------------------------------------------------------
// Gold
// -----------------------------------------------------------------------
/** Every way the seat at the front of the gold queue can choose its cards, supply permitting. */
fun goldMoves(state: CatanState): List<PickGold> {
val owed = state.sea!!.gold.firstOrNull() ?: return emptyList()
val supply = Resource.entries.associateWith { CatanRules.bankSupply(state, it) }
val take = minOf(owed.count, supply.values.sum())
val picks = mutableListOf<PickGold>()
fun walk(index: Int, left: Int, acc: MutableList<Resource>) {
if (left == 0) {
picks += PickGold(acc.toList())
return
}
if (index == Resource.entries.size) return
val resource = Resource.entries[index]
for (n in 0..minOf(left, supply.getValue(resource))) {
repeat(n) { acc += resource }
walk(index + 1, left - n, acc)
repeat(n) { acc.removeAt(acc.size - 1) }
}
}
walk(0, take, mutableListOf())
return picks
}
/** Drops queue entries the bank can no longer pay, and leaves the gold phase once nobody is owed. */
fun settleGold(state: CatanState): CatanState {
val sea = state.sea!!
val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) }
val queue = if (supply > 0) sea.gold else emptyList()
return if (queue.isEmpty()) {
state.copy(sea = sea.copy(gold = emptyList()), phase = CatanPhase.PLAYING)
} else {
state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD)
}
}
// -----------------------------------------------------------------------
// 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
/** 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 {
val sea = state.sea ?: return state
val island = state.topo.vertexIsland[vertex]
if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
return state.copy(
sea = sea.copy(islandBonus = bonus),
log = state.log + "Seat $seat is the first to settle a new island: +$ISLAND_BONUS victory points.",
)
}
// -----------------------------------------------------------------------
// Longest route
// -----------------------------------------------------------------------
/**
* The seat's longest route over roads *and* ships. A route is a simple path of the seat's
* pieces that never runs through an opponent's town; it may change between road and ship
* only at a town of the seat's own.
*/
fun longestRouteFor(state: CatanState, seat: Int): Int {
val topo = state.topo
val sea = state.sea!!
val blocked = BooleanArray(topo.VERTEX_COUNT) { v ->
state.buildings[v]?.let { it.owner != seat } == true
}
fun owns(e: Int) = state.roads[e] == seat || sea.ships[e] == seat
fun kind(e: Int) = if (state.roads[e] == seat) 0 else 1
fun walk(v: Int, arrivedBy: Int, visited: BooleanArray): Int {
visited[v] = true
var best = 0
val ownTown = state.buildings[v]?.owner == seat
for (e in topo.VERTEX_EDGES[v]) {
if (!owns(e)) continue
val k = kind(e)
if (arrivedBy != -1 && k != arrivedBy && !ownTown) continue
val w = topo.otherEnd(e, v)
if (visited[w]) continue
best = maxOf(best, if (blocked[w]) 1 else 1 + walk(w, k, visited))
}
visited[v] = false
return best
}
var best = 0
for (start in 0 until topo.VERTEX_COUNT) {
if (topo.VERTEX_EDGES[start].none { owns(it) }) continue
best = maxOf(best, walk(start, -1, BooleanArray(topo.VERTEX_COUNT)))
}
return best
}
}
@@ -365,12 +365,15 @@ internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSe
CatanRuleSet.BASE -> listOf(5, 8, 10, 12) CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
// Cities & Knights is played to thirteen, which is also why it runs longer. // Cities & Knights is played to thirteen, which is also why it runs longer.
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15) CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
// Seafarers adds two points for each new island, so the target is higher.
CatanRuleSet.SEAFARERS -> listOf(8, 10, 12, 14)
} }
/** The published target for a rule set. */ /** The published target for a rule set. */
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) { internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
CatanRuleSet.BASE -> 10 CatanRuleSet.BASE -> 10
CatanRuleSet.CITIES_AND_KNIGHTS -> 13 CatanRuleSet.CITIES_AND_KNIGHTS -> 13
CatanRuleSet.SEAFARERS -> 12
} }
/** /**
@@ -387,6 +390,7 @@ internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): Cat
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) { private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE -> "Base game" CatanRuleSet.BASE -> "Base game"
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights" CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
CatanRuleSet.SEAFARERS -> "Seafarers"
} }
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) { private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
@@ -395,6 +399,9 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.CITIES_AND_KNIGHTS -> CatanRuleSet.CITIES_AND_KNIGHTS ->
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " + "Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
"cards and metropolises. No development cards; the robber sleeps until the first attack." "cards and metropolises. No development cards; the robber sleeps until the first attack."
CatanRuleSet.SEAFARERS ->
"Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " +
"and the first settlement on each new island scores two extra points."
} }
@Composable @Composable
@@ -33,6 +33,7 @@ import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.NO_PLAYER import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.game.catan.Resource import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
/** /**
* The island, drawn and tappable. * The island, drawn and tappable.
@@ -69,9 +70,10 @@ fun CatanBoardView(
) { ) {
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
var size by remember { mutableStateOf(IntSize.Zero) } var size by remember { mutableStateOf(IntSize.Zero) }
val layout = remember(size) { val topo = state.topo
val layout = remember(size, topo) {
if (size.width > 0 && size.height > 0) { if (size.width > 0 && size.height > 0) {
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat()) CatanGeometry.fit(size.width.toFloat(), size.height.toFloat(), topo)
} else { } else {
null null
} }
@@ -85,7 +87,7 @@ fun CatanBoardView(
Canvas( Canvas(
modifier = modifier modifier = modifier
.aspectRatio(CatanGeometry.ASPECT) .aspectRatio(CatanGeometry.aspect(topo))
.onSizeChanged { size = it } .onSizeChanged { size = it }
.semantics { contentDescription = "Katan island board" } .semantics { contentDescription = "Katan island board" }
.pointerInput(layout, targets, style) { .pointerInput(layout, targets, style) {
@@ -97,8 +99,8 @@ fun CatanBoardView(
targets.tappableVertices.isNotEmpty() -> targets.tappableVertices.isNotEmpty() ->
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) } board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
targets.edges.isNotEmpty() -> targets.edges.isNotEmpty() || targets.ships.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) } board.nearestEdge(at, reach, targets.edges + targets.ships)?.let { edgeTap(it) }
targets.hexes.isNotEmpty() -> targets.hexes.isNotEmpty() ->
// A hex is big: accept a tap anywhere near its middle. // A hex is big: accept a tap anywhere near its middle.
@@ -113,9 +115,12 @@ fun CatanBoardView(
drawHexes(state, board, style, measurer) drawHexes(state, board, style, measurer)
drawRolled(rolledHexes, board, style) drawRolled(rolledHexes, board, style)
drawRobber(state, board, style) drawRobber(state, board, style)
drawPirate(state, board, style)
drawMerchant(state, board, style, measurer) drawMerchant(state, board, style, measurer)
drawRoads(state, board, style) drawRoads(state, board, style)
drawShips(state, board, style)
drawEdgeTargets(targets, board, style) drawEdgeTargets(targets, board, style)
drawShipTargets(targets, board, style)
drawBuildings(state, board, style) drawBuildings(state, board, style)
drawWalls(state, board, style) drawWalls(state, board, style)
drawMetropolises(state, board, style) drawMetropolises(state, board, style)
@@ -137,10 +142,15 @@ private fun DrawScope.drawHexes(
style: CatanStyle, style: CatanStyle,
measurer: TextMeasurer, measurer: TextMeasurer,
) { ) {
for (hex in 0 until CatanBoard.HEX_COUNT) { for (hex in 0 until state.topo.HEX_COUNT) {
val centre = board.hexCenter(hex) val centre = board.hexCenter(hex)
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) } val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
val path = polygon(corners) val path = polygon(corners)
if (state.terrains[hex] == Terrain.SEA) {
// Open water: only a faint outline, so ships have a grid to sail on.
drawPath(path, color = style.hexEdge.copy(alpha = 0.12f), style = Stroke(width = board.radius * 0.02f))
continue
}
drawPath(path, color = style.terrain.getValue(state.terrains[hex])) drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f)) drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
@@ -365,7 +375,7 @@ private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fil
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) { private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (edge in 0 until CatanBoard.EDGE_COUNT) { for (edge in 0 until state.topo.EDGE_COUNT) {
val owner = state.roads[edge] val owner = state.roads[edge]
if (owner == NO_PLAYER) continue if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge) val (a, b) = board.edgeEnds(edge)
@@ -391,7 +401,7 @@ private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: Ca
} }
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) { private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (vertex in 0 until CatanBoard.VERTEX_COUNT) { for (vertex in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[vertex] ?: continue val building = state.buildings[vertex] ?: continue
val at = board.vertex(vertex) val at = board.vertex(vertex)
val fill = style.seats[building.owner % style.seats.size] val fill = style.seats[building.owner % style.seats.size]
@@ -666,3 +676,68 @@ private fun DrawScope.drawStaged(vertex: Int?, edge: Int?, seat: Int?, board: Bo
drawCircle(style.targetSelected, radius * 1.9f, at, style = Stroke(width = board.radius * 0.1f)) drawCircle(style.targetSelected, radius * 1.9f, at, style = Stroke(width = board.radius * 0.1f))
} }
} }
// ---------------------------------------------------------------------------
// Seafarers: ships, the pirate
// ---------------------------------------------------------------------------
/** A ship on each sea edge that has one: a hull along the edge with a sail amidships, in the owner's colour. */
private fun DrawScope.drawShips(state: CatanState, board: BoardLayout, style: CatanStyle) {
val sea = state.sea ?: return
for (edge in sea.ships.indices) {
val owner = sea.ships[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.2f)
val to = lerp(a, b, 0.8f)
val width = board.radius * style.roadWidth * 1.15f
val colour = style.seats[owner % style.seats.size]
drawLine(style.pieceOutline, from.offset(), to.offset(), width * 1.5f, StrokeCap.Round)
drawLine(colour, from.offset(), to.offset(), width, StrokeCap.Round)
// The sail: a small triangle standing off the hull, on the screen's upper side.
val mid = lerp(a, b, 0.5f)
val s = board.radius * 0.17f
val sail = Path().apply {
moveTo(mid.x - s * 0.1f, mid.y - s * 1.9f)
lineTo(mid.x + s * 1.1f, mid.y - s * 0.2f)
lineTo(mid.x - s * 0.1f, mid.y - s * 0.2f)
close()
}
drawPath(sail, style.shipSail)
drawPath(sail, style.pieceOutline, style = Stroke(width = board.radius * 0.03f))
}
}
/** A ring round each of the player's ships that can sail, so they read as tappable. */
private fun DrawScope.drawShipTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (edge in targets.ships) {
val mid = board.edgeMidpoint(edge).offset()
drawCircle(style.target, board.radius * 0.38f, mid, style = Stroke(width = board.radius * 0.07f))
}
}
/** The pirate: a black pawn with a flag, drawn beside the middle of its sea hex like the robber. */
private fun DrawScope.drawPirate(state: CatanState, board: BoardLayout, style: CatanStyle) {
val sea = state.sea ?: return
val hex = board.hexCenter(sea.pirate)
val x = hex.x
val y = hex.y + board.radius * 0.1f
val s = board.radius * style.robberSize
val hull = Path().apply {
moveTo(x - s * 1.0f, y)
lineTo(x + s * 1.0f, y)
lineTo(x + s * 0.6f, y + s * 0.7f)
lineTo(x - s * 0.6f, y + s * 0.7f)
close()
}
drawPath(hull, style.pirate)
drawLine(style.pirate, Offset(x, y), Offset(x, y - s * 1.5f), strokeWidth = s * 0.12f)
val flag = Path().apply {
moveTo(x, y - s * 1.5f)
lineTo(x + s * 0.9f, y - s * 1.15f)
lineTo(x, y - s * 0.8f)
close()
}
drawPath(flag, style.pirate)
drawPath(hull, Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f))
}
@@ -138,6 +138,55 @@ fun CatanDiscardDialog(
) )
} }
// ---------------------------------------------------------------------------
// Gold (Seafarers)
// ---------------------------------------------------------------------------
/**
* A gold field paid out: choose the resource cards it gives. Not dismissable: the game
* is waiting. The engine's list says which resources the bank can still pay.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoldDialog(
legal: List<CatanMove>,
onSubmit: (org.prolibertate.games.game.catan.PickGold) -> Unit,
) {
val count = CatanInteraction.goldCount(legal)
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
val selection = picked.asCards()
val move = CatanInteraction.goldMove(legal, selection)
// A resource is on offer if some legal pick names it.
val offered = legal.filterIsInstance<org.prolibertate.games.game.catan.PickGold>()
.flatMap { it.cards }.toSet()
AlertDialog(
onDismissRequest = {},
title = { Text(CatanText.GOLD_TITLE) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gold! Choose $count resource cards (${selection.size} of $count chosen).")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.filter { it in offered }.forEach { resource ->
ResourceCounter(
resource = resource,
count = picked[resource] ?: 0,
max = if (selection.size < count) count else (picked[resource] ?: 0),
onChange = { n -> picked = picked + (resource to n.coerceIn(0, count)) },
)
}
}
}
},
confirmButton = {
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text(CatanText.GOLD_TAKE) }
},
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The robber // The robber
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1,6 +1,7 @@
package org.prolibertate.games.ui.game.catan package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanBoard import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.Topology
import kotlin.math.sqrt import kotlin.math.sqrt
/** A point in board pixels. Plain floats, so this file needs no Compose types. */ /** A point in board pixels. Plain floats, so this file needs no Compose types. */
@@ -24,29 +25,35 @@ class BoardLayout(
val height: Float, val height: Float,
/** Circumradius of one hex, in pixels. */ /** Circumradius of one hex, in pixels. */
val radius: Float, val radius: Float,
/** The board this layout draws: the standard island unless the game is Seafarers. */
val topo: Topology = CatanBoard.base,
) { ) {
private val centreX = width / 2f private val centreX = width / 2f
private val centreY = height / 2f private val centreY = height / 2f
private val unitX = radius * SQRT3 / 2f private val unitX = radius * SQRT3 / 2f
private val unitY = radius / 2f private val unitY = radius / 2f
/** The lattice point drawn at the middle of the box: the middle of the frame. */
private val midX = (topo.lattice[0] + topo.lattice[1]) / 2f
private val midY = (topo.lattice[2] + topo.lattice[3]) / 2f
fun point(latticeX: Int, latticeY: Int): Pt = fun point(latticeX: Int, latticeY: Int): Pt =
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY) Pt(centreX + (latticeX - midX) * unitX, centreY - (latticeY - midY) * unitY)
fun hexCenter(hex: Int): Pt { fun hexCenter(hex: Int): Pt {
val (lx, ly) = CatanBoard.hexCenter(hex) val (lx, ly) = topo.hexCenter(hex)
return point(lx, ly) return point(lx, ly)
} }
fun vertex(vertex: Int): Pt { fun vertex(vertex: Int): Pt {
val (lx, ly) = CatanBoard.VERTEX_POS[vertex] val (lx, ly) = topo.VERTEX_POS[vertex]
return point(lx, ly) return point(lx, ly)
} }
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) } fun hexCorners(hex: Int): List<Pt> = topo.HEX_CORNERS[hex].map { vertex(it) }
fun edgeEnds(edge: Int): Pair<Pt, Pt> { fun edgeEnds(edge: Int): Pair<Pt, Pt> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge] val (a, b) = topo.EDGE_VERTICES[edge]
return vertex(a) to vertex(b) return vertex(a) to vertex(b)
} }
@@ -62,8 +69,15 @@ class BoardLayout(
*/ */
fun portAnchor(edge: Int): Pt { fun portAnchor(edge: Int): Pt {
val mid = edgeMidpoint(edge) val mid = edgeMidpoint(edge)
val dx = mid.x - centreX // Straight out from the middle of the island on the standard board; on a map of
val dy = mid.y - centreY // islands, straight out from the land hex the harbour sits on.
val from = if (topo.EDGE_HEXES[edge].size == 1) {
Pt(centreX, centreY)
} else {
topo.EDGE_HEXES[edge].firstOrNull { topo.land[it] }?.let { hexCenter(it) } ?: Pt(centreX, centreY)
}
val dx = mid.x - from.x
val dy = mid.y - from.y
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f) val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET) return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
} }
@@ -97,23 +111,29 @@ class BoardLayout(
object CatanGeometry { object CatanGeometry {
/** Lattice extents of the island: x spans -5..5, y spans -8..8. */
private const val LATTICE_X_UNITS = 10
private const val LATTICE_Y_UNITS = 16
/** Empty space round the island, in hex radii, for the sea and the harbours. */ /** Empty space round the island, in hex radii, for the sea and the harbours. */
const val MARGIN = 1.75f const val MARGIN = 1.75f
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN /** A Seafarers frame already ends in a ring of sea, so it needs almost none. */
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN private const val FRAME_MARGIN = 0.3f
private fun margin(topo: Topology) = if (topo.land.all { it }) MARGIN else FRAME_MARGIN
private fun widthInRadii(topo: Topology) =
(topo.lattice[1] - topo.lattice[0]) * SQRT3 / 2f + 2 * margin(topo)
private fun heightInRadii(topo: Topology) =
(topo.lattice[3] - topo.lattice[2]) / 2f + 2 * margin(topo)
/** Width ÷ height of the board including its margin, for sizing its box. */ /** Width ÷ height of the board including its margin, for sizing its box. */
val ASPECT: Float = boardWidthInRadii / boardHeightInRadii val ASPECT: Float = widthInRadii(CatanBoard.base) / heightInRadii(CatanBoard.base)
fun aspect(topo: Topology): Float = widthInRadii(topo) / heightInRadii(topo)
/** The largest layout that fits [width] × [height] pixels. */ /** The largest layout that fits [width] × [height] pixels. */
fun fit(width: Float, height: Float): BoardLayout { fun fit(width: Float, height: Float, topo: Topology = CatanBoard.base): BoardLayout {
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii) val radius = minOf(width / widthInRadii(topo), height / heightInRadii(topo))
return BoardLayout(width, height, radius) return BoardLayout(width, height, radius, topo)
} }
} }
@@ -172,6 +172,7 @@ object CatanHudGlyphs {
BuildKind.CITY -> "▲" BuildKind.CITY -> "▲"
BuildKind.WALL -> "▦" BuildKind.WALL -> "▦"
BuildKind.KNIGHT -> "♞" BuildKind.KNIGHT -> "♞"
BuildKind.SHIP -> "⛵"
} }
fun label(kind: BuildKind): String = when (kind) { fun label(kind: BuildKind): String = when (kind) {
@@ -180,6 +181,7 @@ object CatanHudGlyphs {
BuildKind.CITY -> CatanText.CITY BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
} }
} }
@@ -5,6 +5,10 @@ import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.ActivateKnight import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildShip
import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.Topology
import org.prolibertate.games.game.catan.BuildSettlement import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyDevCard import org.prolibertate.games.game.catan.BuyDevCard
@@ -35,6 +39,7 @@ import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.OfferTrade import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PickGold
import org.prolibertate.games.game.catan.PillageCity import org.prolibertate.games.game.catan.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis import org.prolibertate.games.game.catan.PlaceMetropolis
@@ -59,10 +64,16 @@ import org.prolibertate.games.game.catan.Track
* only in Cities & Knights; the base game's build menu lists the first three. * only in Cities & Knights; the base game's build menu lists the first three.
*/ */
enum class BuildKind { enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT; ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
/** Seafarers: a ship, laid on a sea edge. */
SHIP;
companion object { companion object {
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY) 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)
} }
} }
@@ -76,11 +87,14 @@ data class BoardTargets(
val edges: Set<Int> = emptySet(), val edges: Set<Int> = emptySet(),
val hexes: Set<Int> = emptySet(), val hexes: Set<Int> = emptySet(),
val knights: Set<Int> = emptySet(), val knights: Set<Int> = emptySet(),
/** Seafarers: the player's ships that can sail this turn, tapped to pick one up. */
val ships: Set<Int> = emptySet(),
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */ /** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
val idleKnights: Set<Int> = emptySet(), val idleKnights: Set<Int> = emptySet(),
) { ) {
val isEmpty: Boolean val isEmpty: Boolean
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() && idleKnights.isEmpty() get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() &&
idleKnights.isEmpty() && ships.isEmpty()
/** Every crossing a tap can land on. */ /** Every crossing a tap can land on. */
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
@@ -109,8 +123,11 @@ sealed interface TapOutcome {
/** Setup: remember this settlement; the road that goes with it is tapped next. */ /** Setup: remember this settlement; the road that goes with it is tapped next. */
data class PickSetupVertex(val vertex: Int) : TapOutcome data class PickSetupVertex(val vertex: Int) : TapOutcome
/** The robber's hex has more than one possible victim: ask which. */ /** The robber's (or the pirate's) hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>, val pirate: Boolean = false) : TapOutcome
/** Seafarers: one of the player's ships was tapped; its destination is tapped next. */
data class PickShip(val edge: Int) : TapOutcome
/** One of the player's own knights was tapped: open its menu. */ /** One of the player's own knights was tapped: open its menu. */
data class PickKnight(val vertex: Int) : TapOutcome data class PickKnight(val vertex: Int) : TapOutcome
@@ -164,13 +181,14 @@ object CatanInteraction {
fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? { fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? {
if (building == null || outcome !is TapOutcome.Submit) return null if (building == null || outcome !is TapOutcome.Submit) return null
return outcome.move.takeIf { return outcome.move.takeIf {
it is BuildRoad || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight it is BuildRoad || it is BuildShip || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight
} }
} }
/** Where a staged move sits on the board, for drawing its ghost. */ /** Where a staged move sits on the board, for drawing its ghost. */
fun spotOf(move: CatanMove?): Spot = when (move) { fun spotOf(move: CatanMove?): Spot = when (move) {
is BuildRoad -> Spot(edge = move.edge) is BuildRoad -> Spot(edge = move.edge)
is BuildShip -> Spot(edge = move.edge)
is BuildSettlement -> Spot(vertex = move.vertex) is BuildSettlement -> Spot(vertex = move.vertex)
is BuildCity -> Spot(vertex = move.vertex) is BuildCity -> Spot(vertex = move.vertex)
is BuildWall -> Spot(vertex = move.vertex) is BuildWall -> Spot(vertex = move.vertex)
@@ -189,6 +207,8 @@ object CatanInteraction {
armed: ArmedCard? = null, armed: ArmedCard? = null,
/** The local seat, so its sleeping knights can be tapped for their menu too. */ /** The local seat, so its sleeping knights can be tapped for their menu too. */
localSeat: Int? = null, localSeat: Int? = null,
/** Seafarers: the ship that has been picked up and is waiting for a destination. */
movingShip: Int? = null,
): BoardTargets { ): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>() val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) { if (setup.isNotEmpty()) {
@@ -200,8 +220,11 @@ object CatanInteraction {
} }
val robber = legal.filterIsInstance<MoveRobber>() val robber = legal.filterIsInstance<MoveRobber>()
if (robber.isNotEmpty()) { val pirate = legal.filterIsInstance<MovePirate>()
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet()) if (robber.isNotEmpty() || pirate.isNotEmpty()) {
return BoardTargets(
hexes = (robber.map { it.hex } + pirate.map { it.hex }).mapNotNull { state.topo.hexIndex[it] }.toSet(),
)
} }
// Cities & Knights: answers to a queued chore are taps on the board. // Cities & Knights: answers to a queued chore are taps on the board.
@@ -209,7 +232,14 @@ object CatanInteraction {
if (chore.isNotEmpty()) return BoardTargets(vertices = chore) if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet() val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads) val ships = legal.filterIsInstance<BuildShip>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads + ships)
if (movingShip != null) {
return BoardTargets(
edges = legal.filterIsInstance<MoveShip>().filter { it.from == movingShip }.map { it.to }.toSet(),
)
}
if (armed != null) return armedTargets(armed) if (armed != null) return armedTargets(armed)
@@ -221,6 +251,7 @@ object CatanInteraction {
return when (building) { return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads) BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SHIP -> BoardTargets(edges = ships)
BuildKind.SETTLEMENT -> BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet()) BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
@@ -240,7 +271,11 @@ object CatanInteraction {
} else { } else {
emptySet() emptySet()
} }
BoardTargets(knights = ready, idleKnights = mine - ready) BoardTargets(
knights = ready,
idleKnights = mine - ready,
ships = legal.filterIsInstance<MoveShip>().map { it.from }.toSet(),
)
} }
} }
} }
@@ -350,6 +385,15 @@ object CatanInteraction {
// --- Cities & Knights: goods ---------------------------------------------- // --- Cities & Knights: goods ----------------------------------------------
/** The gold pick that names exactly [selection], if the engine allows it. */
fun goldMove(legal: List<CatanMove>, selection: List<Resource>): PickGold? {
val want = selection.groupingBy { it }.eachCount()
return legal.filterIsInstance<PickGold>().firstOrNull { it.cards.groupingBy { c -> c }.eachCount() == want }
}
/** How many cards the gold pick on offer must name. */
fun goldCount(legal: List<CatanMove>): Int = legal.filterIsInstance<PickGold>().firstOrNull()?.cards?.size ?: 0
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */ /** Bank trades on offer in Cities & Knights: what can be given, and for what. */
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> = fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
legal.filterIsInstance<GoodsBankTrade>() legal.filterIsInstance<GoodsBankTrade>()
@@ -432,6 +476,8 @@ object CatanInteraction {
building: BuildKind?, building: BuildKind?,
setupVertex: Int?, setupVertex: Int?,
armed: ArmedCard? = null, armed: ArmedCard? = null,
/** Seafarers: the ship picked up and waiting for a destination. */
movingShip: Int? = null,
): TapOutcome { ): TapOutcome {
if (legal.any { it is SetupPlace }) { if (legal.any { it is SetupPlace }) {
if (setupVertex == null) return TapOutcome.Ignored if (setupVertex == null) return TapOutcome.Ignored
@@ -442,21 +488,51 @@ object CatanInteraction {
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
} }
// Free roads need no build mode; a paid road does. if (movingShip != null) {
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads } val move = MoveShip(movingShip, edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
// Free roads (and free ships) need no build mode; a paid one does.
val free = legal.any { it is EndFreeRoads }
if (building == BuildKind.SHIP || (free && BuildShip(edge) in legal)) {
val move = BuildShip(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
if (building == null && !free) {
// Nothing armed: tapping one of your own ships that can sail picks it up.
return if (legal.any { it is MoveShip && it.from == edge }) TapOutcome.PickShip(edge) else TapOutcome.Ignored
}
val canBuild = building == BuildKind.ROAD || free
if (!canBuild) return TapOutcome.Ignored if (!canBuild) return TapOutcome.Ignored
val move = BuildRoad(edge) val move = BuildRoad(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
} }
fun onHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard? = null): TapOutcome { fun onHexTap(
hex: Int,
legal: List<CatanMove>,
armed: ArmedCard? = null,
/** The board the hex index belongs to (the standard island unless the game is Seafarers). */
topo: Topology = CatanBoard.base,
): TapOutcome {
if (armed != null) return onArmedHexTap(hex, legal, armed) if (armed != null) return onArmedHexTap(hex, legal, armed)
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord } val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
return when { return when {
moves.isEmpty() -> TapOutcome.Ignored moves.isNotEmpty() -> if (moves.size == 1) {
moves.size == 1 -> TapOutcome.Submit(moves.first()) TapOutcome.Submit(moves.first())
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim }) } else {
TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
}
pirates.isNotEmpty() -> if (pirates.size == 1) {
TapOutcome.Submit(pirates.first())
} else {
TapOutcome.PickRobberVictim(coord, pirates.mapNotNull { it.victim }, pirate = true)
}
else -> TapOutcome.Ignored
} }
} }
@@ -494,7 +570,7 @@ object CatanInteraction {
} }
val total = state.lastDice.sum() val total = state.lastDice.sum()
if (total == 7) return emptySet() if (total == 7) return emptySet()
return (0 until CatanBoard.HEX_COUNT) return (0 until state.topo.HEX_COUNT)
.filter { state.numbers[it] == total && it != state.robber } .filter { state.numbers[it] == total && it != state.robber }
.toSet() .toSet()
} }
@@ -507,6 +583,7 @@ object CatanInteraction {
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet { fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD) if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
if (legal.any { it is BuildShip }) add(BuildKind.SHIP)
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT) if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
if (legal.any { it is BuildCity }) add(BuildKind.CITY) if (legal.any { it is BuildCity }) add(BuildKind.CITY)
if (legal.any { it is BuildWall }) add(BuildKind.WALL) if (legal.any { it is BuildWall }) add(BuildKind.WALL)
@@ -597,6 +597,7 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.CITY -> CatanText.CITY BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
} }
private fun buildCost(kind: BuildKind): String = when (kind) { private fun buildCost(kind: BuildKind): String = when (kind) {
@@ -605,6 +606,7 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.CITY -> CatanText.CITY_COST BuildKind.CITY -> CatanText.CITY_COST
BuildKind.WALL -> CatanText.WALL_COST BuildKind.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST BuildKind.KNIGHT -> CatanText.KNIGHT_COST
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
} }
@Composable @Composable
@@ -57,6 +57,7 @@ import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.GiveChore import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.GiveGoods import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.MoveKnight import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.MoveRobber import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PickDeckChore import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.RollDice import org.prolibertate.games.game.catan.RollDice
@@ -112,6 +113,8 @@ fun CatanScreen(
var knightMenu by remember { mutableStateOf<Int?>(null) } var knightMenu by remember { mutableStateOf<Int?>(null) }
var movingKnight by remember { mutableStateOf<Int?>(null) } var movingKnight by remember { mutableStateOf<Int?>(null) }
var armed by remember { mutableStateOf<ArmedCard?>(null) } var armed by remember { mutableStateOf<ArmedCard?>(null) }
// Seafarers: the ship that has been picked up and is waiting for somewhere to sail.
var movingShip by remember { mutableStateOf<Int?>(null) }
var showImprove by remember { mutableStateOf(false) } var showImprove by remember { mutableStateOf(false) }
var showProgress by remember { mutableStateOf(false) } var showProgress by remember { mutableStateOf(false) }
// The landscape HUD: a placement waiting for its ✓, the piece being dragged from the // The landscape HUD: a placement waiting for its ✓, the piece being dragged from the
@@ -141,6 +144,7 @@ fun CatanScreen(
setupVertex = null setupVertex = null
} }
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
if (movingShip != null && legal.none { it is MoveShip && it.from == movingShip }) movingShip = null
armed?.let { card -> if (card.plays.none { it in legal }) armed = null } armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
if (legal.isEmpty()) { if (legal.isEmpty()) {
showCards = false showCards = false
@@ -166,6 +170,7 @@ fun CatanScreen(
showProgress = false showProgress = false
knightMenu = null knightMenu = null
movingKnight = null movingKnight = null
movingShip = null
armed = null armed = null
staged = null staged = null
dragKind = null dragKind = null
@@ -186,13 +191,17 @@ fun CatanScreen(
return return
} }
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat) val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat, movingShip)
val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty() val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
val devHand = CatanInteraction.devHand(current, localSeat, legal) val devHand = CatanInteraction.devHand(current, localSeat, legal)
val progressHand = CatanInteraction.progressHand(current, localSeat, legal) val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions( val actions = CatanActions(
buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE, buildKinds = when {
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
current.isSea -> BuildKind.SEAFARERS
else -> BuildKind.BASE
},
ck = current.isCk, ck = current.isCk,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(), canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable }, canPlayProgress = progressHand.any { it.playable },
@@ -216,6 +225,7 @@ fun CatanScreen(
building = building, building = building,
movingKnight = movingKnight != null, movingKnight = movingKnight != null,
armed = armed?.kind, armed = armed?.kind,
movingShip = movingShip != null,
) )
val handle: (TapOutcome) -> Unit = { outcome -> val handle: (TapOutcome) -> Unit = { outcome ->
@@ -228,6 +238,7 @@ fun CatanScreen(
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome is TapOutcome.PickRobberVictim -> victimChoice = outcome
is TapOutcome.PickKnight -> knightMenu = outcome.vertex is TapOutcome.PickKnight -> knightMenu = outcome.vertex
is TapOutcome.PickShip -> movingShip = outcome.edge
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex) is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
} }
} }
@@ -262,8 +273,8 @@ fun CatanScreen(
), ),
) )
}, },
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) }, onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed, movingShip)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) }, onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed, current.topo)) },
modifier = boardModifier, modifier = boardModifier,
style = style, style = style,
rolledHexes = rolledHexes, rolledHexes = rolledHexes,
@@ -339,7 +350,7 @@ fun CatanScreen(
spot.vertex, legal, building, setupVertex, spot.vertex, legal, building, setupVertex,
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(), ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
) )
spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed) spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed, movingShip)
else -> TapOutcome.Ignored else -> TapOutcome.Ignored
} }
handle(outcome) handle(outcome)
@@ -560,6 +571,9 @@ private fun CatanDialogHost(
onSubmit = onSubmit, onSubmit = onSubmit,
) )
state.phase == CatanPhase.GOLD && CatanRules.currentSeat(state) == localSeat && legal.isNotEmpty() ->
CatanGoldDialog(legal, onSubmit)
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat -> state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
ChoreDialog(state, localSeat, legal, onSubmit) ChoreDialog(state, localSeat, legal, onSubmit)
@@ -595,7 +609,15 @@ private fun CatanDialogHost(
CatanVictimDialog( CatanVictimDialog(
victims = victimChoice.victims, victims = victimChoice.victims,
seats = seats, seats = seats,
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) }, onPick = { victim ->
onSubmit(
if (victimChoice.pirate) {
org.prolibertate.games.game.catan.MovePirate(victimChoice.hex, victim)
} else {
MoveRobber(victimChoice.hex, victim)
},
)
},
onCancel = onCancelVictim, onCancel = onCancelVictim,
) )
@@ -81,6 +81,9 @@ data class CatanStyle(
val citySize: Float = 0.42f, val citySize: Float = 0.42f,
val tokenRadius: Float = 0.36f, val tokenRadius: Float = 0.36f,
val robberSize: Float = 0.34f, val robberSize: Float = 0.34f,
/** Seafarers: the pirate's flag, and the hull and sail of a ship. */
val pirate: Color = Color(0xFF000000),
val shipSail: Color = Color(0xFFFFFFFF),
val portRadius: Float = 0.46f, val portRadius: Float = 0.46f,
val targetRadius: Float = 0.20f, val targetRadius: Float = 0.20f,
/** How close a tap must land to a vertex / edge / hex centre to count. */ /** How close a tap must land to a vertex / edge / hex centre to count. */
@@ -118,6 +121,9 @@ private val LightStyle = CatanStyle(
Terrain.HILLS to Color(0xFFB4532F), Terrain.HILLS to Color(0xFFB4532F),
Terrain.MOUNTAINS to Color(0xFF7C8794), Terrain.MOUNTAINS to Color(0xFF7C8794),
Terrain.DESERT to Color(0xFFE6D3A0), Terrain.DESERT to Color(0xFFE6D3A0),
// Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow.
Terrain.SEA to Color(0xFF3A7CA5),
Terrain.GOLD to Color(0xFFF2B01E),
), ),
hexEdge = Color(0x55000000), hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9), token = Color(0xFFF7EFD9),
@@ -72,6 +72,8 @@ object CatanText {
Terrain.HILLS -> "Hills" Terrain.HILLS -> "Hills"
Terrain.MOUNTAINS -> "Mountains" Terrain.MOUNTAINS -> "Mountains"
Terrain.DESERT -> "Desert" Terrain.DESERT -> "Desert"
Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field"
} }
fun devCard(type: DevCardType): String = when (type) { fun devCard(type: DevCardType): String = when (type) {
@@ -108,6 +110,11 @@ object CatanText {
// --- Buttons and headings -------------------------------------------------- // --- Buttons and headings --------------------------------------------------
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 GOLD_TITLE = "Gold field"
const val GOLD_TAKE = "Take"
const val ROLL = "Roll dice" const val ROLL = "Roll dice"
const val END_TURN = "End turn" const val END_TURN = "End turn"
const val ROAD = "Road" const val ROAD = "Road"
@@ -183,6 +190,8 @@ object CatanText {
movingKnight: Boolean = false, movingKnight: Boolean = false,
/** Cities & Knights: a progress card is waiting for a tap on the board. */ /** Cities & Knights: a progress card is waiting for a tap on the board. */
armed: ProgressType? = null, armed: ProgressType? = null,
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */
movingShip: Boolean = false,
): String { ): String {
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed) if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
val me = seats.me val me = seats.me
@@ -220,10 +229,23 @@ object CatanText {
} }
CatanPhase.ROBBER -> CatanPhase.ROBBER ->
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…" if (mine) {
if (state.isSea) SEA_ROBBER else "Move the robber. Tap a hex."
} else {
"$turn is moving the robber…"
}
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.PLAYING -> when { CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…" !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.ROAD -> "Tap an edge to build a road." building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
@@ -386,6 +408,8 @@ object CatanText {
val ck = state.ck!! val ck = state.ck!!
return when (state.phase) { return when (state.phase) {
CatanPhase.GOLD -> ""
CatanPhase.GAME_OVER -> { CatanPhase.GAME_OVER -> {
val winner = state.winner val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game." if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
@@ -0,0 +1,325 @@
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.assertNotEquals
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
class CatanSeafarersTest {
private val json = Json { encodeDefaults = true }
private fun options(players: Int = 3) =
CatanOptions(playerCount = players, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
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(players: Int = 3, seed: Long = 7L): CatanState =
CatanRules.initialState(config(options(players), seed))
/** A mid-game table on seat 0's turn with plenty of cards. */
private fun playing(seed: Long = 7L, adjust: CatanState.() -> CatanState = { this }): CatanState =
start(seed = seed).copy(
phase = CatanPhase.PLAYING,
turn = 0,
turnNumber = 1,
resources = List(3) { Resource.entries.associateWith { 5 } },
).adjust()
private fun CatanState.settle(vertex: Int, seat: Int, type: BuildingType = BuildingType.SETTLEMENT) =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) })
private fun CatanState.withShip(edge: Int, seat: Int) =
copy(sea = sea!!.let { it.copy(ships = it.ships.toMutableList().also { l -> l[edge] = seat }) })
private fun CatanState.withRoad(edge: Int, seat: Int) =
copy(roads = roads.toMutableList().also { it[edge] = seat })
/** A home-island vertex that touches open water, with a sea edge leaving it. */
private fun CatanState.coastVertex(): Int {
val topo = topo
return (0 until topo.VERTEX_COUNT).first { v ->
topo.vertexIsland[v] == sea!!.home &&
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
}
// --- The map -------------------------------------------------------------
@Test
fun `the new shores map is a big home island and three small ones`() {
val topo = SeaBoards.topology(SeaScenario.NEW_SHORES)
assertEquals(4, topo.islandCount)
val sizes = (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted()
assertEquals(listOf(2, 3, 3, 21), sizes)
// Every coast is closed: nothing on land touches the edge of the frame.
for (e in 0 until topo.EDGE_COUNT) {
if (topo.EDGE_HEXES[e].size == 1) assertFalse("land on the frame edge at $e", topo.land[topo.EDGE_HEXES[e][0]])
}
}
@Test
fun `a new game has gold, a desert, numbers on every other land hex, a pirate at sea and nine harbours`() {
repeat(5) { i ->
val s = start(seed = 100L + i)
val topo = s.topo
assertEquals(2, s.terrains.count { it == Terrain.GOLD })
assertEquals(1, s.terrains.count { it == Terrain.DESERT })
for (h in 0 until topo.HEX_COUNT) {
val land = s.terrains[h] != Terrain.SEA
assertEquals("hex $h", topo.land[h], land)
assertEquals("hex $h", land && s.terrains[h] != Terrain.DESERT, s.numbers[h] != 0)
}
assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate])
assertTrue(s.terrains[s.robber] == Terrain.DESERT)
assertEquals(9, s.ports.size)
// The red numbers are never neighbours.
val reds = (0 until topo.HEX_COUNT).filter { s.numbers[it] == 6 || s.numbers[it] == 8 }
for (a in reds) for (b in reds) if (a < b) assertFalse(topo.hexesAdjacent(a, b))
}
}
@Test
fun `setup places both settlements on the home island, with roads rather than ships`() {
val s = start()
val moves = CatanRules.legalMoves(s, 0).filterIsInstance<SetupPlace>()
assertTrue(moves.isNotEmpty())
assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home })
assertTrue(moves.all { s.topo.edgeLand[it.edge] })
}
@Test
fun `the state survives its wire format`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
}
// --- Ships ---------------------------------------------------------------
@Test
fun `a ship launches from a harbour town onto a sea edge and costs a lumber and a wool`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
val ships = CatanRules.legalMoves(s, 0).filterIsInstance<BuildShip>()
assertTrue(ships.isNotEmpty())
assertTrue(ships.all { s.topo.edgeSea[it.edge] })
val after = CatanRules.applyMove(s, 0, ships.first())
assertEquals(0, after.sea!!.ships.indexOfFirst { it == 0 }.let { if (it == ships.first().edge) 0 else 1 })
assertEquals(4, after.resources[0].getValue(Resource.LUMBER))
assertEquals(4, after.resources[0].getValue(Resource.WOOL))
assertEquals(5, after.resources[0].getValue(Resource.BRICK))
}
@Test
fun `no ship without the cards, and none sails out of nothing`() {
val bare = playing().let { it.settle(it.coastVertex(), 0) }
.let { it.copy(resources = it.resources.toMutableList().also { r -> r[0] = Resource.entries.associateWith { 0 } }) }
assertTrue(CatanRules.legalMoves(bare, 0).none { it is BuildShip })
// Nobody has a town or a fleet: there is nowhere to start from.
val nothing = playing()
assertTrue(CatanRules.legalMoves(nothing, 0).none { it is BuildShip })
}
@Test
fun `the pirate keeps ships off the edges round its hex`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
val ship = CatanRules.legalMoves(s, 0).filterIsInstance<BuildShip>().first()
val seaHex = s.topo.EDGE_HEXES[ship.edge].first { !s.topo.land[it] }
val blocked = s.copy(sea = s.sea!!.copy(pirate = seaHex))
assertTrue(CatanRules.legalMoves(blocked, 0).filterIsInstance<BuildShip>().none { seaHex in s.topo.EDGE_HEXES[it.edge] })
}
@Test
fun `a ship cannot sail the turn it is built, but can the next, from the open end only`() {
val s0 = playing().let { it.settle(it.coastVertex(), 0) }
val first = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first()
val s1 = CatanRules.applyMove(s0, 0, first)
assertTrue(CatanRules.legalMoves(s1, 0).none { it is MoveShip })
// Next turn (after the others pass), the lone ship is at an open end and can sail.
val s2 = s1.copy(sea = s1.sea!!.copy(builtThisTurn = emptyList(), shipMoved = false))
val sails = CatanRules.legalMoves(s2, 0).filterIsInstance<MoveShip>()
assertTrue(sails.isNotEmpty())
assertTrue(sails.all { it.from == first.edge && it.to != first.edge })
// Sailing uses the turn's one move.
val s3 = CatanRules.applyMove(s2, 0, sails.first())
assertTrue(s3.sea!!.shipMoved)
assertEquals(NO_PLAYER, s3.sea!!.ships[first.edge])
assertEquals(0, s3.sea!!.ships[sails.first().to])
assertTrue(CatanRules.legalMoves(s3, 0).none { it is MoveShip })
}
@Test
fun `a ship in the middle of a route is stuck`() {
// Town - ship A - ship B: A has the town at one end and B at the other, so only B is at an open end.
val s0 = playing().let { it.settle(it.coastVertex(), 0) }
val a = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first().edge
var s = s0.withShip(a, 0)
val far = s.topo.EDGE_VERTICES[a].toList().first { s.buildings[it] == null }
val b = s.topo.VERTEX_EDGES[far].first { it != a && s.topo.edgeSea[it] && s.roads[it] == NO_PLAYER }
s = s.withShip(b, 0)
val movers = CatanRules.legalMoves(s, 0).filterIsInstance<MoveShip>().map { it.from }.toSet()
assertEquals(setOf(b), movers)
}
@Test
fun `a settlement can rise at the end of a ship route, and the first on a new island scores the bonus`() {
val base = playing()
val topo = base.topo
val sea = base.sea!!
// A vertex on a small island, and a ship chain reaching it is too long to build by hand,
// so give the player the ship on the edge beside that vertex.
val vertex = (0 until topo.VERTEX_COUNT).first { v ->
val island = topo.vertexIsland[v]
island != -1 && island != sea.home &&
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] }
}
val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeSea[it] }
var s = base.withShip(edge, 0)
assertTrue(CatanRules.legalMoves(s, 0).any { it == BuildSettlement(vertex) })
val before = CatanRules.publicVictoryPoints(s)[0]
s = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(before + 1 + SeaRules.ISLAND_BONUS, CatanRules.publicVictoryPoints(s)[0])
assertEquals(0, s.sea!!.islandBonus[topo.vertexIsland[vertex]])
// A second settlement on that island scores no second bonus.
val again = SeaRules.claimIsland(s, 1, vertex)
assertEquals(0, again.sea!!.islandBonus[topo.vertexIsland[vertex]])
}
@Test
fun `a road and a ship join a route only at a town of your own`() {
val base = playing()
val topo = base.topo
val v = base.coastVertex()
val road = topo.VERTEX_EDGES[v].first { topo.edgeLand[it] }
val ship = topo.VERTEX_EDGES[v].first { topo.edgeSea[it] && !topo.edgeLand[it] }
// With a town at the junction, road + ship is one route of two.
val withTown = base.settle(v, 0).withRoad(road, 0).withShip(ship, 0)
assertEquals(2, SeaRules.longestRouteFor(withTown, 0))
// Without it, they are two separate pieces.
val noTown = base.withRoad(road, 0).withShip(ship, 0)
assertEquals(1, SeaRules.longestRouteFor(noTown, 0))
}
// --- Pirate --------------------------------------------------------------
@Test
fun `on a seven the roller may move the robber to land or the pirate to sea, and the pirate robs a ship's owner`() {
val s0 = playing().let { it.settle(it.coastVertex(), 1) }
val ship = SeaRules.buildShipMoves(s0, 1).first().edge
val s = s0.withShip(ship, 1).copy(phase = CatanPhase.ROBBER)
val moves = CatanRules.legalMoves(s, 0)
assertTrue(moves.any { it is MoveRobber })
assertTrue(moves.any { it is MovePirate })
// Robber moves are on land only.
assertTrue(moves.filterIsInstance<MoveRobber>().all { s.topo.land[s.topo.hexIndex.getValue(it.hex)] })
assertTrue(moves.filterIsInstance<MovePirate>().all { !s.topo.land[s.topo.hexIndex.getValue(it.hex)] })
val robbery = moves.filterIsInstance<MovePirate>().first { it.victim == 1 }
val after = CatanRules.applyMove(s, 0, robbery)
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(s.topo.hexIndex.getValue(robbery.hex), after.sea!!.pirate)
assertEquals(s.resourceCount(0) + 1, after.resourceCount(0))
assertEquals(s.resourceCount(1) - 1, after.resourceCount(1))
}
// --- Gold ----------------------------------------------------------------
@Test
fun `gold pays a resource of the owner's choice`() {
val base = playing()
val topo = base.topo
val goldHex = base.terrains.indexOf(Terrain.GOLD)
val number = base.numbers[goldHex]
val v = topo.HEX_CORNERS[goldHex].first { topo.vertexLand[it] }
// Seat 0's town beside the gold field; nothing else of theirs produces on that number.
val s = base.settle(v, 0).copy(
robber = (0 until topo.HEX_COUNT).first { topo.land[it] && it != goldHex && v !in topo.HEX_CORNERS[it] },
resources = List(3) { Resource.entries.associateWith { 0 } },
phase = CatanPhase.AWAIT_ROLL,
)
val dice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6)
val rolled = CatanRules.applyMove(s, 0, RollDice(forced = dice))
assertEquals(CatanPhase.GOLD, rolled.phase)
assertEquals(0, CatanRules.currentSeat(rolled))
val picks = CatanRules.legalMoves(rolled, 0)
assertEquals(Resource.entries.size, picks.size)
val after = CatanRules.applyMove(rolled, 0, PickGold(listOf(Resource.ORE)))
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(1, after.resources[0].getValue(Resource.ORE))
}
// --- Whole games ---------------------------------------------------------
private data class Report(val seed: Long, val moves: Int, val ships: Int, val islandsTaken: Int, val points: List<Int>)
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 20_000): Pair<CatanState, Report> {
val ai = CatanAi()
var state = CatanRules.initialState(config(options, seed))
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("seed $seed ran past $cap moves; log tail: ${state.log.takeLast(8)}", moves++ < cap)
val seat = CatanRules.currentSeat(state)
?: error("seed $seed: nobody on the clock in ${state.phase}; log tail: ${state.log.takeLast(8)}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("seed $seed: seat $seat has no legal move in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue(
"seed $seed: seat $seat chose the illegal move $move in ${state.phase}; log tail: ${state.log.takeLast(5)}",
CatanRules.isLegalMove(state, seat, move),
)
val next = CatanRules.applyMove(state, seat, move)
// The wire format must carry everything, every step.
if (moves % 25 == 0) assertEquals(next, CatanRules.decodeState(CatanRules.encodeState(next)))
state = next
}
val sea = state.sea!!
return state to Report(
seed, moves,
ships = sea.ships.count { it != NO_PLAYER },
islandsTaken = sea.islandBonus.count { it != NO_PLAYER },
points = CatanRules.publicVictoryPoints(state),
)
}
@Test
fun `engines finish Seafarers games at three and four players`() {
val reports = mutableListOf<Report>()
for ((players, seeds) in listOf(3 to listOf(1L, 2L, 3L, 4L), 4 to listOf(5L, 6L, 7L))) {
for (seed in seeds) {
val (state, report) = playOut(options(players), seed)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
val winner = state.winner
assertTrue("seed $seed finished without a winner", winner != null)
assertTrue(
"seed $seed: the winner fell short of the target",
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
)
reports += report
}
}
println("Seafarers self-play: " + reports.joinToString("; ") {
"seed ${it.seed}: ${it.moves} moves, ${it.ships} ships, ${it.islandsTaken} islands, points ${it.points}"
})
// The engines must actually go to sea in at least some of the games.
assertNotEquals("no engine ever built a ship", 0, reports.sumOf { it.ships })
}
}
@@ -0,0 +1,149 @@
package org.prolibertate.games.ui.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.catan.BuildShip
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanBuilding
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.PickGold
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** What the screen offers on a Seafarers board: ships, the pirate, and gold. */
class CatanSeafarersInteractionTest {
private fun fresh(): CatanState {
val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
return CatanRules.initialState(
TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
optionsJson = Json { encodeDefaults = true }.encodeToString(options),
seed = 7L,
),
)
}
/** Seat 0, mid-game, a harbour town on the coast of the home island and plenty of cards. */
private fun harbour(): CatanState {
val s = fresh().copy(
phase = CatanPhase.PLAYING,
turn = 0,
turnNumber = 1,
resources = List(3) { Resource.entries.associateWith { 5 } },
)
val topo = s.topo
val v = (0 until topo.VERTEX_COUNT).first { vertex ->
topo.vertexIsland[vertex] == s.sea!!.home &&
topo.VERTEX_EDGES[vertex].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
return s.copy(buildings = s.buildings.toMutableList().also { it[v] = CatanBuilding(BuildingType.SETTLEMENT, 0) })
}
@Test
fun `the ship is on the build menu, and arming it lights the sea edges a ship can take`() {
val s = harbour()
val legal = CatanRules.legalMoves(s, 0)
assertTrue(BuildKind.SHIP in CatanInteraction.canBuild(legal))
val targets = CatanInteraction.targets(s, legal, BuildKind.SHIP, null)
assertEquals(legal.filterIsInstance<BuildShip>().map { it.edge }.toSet(), targets.edges)
val edge = targets.edges.first { !s.topo.edgeLand[it] }
assertEquals(
TapOutcome.Submit(BuildShip(edge)),
CatanInteraction.onEdgeTap(edge, legal, BuildKind.SHIP, null),
)
// The same tap with the road armed means nothing: a road cannot lie between two sea hexes.
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, BuildKind.ROAD, null))
// A build tap waits for ✓ like any other piece.
assertEquals(BuildShip(edge), CatanInteraction.stageable(TapOutcome.Submit(BuildShip(edge)), BuildKind.SHIP))
}
@Test
fun `tapping a ship that can sail picks it up, and the next tap on a lit sea edge sails it`() {
val s0 = harbour()
val built = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first()
val s = CatanRules.applyMove(s0, 0, built)
.let { it.copy(sea = it.sea!!.copy(builtThisTurn = emptyList())) }
val legal = CatanRules.legalMoves(s, 0)
val idle = CatanInteraction.targets(s, legal, null, null)
assertEquals(setOf(built.edge), idle.ships)
assertEquals(TapOutcome.PickShip(built.edge), CatanInteraction.onEdgeTap(built.edge, legal, null, null))
val sailing = CatanInteraction.targets(s, legal, null, null, movingShip = built.edge)
val to = sailing.edges.first()
assertEquals(
TapOutcome.Submit(MoveShip(built.edge, to)),
CatanInteraction.onEdgeTap(to, legal, null, null, movingShip = built.edge),
)
}
@Test
fun `after a seven a land hex moves the robber and a sea hex moves the pirate`() {
val s = harbour().copy(phase = CatanPhase.ROBBER)
val legal = CatanRules.legalMoves(s, 0)
val targets = CatanInteraction.targets(s, legal, null, null)
val land = targets.hexes.first { s.terrains[it] != Terrain.SEA }
val sea = targets.hexes.first { s.terrains[it] == Terrain.SEA }
val robber = CatanInteraction.onHexTap(land, legal, topo = s.topo)
assertTrue(robber is TapOutcome.Submit && robber.move is MoveRobber)
val pirate = CatanInteraction.onHexTap(sea, legal, topo = s.topo)
assertTrue(pirate is TapOutcome.Submit && pirate.move is MovePirate)
}
@Test
fun `a gold pick is found by the cards it names`() {
val picks = listOf(PickGold(listOf(Resource.ORE)), PickGold(listOf(Resource.WOOL)))
assertEquals(PickGold(listOf(Resource.WOOL)), CatanInteraction.goldMove(picks, listOf(Resource.WOOL)))
assertEquals(null, CatanInteraction.goldMove(picks, listOf(Resource.WOOL, Resource.WOOL)))
assertEquals(1, CatanInteraction.goldCount(picks))
}
@Test
fun `the engines' own choices are always something the screen can make`() {
// Every legal move in a played-out game is reachable: a tap, a button or a dialog choice.
val ai = CatanAi()
var s = fresh()
var n = 0
while (!CatanRules.isFinished(s) && n++ < 600) {
val seat = CatanRules.currentSeat(s) ?: break
val legal = CatanRules.legalMoves(s, seat)
val ships = legal.filterIsInstance<BuildShip>()
if (s.phase == CatanPhase.PLAYING && ships.isNotEmpty()) {
val armed = CatanInteraction.targets(s, legal, BuildKind.SHIP, null)
ships.forEach { ship ->
assertTrue("ship ${ship.edge} not lit", ship.edge in armed.edges)
assertEquals(TapOutcome.Submit(ship), CatanInteraction.onEdgeTap(ship.edge, legal, BuildKind.SHIP, null))
}
}
legal.filterIsInstance<MoveShip>().forEach { sail ->
val lit = CatanInteraction.targets(s, legal, null, null, movingShip = sail.from)
assertTrue(sail.to in lit.edges)
assertEquals(
TapOutcome.Submit(sail),
CatanInteraction.onEdgeTap(sail.to, legal, null, null, movingShip = sail.from),
)
}
s = CatanRules.applyMove(s, seat, ai.chooseMove(s, seat, legal))
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
| Golf | [RULES-golf.md](RULES-golf.md) | | Golf | [RULES-golf.md](RULES-golf.md) |
| Hearts | — (TODO) | | Hearts | — (TODO) |
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) | | Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) | | Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule sets, [RULES-catan-ck.md](RULES-catan-ck.md) and [RULES-catan-seafarers.md](RULES-catan-seafarers.md) |
| Klondike | — (TODO) | | Klondike | — (TODO) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) | | Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
| Meowdoku | [RULES-meowdoku.md](RULES-meowdoku.md) | | Meowdoku | [RULES-meowdoku.md](RULES-meowdoku.md) |
+71
View File
@@ -0,0 +1,71 @@
# Katan: Seafarers
The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. It is the base
game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to
settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map),
`CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea`
(null in the other rule sets). It cannot be combined with Cities & Knights.
Where the published rules leave room, or where this game differs, it says so below.
## The map
One map, *Heading for New Shores*: a large **home island** (21 hexes, one desert) and three small
islands (3, 3 and 2 hexes) across the water, inside a ring of sea. Which land hexes are gold, which
are the desert and where the sea lies is fixed; the **resources on the other hexes, the number tokens
and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring
hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14).
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching
it is land and a ship if any hex touching it is sea (so a coastal edge takes either, never both).
## What changes
**Setup.** Both starting settlements (and their roads) go on the home island. A starting piece is a
road, never a ship. The second settlement collects from the hexes round it as usual (gold pays nothing
at setup).
**Ships.** A ship costs 1 lumber and 1 wool and lies on a sea edge.
* It must join your fleet or a town of yours: one end of the edge touches your town, or another of your ships.
An opponent's town is a wall.
* Roads and ships connect **only at a town of your own**: a road cannot start from a ship's end, nor a
ship from a road's, unless a settlement or city of yours stands on that crossing. A settlement may be
built at the end of a ship route exactly as at the end of a road route.
* No more than 15 ships. Road Building may lay two roads, two ships or one of each.
* **Sailing.** Once per turn you may move one ship, never one built or moved this turn, and only a ship at the
**open end** of a route (one end touches nothing else of yours). It goes to any edge it could be built on
with it lifted off.
**The pirate.** Starts in the open sea. On a seven (and with a Knight card) the player moves either the
robber, to any land hex, or the pirate, to any sea hex. The robber works as before. The pirate **blocks the six
edges round its hex**: no ship may be built or sail onto them (ships already there stay). It robs one card
at random from a player with a ship on an edge of its hex.
**Gold.** A gold field has a number like any hex. When it comes up, each town beside it (one card; a city two)
pays its owner **any resource of their choice**, chosen after the ordinary production is dealt, in seat order from
the roller. The robber blocks a gold field like any hex. If the bank cannot supply the choice it is limited to
what it holds.
**New islands.** The first player to settle on each island other than the home island scores **2 bonus points**,
permanently (`SeaState.islandBonus`). A second settlement there earns nothing extra.
**Longest route.** Longest Road counts roads and ships together, as a path of your pieces that never runs
through an opponent's town and changes between road and ship only at a town of yours. Five or more, as before.
Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game.
## Not included
The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**:
fog islands, treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations,
the scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map
is a new text map and a new enum entry.
## 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
not sail ships it has already launched. See `CatanAi.shipValue`.
+16
View File
@@ -192,3 +192,19 @@ Landscape screens use the control scheme of Catan Universe's match view, redrawn
ratio under the *give* card. An offer made to you opens the same panel read-only: ✓ accepts, ratio under the *give* card. An offer made to you opens the same panel read-only: ✓ accepts,
✕ declines, *Counter…* makes the rows editable. ✕ declines, *Counter…* makes the rows editable.
Cities & Knights goods trades and the counter-offer chooser still use dialogs. Cities & Knights goods trades and the counter-offer chooser still use dialogs.
## Seafarers on the screen
| Layer | What it adds |
| --- | --- |
| `CatanGeometry.kt` | `BoardLayout` and `CatanGeometry.fit` take the game's `Topology`, so a map of islands fits the box like the standard island does |
| `CatanStyle.kt` | gold-field colour, the pirate's and the ship sail's colours |
| `CatanBoardView.kt` | sea hexes (a faint grid, no fill), ships (hull with a sail), the pirate (a flagged hull), a ring on ships that can sail |
| `CatanInteraction.kt` | `BuildKind.SHIP`; `PickShip`, `movingShip`, ship targets; the robber's and the pirate's hexes; `goldMove` |
| `CatanDialogs.kt` | `CatanGoldDialog`, the choice of what a gold field pays |
| `CatanScreen.kt` | the picked-up ship, the ship on the build menu / column |
**Interaction model.** Arm *Ship* (or drag it from the build column) and tap a lit sea edge: ✓ places it. To sail,
tap one of your ships that has a ring, then a lit sea edge. After a seven, tap a land hex for the robber or a sea
hex for the pirate. A gold field opens a dialog that cannot be dismissed. `CatanSeafarersInteractionTest` checks
that every build and sail the engine offers has a tap that produces it.