Compare commits

...
Author SHA1 Message Date
Claude 4ed110e68c Production release: build and ship a non-debuggable release APK
CI / Build and check (pull_request) Successful in 12m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
release.yml shipped app-debug.apk, so production was debuggable. It now runs
assembleRelease, signed with the same keystore (identical certificate, so
updates still install over existing ones), with isDebuggable = false.
Minification stays off: R8 cannot be tried on a device here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-07 20:15:24 +00:00
devadmin b818997699 Merge pull request 'End game on every card game; Resume game menu' (#27) from feature/end-game-and-resume into dev
CI / Build and check (push) Successful in 9m35s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
CI / Build and check (pull_request) Successful in 9m21s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-07 12:35:01 -06:00
Claude aeda2924f4 End-game button on every card game; Resume game menu
CI / Build and check (pull_request) Successful in 9m33s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
President, Crazy Eights, Cribbage, Euchre, Golf, Kaiser, Sequence and Wizard
gain the "End game" button and the left-the-table dialog the other games have.
The main menu's inline Continue list becomes a Resume game button opening a
screen of all saved games, each with Resume and Delete (confirmed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-07 18:25:11 +00:00
devadmin 2e234c7f20 Merge pull request 'Katan: barbarians and improvement levels in the wide HUD' (#26) from fix/katan-landscape-hud into dev
CI / Build and check (push) Successful in 9m4s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-29 10:12:26 -06:00
Claude f4f53a93b6 Katan: show barbarian strip and improvement levels in the wide HUD
CI / Build and check (pull_request) Successful in 8m47s
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 16:03:31 +00:00
devadmin bce9b68ece Merge pull request 'Katan: Seafarers rule set' (#14) from Seafarers into dev
CI / Build and check (push) Successful in 9m12s
CI / Build and check (pull_request) Successful in 9m16s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-10-28 21:27:34 -06:00
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
36 changed files with 2064 additions and 200 deletions
+3 -3
View File
@@ -49,8 +49,8 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v4
- name: Build debug APK
run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Build release APK
run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: Stage APK for release
id: stage
@@ -65,7 +65,7 @@ jobs:
test -n "$offset"
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
version="v${series}.$(( ${{ github.run_number }} + offset ))"
src="app/build/outputs/apk/debug/app-debug.apk"
src="app/build/outputs/apk/release/app-release.apk"
dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest"
# The same build again under a name that never changes. GitHub's
+8 -2
View File
@@ -52,9 +52,15 @@ integration:
## Katan
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
sets chosen at setup: the base game (`docs/RULES-catan.md`) 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
`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.
## Bash output is filtered before it reaches the model
+5
View File
@@ -104,6 +104,11 @@ android {
buildTypes {
release {
// Signed with the same key as the debug builds, so a release installs
// over what is already on a device; a different key would make the
// updater's installs fail with a signature mismatch.
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -34,6 +34,11 @@ class CatanAi : GameAi<CatanState, CatanMove> {
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.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
@@ -70,9 +75,10 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* worth is what it reaches past it.
*/
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
val far = CatanBoard.otherEnd(place.edge, place.vertex)
return CatanBoard.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
val topo = state.topo
val far = topo.otherEnd(place.edge, place.vertex)
return topo.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in topo.VERTEX_NEIGHBORS[place.vertex] }
.maxOfOrNull { spotValue(state, it) }
?: 0.0
}
@@ -94,9 +100,9 @@ class CatanAi : GameAi<CatanState, CatanMove> {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val hex = CatanBoard.hexIndex.getValue(move.hex)
val hex = state.topo.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
for (v in state.topo.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
if (building.owner == seat) continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
@@ -124,6 +130,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
spotValue(state, it.vertex)
}?.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
// opponent's best hex and marches toward Largest Army.
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
@@ -142,6 +154,12 @@ class CatanAi : GameAi<CatanState, CatanMove> {
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.
legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.1 }
@@ -301,13 +319,13 @@ class CatanAi : GameAi<CatanState, CatanMove> {
if (state.buildings[vertex] != null) return -1.0
var value = 0.0
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
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
kinds += resource
}
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
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 {
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 (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
for (near in state.topo.VERTEX_NEIGHBORS[endpoint]) {
best = maxOf(best, spotValue(state, near) * 0.8)
}
}
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]. */
internal fun want(resource: Resource): Double = when (resource) {
Resource.ORE -> 1.20 // the city
@@ -5,13 +5,14 @@ import kotlin.math.atan2
import kotlin.random.Random
/**
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
* shared edges, the coast and the nine harbour positions.
* The geometry of one board: its hexes, the shared vertices and edges between
* them, and which hexes are land.
*
* Everything is derived once, deterministically, from axial coordinates —
* 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
* live state lives in [CatanState].
* game (which terrain and number sits on which hex) is generated elsewhere
* ([CatanBoard.generate], [SeaBoards.generate]), and the live state lives in
* [CatanState].
*
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
* 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
* in half-units of the hex grid; rendering scales and flips them (lattice
* +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 {
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
val HEXES: List<HexCoord> = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
}
class Topology(
/** Every hex of the frame, land and sea alike. */
val HEXES: List<HexCoord>,
/** Whether each hex is land. The base island is all land; Seafarers maps are mostly sea. */
val land: List<Boolean> = List(HEXES.size) { true },
/** The standard 19-hex island: checks its counts and uses the fixed nine-harbour frame. */
private val standardFrame: Boolean = false,
/** Harbours wanted on a non-standard map. */
harbours: Int = 0,
) {
val HEX_COUNT: Int = HEXES.size
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
// -----------------------------------------------------------------------
@@ -93,9 +78,28 @@ object CatanBoard {
/** The coastal edges (those with only one hex) in ring order. */
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>
// --- 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 {
// Vertices: assign ids in first-seen order over hexes then corners.
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
// 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()
.filter { it.value.size == 1 }
.map { (e, _) -> 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" }
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" }
vertexLand = List(VERTEX_COUNT) { v -> VERTEX_HEXES[v].any { land[it] } }
edgeLand = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { land[it] } }
edgeSea = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { !land[it] } }
// Islands: connected groups of land hexes.
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. */
@@ -207,19 +283,14 @@ object CatanBoard {
/** Whether two hexes share an edge. */
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
/** 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))
/** Lattice extent of the whole frame: min x, max x, min y, max y. */
val lattice: IntArray by lazy {
intArrayOf(
VERTEX_POS.minOf { it.first }, VERTEX_POS.maxOf { it.first },
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> {
val (a, b) = EDGE_VERTICES[edge]
val pa = VERTEX_POS[a]
@@ -230,6 +301,84 @@ object CatanBoard {
private fun angleOf(point: Pair<Double, Double>): Double =
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
// -----------------------------------------------------------------------
@@ -273,6 +273,9 @@ object CatanCk {
CatanPhase.FREE_ROADS ->
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
// Seafarers is never combined with Cities & Knights.
CatanPhase.GOLD -> emptyList()
CatanPhase.TRADE_PENDING -> {
val offer = state.ck!!.goodsOffer
if (offer == null) {
@@ -35,7 +35,7 @@ internal class CatanCkAi(private val ai: CatanAi) {
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}")
}
@@ -39,6 +39,12 @@ enum class Terrain(val produces: Resource?) {
HILLS(Resource.BRICK),
MOUNTAINS(Resource.ORE),
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]. */
@@ -114,6 +120,9 @@ enum class CatanPhase {
/** The player whose turn it is must roll the dice. */
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. */
DISCARDING,
@@ -150,6 +159,9 @@ enum class CatanRuleSet {
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS,
/** Seafarers: ships, a pirate, gold fields and islands to settle (see `docs/RULES-catan-seafarers.md`). */
SEAFARERS,
}
@Serializable
@@ -230,9 +242,17 @@ data class CatanState(
val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */
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
/** 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. */
val isCk: Boolean get() = ck != null
@@ -377,3 +397,48 @@ data object EndTurn : CatanMove
@Serializable
@SerialName("endFreeRoads")
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"
}
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
// produces both a fixed island and a fixed deck.
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
@@ -79,8 +84,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
numbers = board.numbers,
robber = board.robber,
ports = board.ports,
buildings = List(CatanBoard.VERTEX_COUNT) { null },
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER },
buildings = List(topo.VERTEX_COUNT) { null },
roads = List(topo.EDGE_COUNT) { NO_PLAYER },
resources = List(options.playerCount) {
Resource.entries.associateWith { 0 }
},
@@ -98,10 +103,19 @@ object CatanRules : GameRules<CatanState, CatanMove> {
rngStep = 0,
winner = null,
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.",
),
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 }
CatanPhase.TRADE_COUNTERS -> state.turn
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
CatanPhase.GOLD -> state.sea?.gold?.firstOrNull()?.seat
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn
@@ -162,12 +177,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
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.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)
@@ -179,10 +199,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
internal fun setupMoves(state: CatanState): List<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
for (e in CatanBoard.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER) continue
// Seafarers: everyone starts on the home island.
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)
}
}
@@ -215,18 +237,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val moves = mutableListOf<MoveRobber>()
for (h in 0 until CatanBoard.HEX_COUNT) {
if (h == state.robber) continue
val victims = CatanBoard.HEX_CORNERS[h]
for (h in 0 until state.topo.HEX_COUNT) {
if (h == state.robber || !state.topo.land[h]) continue
val victims = state.topo.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] }
.map { it.owner }
.filter { it != seat }
.distinct()
.filter { state.resourceCount(it) > 0 }
if (victims.isEmpty()) {
moves += MoveRobber(CatanBoard.HEXES[h])
moves += MoveRobber(state.topo.HEXES[h])
} else {
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
}
}
return moves
@@ -245,6 +267,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
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) {
for (card in state.devHands[seat]) {
@@ -301,8 +327,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList()
} else {
(0 until CatanBoard.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
(0 until state.topo.EDGE_COUNT)
.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) }
}
@@ -316,9 +346,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
(0 until state.topo.VERTEX_COUNT).filter { 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) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
(0 until state.topo.VERTEX_COUNT).filter { v ->
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. */
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && !hasKnight(state, v) &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/**
* 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.
*/
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)
}
@@ -397,7 +427,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (knight != null && knight.owner != seat) return false
// Your own building is always a way in; anyone else's is a wall.
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 {
@@ -423,6 +453,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is RollDice -> applyRoll(state, seat, move)
is Discard -> applyDiscard(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 BuildSettlement -> applyBuildSettlement(state, seat, move)
is BuildCity -> applyBuildCity(state, seat, move)
@@ -461,7 +495,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (second) {
// The second settlement earns one card from each adjacent
// 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) ->
need <= bankSupply(state, res)
}
@@ -517,11 +551,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
for (resource in Resource.entries) {
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 (h == state.robber) 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 amount = if (building.type == BuildingType.CITY) 2 else 1
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)
val whoGot = mutableListOf<String>()
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))
whoGot += "seat $seat takes ${describe(got)}"
}
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ")
return s.copy(
val goldLine = goldOwed.joinToString("") { ", seat ${it.seat} is owed ${it.count} gold" }
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.",
)
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 {
@@ -565,7 +623,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
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}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
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}."))
}
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 {
var s = state.copy(
buildings = state.buildings.toMutableList().also {
@@ -606,6 +726,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
},
)
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
// recomputed for everyone.
s = refreshLongestRoad(s)
@@ -831,6 +952,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
phase = CatanPhase.AWAIT_ROLL,
devCardPlayedThisTurn = false,
pendingTrade = null,
sea = state.sea?.copy(builtThisTurn = emptyList(), shipMoved = false),
)
// Someone can cross the target on another player's turn (a broken
// 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.largestArmyOwner?.let { points[it] += 5 }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList()
}
@@ -927,16 +1050,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* one road, not two — the intersection is a branch).
*/
fun longestRoadFor(state: CatanState, seat: Int): Int {
if (state.isSea) return SeaRules.longestRouteFor(state, seat)
var best = 0
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT)
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val blocked = BooleanArray(state.topo.VERTEX_COUNT)
for (v in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[v]
if (building != null && building.owner != seat) blocked[v] = true
}
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
for (start in 0 until CatanBoard.VERTEX_COUNT) {
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
for (start in 0 until state.topo.VERTEX_COUNT) {
if (state.topo.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(state.topo.VERTEX_COUNT)
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
}
return best
@@ -951,9 +1075,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
): Int {
visited[v] = true
var best = 0
for (e in CatanBoard.VERTEX_EDGES[v]) {
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != seat) continue
val w = CatanBoard.otherEnd(e, v)
val w = state.topo.otherEnd(e, v)
if (visited[w]) continue
if (blocked[w]) {
// 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) {
if (port.resource != null && port.resource != give) 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
if (mine) best = port.ratio
}
@@ -1050,7 +1174,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
fun genericPortRatio(state: CatanState, seat: Int): Int {
for (port in state.ports) {
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
}
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
}
}
@@ -88,6 +88,9 @@ sealed interface Route {
data class Section(val menu: GameMenu) : Route
data object Settings : Route
data object Scorekeeper : Route
/** Every saved game, to carry on from or delete. */
data object Resume : Route
data class Setup(val gameId: String) : Route
data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route
data class Play(
@@ -188,10 +191,8 @@ private fun AppContent(
// Dev builds show the games that are not finished yet; production
// releases list only what can actually be played.
showComingSoon = showComingSoon,
saved = savedGames(env, stack),
onResume = { saved ->
push(Route.Play(saved.gameId, saved.config, true, saved.localSeat, saved.stateJson))
},
hasSaved = savedGames(env, stack).isNotEmpty(),
onResume = { push(Route.Resume) },
onPickSection = { push(Route.Section(it)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onScorekeeper = { push(Route.Scorekeeper) },
@@ -206,6 +207,25 @@ private fun AppContent(
onBack = pop,
)
is Route.Resume -> {
// Bumped when a game is deleted, so the list is read again.
var deletions by remember(route) { mutableIntStateOf(0) }
val saved by produceState(emptyList<SavedMatch>(), route, deletions) {
value = withContext(Dispatchers.IO) { env.saves.all() }
}
ResumeScreen(
saved = saved,
onResume = { match ->
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
},
onDelete = { match ->
env.saves.clear(match.gameId)
deletions++
},
onBack = pop,
)
}
is Route.Settings -> SettingsScreen(
env = env,
settings = settings,
@@ -271,8 +291,8 @@ private fun AppContent(
@Composable
fun MainMenuScreen(
showComingSoon: Boolean,
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
hasSaved: Boolean,
onResume: () -> Unit,
onPickSection: (GameMenu) -> Unit,
onJoinGame: () -> Unit,
onScorekeeper: () -> Unit,
@@ -322,20 +342,11 @@ fun MainMenuScreen(
)
}
// Games left half-played, most recent first, above everything
// else: coming back to the app is mostly coming back to one.
if (saved.isNotEmpty()) {
Text(
"Continue",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.fillMaxWidth(),
)
val now = System.currentTimeMillis()
saved.forEach { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Button(onClick = { onResume(match) }, modifier = Modifier.fillMaxWidth()) {
Text(savedLine(title, match, now))
}
// Games left half-played, above everything else: coming back to
// the app is mostly coming back to one.
if (hasSaved) {
Button(onClick = onResume, modifier = Modifier.fillMaxWidth()) {
Text("Resume game")
}
}
@@ -365,12 +365,15 @@ internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSe
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
// 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. */
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
CatanRuleSet.BASE -> 10
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) {
CatanRuleSet.BASE -> "Base game"
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
CatanRuleSet.SEAFARERS -> "Seafarers"
}
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
@@ -395,6 +399,9 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.CITIES_AND_KNIGHTS ->
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
"cards and metropolises. No development cards; the robber sleeps until the first attack."
CatanRuleSet.SEAFARERS ->
"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
@@ -0,0 +1,87 @@
package org.prolibertate.games.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.SavedMatch
/**
* Every game that can be carried on from, most recently played first, each with
* a way to pick it up and a way to throw it away.
*/
@Composable
fun ResumeScreen(
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
onDelete: (SavedMatch) -> Unit,
onBack: () -> Unit,
) {
var deleting by remember { mutableStateOf<SavedMatch?>(null) }
ScreenScaffold(title = "Resume game", onBack = onBack) { modifier ->
if (saved.isEmpty()) {
Text("No games to resume.", modifier = modifier)
} else {
val now = System.currentTimeMillis()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(saved, key = { it.gameId }) { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(title, fontWeight = FontWeight.Bold)
Text(
buildString {
append("Saved ${agoText(now, match.savedAtMillis)}")
if (match.summary.isNotBlank()) append(" · ${match.summary}")
},
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onResume(match) }) { Text("Resume") }
TextButton(onClick = { deleting = match }) { Text("Delete") }
}
}
}
}
}
}
}
deleting?.let { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Delete this game?") },
text = { Text("Your saved game of $title will be removed and cannot be resumed.") },
confirmButton = {
TextButton(onClick = { deleting = null; onDelete(match) }) { Text("Delete") }
},
dismissButton = { TextButton(onClick = { deleting = null }) { Text("Keep it") } },
)
}
}
@@ -60,6 +60,22 @@ fun CrazyEightsScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
// Playing an eight is two decisions: the card, then the suit it turns the
@@ -68,13 +84,15 @@ fun CrazyEightsScreen(
val current = state
if (current == null) {
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
@@ -65,11 +65,28 @@ fun CribbageScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
@@ -79,7 +96,8 @@ fun CribbageScreen(
// together. Forgotten whenever a new hand is dealt.
var chosen by remember(current.handNumber) { mutableStateOf(emptySet<Card>()) }
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
@@ -72,18 +73,36 @@ fun EuchreScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.17f else 0.12f)
@@ -69,18 +69,36 @@ fun GolfScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
var mode by remember { mutableStateOf(PlaceMode.SWAP) }
val current = state
if (current == null) {
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val shortest = minOf(maxWidth, maxHeight)
val ownCard = (shortest * 0.16f).coerceIn(40.dp, 84.dp)
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -74,18 +75,36 @@ fun KaiserScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.15f else 0.11f)
@@ -20,11 +20,15 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -66,16 +70,34 @@ fun PresidentScreen(
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val current = state
if (current == null) {
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
dialogs()
}
return
}
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.15f).coerceIn(44.dp, 88.dp)
@@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -81,12 +82,29 @@ fun SequenceScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
var selected by remember { mutableStateOf<Card?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
@@ -97,7 +115,8 @@ fun SequenceScreen(
val removals = legal.filterIsInstance<RemoveChip>().filter { it.card == selected }
val highlighted = (placements.map { it.cell } + removals.map { it.cell }).toSet()
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight
val boardSide = if (wide) {
@@ -75,18 +75,36 @@ fun WizardScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.14f).coerceIn(40.dp, 88.dp)
@@ -33,6 +33,7 @@ import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
/**
* The island, drawn and tappable.
@@ -69,9 +70,10 @@ fun CatanBoardView(
) {
val measurer = rememberTextMeasurer()
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) {
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat())
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat(), topo)
} else {
null
}
@@ -85,7 +87,7 @@ fun CatanBoardView(
Canvas(
modifier = modifier
.aspectRatio(CatanGeometry.ASPECT)
.aspectRatio(CatanGeometry.aspect(topo))
.onSizeChanged { size = it }
.semantics { contentDescription = "Katan island board" }
.pointerInput(layout, targets, style) {
@@ -97,8 +99,8 @@ fun CatanBoardView(
targets.tappableVertices.isNotEmpty() ->
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
targets.edges.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
targets.edges.isNotEmpty() || targets.ships.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges + targets.ships)?.let { edgeTap(it) }
targets.hexes.isNotEmpty() ->
// A hex is big: accept a tap anywhere near its middle.
@@ -113,9 +115,12 @@ fun CatanBoardView(
drawHexes(state, board, style, measurer)
drawRolled(rolledHexes, board, style)
drawRobber(state, board, style)
drawPirate(state, board, style)
drawMerchant(state, board, style, measurer)
drawRoads(state, board, style)
drawShips(state, board, style)
drawEdgeTargets(targets, board, style)
drawShipTargets(targets, board, style)
drawBuildings(state, board, style)
drawWalls(state, board, style)
drawMetropolises(state, board, style)
@@ -137,10 +142,15 @@ private fun DrawScope.drawHexes(
style: CatanStyle,
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 corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
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.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) {
for (edge in 0 until CatanBoard.EDGE_COUNT) {
for (edge in 0 until state.topo.EDGE_COUNT) {
val owner = state.roads[edge]
if (owner == NO_PLAYER) continue
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) {
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
for (vertex in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[vertex] ?: continue
val at = board.vertex(vertex)
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))
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
@@ -1,6 +1,7 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.Topology
import kotlin.math.sqrt
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
@@ -24,29 +25,35 @@ class BoardLayout(
val height: Float,
/** Circumradius of one hex, in pixels. */
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 centreY = height / 2f
private val unitX = radius * SQRT3 / 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 =
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
Pt(centreX + (latticeX - midX) * unitX, centreY - (latticeY - midY) * unitY)
fun hexCenter(hex: Int): Pt {
val (lx, ly) = CatanBoard.hexCenter(hex)
val (lx, ly) = topo.hexCenter(hex)
return point(lx, ly)
}
fun vertex(vertex: Int): Pt {
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
val (lx, ly) = topo.VERTEX_POS[vertex]
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> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
val (a, b) = topo.EDGE_VERTICES[edge]
return vertex(a) to vertex(b)
}
@@ -62,8 +69,15 @@ class BoardLayout(
*/
fun portAnchor(edge: Int): Pt {
val mid = edgeMidpoint(edge)
val dx = mid.x - centreX
val dy = mid.y - centreY
// Straight out from the middle of the island on the standard board; on a map of
// 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)
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
}
@@ -97,23 +111,29 @@ class BoardLayout(
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. */
const val MARGIN = 1.75f
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
/** A Seafarers frame already ends in a ring of sea, so it needs almost none. */
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. */
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. */
fun fit(width: Float, height: Float): BoardLayout {
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii)
return BoardLayout(width, height, radius)
fun fit(width: Float, height: Float, topo: Topology = CatanBoard.base): BoardLayout {
val radius = minOf(width / widthInRadii(topo), height / heightInRadii(topo))
return BoardLayout(width, height, radius, topo)
}
}
@@ -172,6 +172,7 @@ object CatanHudGlyphs {
BuildKind.CITY -> "▲"
BuildKind.WALL -> "▦"
BuildKind.KNIGHT -> "♞"
BuildKind.SHIP -> "⛵"
}
fun label(kind: BuildKind): String = when (kind) {
@@ -180,6 +181,7 @@ object CatanHudGlyphs {
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
}
}
@@ -311,6 +313,10 @@ fun CatanHud(
) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
HudPlayerStrip(state, seats, pointsOf, style)
// Cities & Knights: the ship and the improvement levels are as much part
// of the game as the scores, so they sit with them rather than in a panel
// this layout does not have room for.
if (state.isCk) CatanBarbarians(state, Modifier.padding(horizontal = 8.dp), style)
Box(Modifier.weight(1f).fillMaxWidth()) {
// The island fills the whole area; controls float over its sea margin.
ZoomPanBox(Modifier.fillMaxSize().padding(start = 64.dp, end = 64.dp, top = 30.dp, bottom = 44.dp)) {
@@ -451,6 +457,16 @@ private fun HudPlayerStrip(
Modifier.size(14.dp).clip(CircleShape).background(style.seats[seat]).border(1.dp, style.pieceOutline, CircleShape),
)
Text(seats.label(seat), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, modifier = Modifier.weight(1f))
// Trade, politics, science: the three improvement levels.
if (state.isCk) {
Text(
text = org.prolibertate.games.game.catan.Track.entries.joinToString(" ") {
"${it.name.first()}${org.prolibertate.games.game.catan.CatanCk.improvement(state, seat, it)}"
},
fontSize = 12.sp,
maxLines = 1,
)
}
Text("${pointsOf(seat)} VP · ${state.resourceCount(seat)}🂠", fontSize = 12.sp, maxLines = 1)
}
}
@@ -5,6 +5,10 @@ import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildShip
import org.prolibertate.games.game.catan.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.BuildWall
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.OfferGoods
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.PlaceKnight
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.
*/
enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
/** Seafarers: a ship, laid on a sea edge. */
SHIP;
companion object {
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
/** Seafarers adds the ship, listed after the road it sails beside. */
val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, SETTLEMENT, CITY)
}
}
@@ -76,11 +87,14 @@ data class BoardTargets(
val edges: Set<Int> = emptySet(),
val hexes: 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. */
val idleKnights: Set<Int> = emptySet(),
) {
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. */
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. */
data class PickSetupVertex(val vertex: Int) : TapOutcome
/** The robber's hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
/** 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>, 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. */
data class PickKnight(val vertex: Int) : TapOutcome
@@ -164,13 +181,14 @@ object CatanInteraction {
fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? {
if (building == null || outcome !is TapOutcome.Submit) return null
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. */
fun spotOf(move: CatanMove?): Spot = when (move) {
is BuildRoad -> Spot(edge = move.edge)
is BuildShip -> Spot(edge = move.edge)
is BuildSettlement -> Spot(vertex = move.vertex)
is BuildCity -> Spot(vertex = move.vertex)
is BuildWall -> Spot(vertex = move.vertex)
@@ -189,6 +207,8 @@ object CatanInteraction {
armed: ArmedCard? = null,
/** The local seat, so its sleeping knights can be tapped for their menu too. */
localSeat: Int? = null,
/** Seafarers: the ship that has been picked up and is waiting for a destination. */
movingShip: Int? = null,
): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
@@ -200,8 +220,11 @@ object CatanInteraction {
}
val robber = legal.filterIsInstance<MoveRobber>()
if (robber.isNotEmpty()) {
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
val pirate = legal.filterIsInstance<MovePirate>()
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.
@@ -209,7 +232,14 @@ object CatanInteraction {
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
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)
@@ -221,6 +251,7 @@ object CatanInteraction {
return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SHIP -> BoardTargets(edges = ships)
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
@@ -240,7 +271,11 @@ object CatanInteraction {
} else {
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 ----------------------------------------------
/** 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. */
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
legal.filterIsInstance<GoodsBankTrade>()
@@ -432,6 +476,8 @@ object CatanInteraction {
building: BuildKind?,
setupVertex: Int?,
armed: ArmedCard? = null,
/** Seafarers: the ship picked up and waiting for a destination. */
movingShip: Int? = null,
): TapOutcome {
if (legal.any { it is SetupPlace }) {
if (setupVertex == null) return TapOutcome.Ignored
@@ -442,21 +488,51 @@ object CatanInteraction {
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
// Free roads need no build mode; a paid road does.
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
if (movingShip != null) {
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
val move = BuildRoad(edge)
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)
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 pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
return when {
moves.isEmpty() -> TapOutcome.Ignored
moves.size == 1 -> TapOutcome.Submit(moves.first())
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
moves.isNotEmpty() -> if (moves.size == 1) {
TapOutcome.Submit(moves.first())
} 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()
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 }
.toSet()
}
@@ -507,6 +583,7 @@ object CatanInteraction {
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
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 BuildCity }) add(BuildKind.CITY)
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.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
}
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.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
}
@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.GiveGoods
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.PickDeckChore
import org.prolibertate.games.game.catan.RollDice
@@ -112,6 +113,8 @@ fun CatanScreen(
var knightMenu by remember { mutableStateOf<Int?>(null) }
var movingKnight by remember { mutableStateOf<Int?>(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 showProgress by remember { mutableStateOf(false) }
// The landscape HUD: a placement waiting for its ✓, the piece being dragged from the
@@ -141,6 +144,7 @@ fun CatanScreen(
setupVertex = 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 }
if (legal.isEmpty()) {
showCards = false
@@ -166,6 +170,7 @@ fun CatanScreen(
showProgress = false
knightMenu = null
movingKnight = null
movingShip = null
armed = null
staged = null
dragKind = null
@@ -186,13 +191,17 @@ fun CatanScreen(
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 devHand = CatanInteraction.devHand(current, localSeat, legal)
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
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,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable },
@@ -216,6 +225,7 @@ fun CatanScreen(
building = building,
movingKnight = movingKnight != null,
armed = armed?.kind,
movingShip = movingShip != null,
)
val handle: (TapOutcome) -> Unit = { outcome ->
@@ -228,6 +238,7 @@ fun CatanScreen(
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
is TapOutcome.PickShip -> movingShip = outcome.edge
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
}
}
@@ -262,8 +273,8 @@ fun CatanScreen(
),
)
},
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed, movingShip)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed, current.topo)) },
modifier = boardModifier,
style = style,
rolledHexes = rolledHexes,
@@ -339,7 +350,7 @@ fun CatanScreen(
spot.vertex, legal, building, setupVertex,
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
}
handle(outcome)
@@ -560,6 +571,9 @@ private fun CatanDialogHost(
onSubmit = onSubmit,
)
state.phase == CatanPhase.GOLD && CatanRules.currentSeat(state) == localSeat && legal.isNotEmpty() ->
CatanGoldDialog(legal, onSubmit)
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
ChoreDialog(state, localSeat, legal, onSubmit)
@@ -595,7 +609,15 @@ private fun CatanDialogHost(
CatanVictimDialog(
victims = victimChoice.victims,
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,
)
@@ -81,6 +81,9 @@ data class CatanStyle(
val citySize: Float = 0.42f,
val tokenRadius: Float = 0.36f,
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 targetRadius: Float = 0.20f,
/** 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.MOUNTAINS to Color(0xFF7C8794),
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),
token = Color(0xFFF7EFD9),
@@ -72,6 +72,8 @@ object CatanText {
Terrain.HILLS -> "Hills"
Terrain.MOUNTAINS -> "Mountains"
Terrain.DESERT -> "Desert"
Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field"
}
fun devCard(type: DevCardType): String = when (type) {
@@ -108,6 +110,11 @@ object CatanText {
// --- 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 END_TURN = "End turn"
const val ROAD = "Road"
@@ -183,6 +190,8 @@ object CatanText {
movingKnight: Boolean = false,
/** Cities & Knights: a progress card is waiting for a tap on the board. */
armed: ProgressType? = null,
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */
movingShip: Boolean = false,
): String {
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
val me = seats.me
@@ -220,10 +229,23 @@ object CatanText {
}
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 {
!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.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
@@ -386,6 +408,8 @@ object CatanText {
val ck = state.ck!!
return when (state.phase) {
CatanPhase.GOLD -> ""
CatanPhase.GAME_OVER -> {
val winner = state.winner
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) |
| Hearts | — (TODO) |
| 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) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.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,
✕ declines, *Counter…* makes the rows editable.
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.