From 3a930f542344f00a358ee61c2585121b5910d55b Mon Sep 17 00:00:00 2001 From: adrianoftyriel <12343778+adrianoftyriel@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:26:16 +0000 Subject: [PATCH 01/19] Katan rules engine + AI (WALA-119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The island game as a pure-Kotlin state machine under game/catan/, wired to the upstream GameRules / GameAi contract and fully unit-tested on the JVM. - game/catan/: CatanModel (state, moves, phases), CatanBoard (19-hex geometry, seeded island generation with the 6/8 separation rule, shared vertex/edge graph), CatanRules (setup, production, the seven and the robber, domestic and maritime trade, building, development cards, Longest Road, Largest Army, win detection), CatanAi (a heuristic opponent that reads only public information). - GameRules.isLegalMove: a defaulted structural legality check so a game with a combinatorial move space (Catan's trade offers) is gated the same way as one with an enumerated move list; MatchController uses it. - GameCatalog.CATAN: the catalogue entry, listed but not yet available (descriptor and screen land with the screen task). - Tests: 51 across CatanBoardTest, CatanRulesTest and CatanAiTest — geometry invariants, generation balance, setup, production, the seven/robber, both trade types, development-card timing, Longest Road edge cases, Largest Army, win detection, and AI games played to a result. - docs/RULES-catan.md, in the per-game style, with the rule choices and the known gaps called out. Two real engine bugs found and fixed while getting the suite green: VERTEX_EDGES counted each shared edge once per adjacent hex, so an interior vertex looked like it had six edges (which also double-counted segments in the Longest Road search and duplicated setup moves); and the applyRoll seven-transition was a discarded expression, so a seven never left AWAIT_ROLL. Three test fixtures rolled from the PLAYING phase and two assumed a two-seat rotation; corrected to the phases and seat order the engine actually uses. Verified: ./gradlew :app:testDebugUnitTest --offline — 679 tests, 0 failures. Co-Authored-By: Paperclip --- README.md | 3 + .../prolibertate/games/game/catan/CatanAi.kt | 319 ++++++ .../games/game/catan/CatanBoard.kt | 322 +++++++ .../games/game/catan/CatanModel.kt | 307 ++++++ .../games/game/catan/CatanRules.kt | 905 ++++++++++++++++++ .../prolibertate/games/game/engine/Engine.kt | 15 + .../prolibertate/games/net/MatchController.kt | 4 +- .../games/game/catan/CatanAiTest.kt | 145 +++ .../games/game/catan/CatanBoardTest.kt | 192 ++++ .../games/game/catan/CatanRulesTest.kt | 861 +++++++++++++++++ docs/README.md | 1 + docs/RULES-catan.md | 304 ++++++ 12 files changed, 3376 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/catan/CatanBoard.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanBoardTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt create mode 100644 docs/RULES-catan.md diff --git a/README.md b/README.md index 451c55f..2074948 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,8 @@ game (index: [docs/README.md](docs/README.md)): - [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**: the game is out of print and the rulebook could not be obtained, so the document says which parts are attested and which were derived +- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the + rules engine and AI are in place, the screen lands with its own task ## Layout @@ -348,6 +350,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested mastermind/ Mastermind codes, scoring, and a code breaker pirates/ Pirates and Bulgars — the cross board, the hunt, search tayu/ Ta Yü tile geometry, river rules, scoring, AI + catan/ Katan island geometry, production, trade, dev cards, heuristic AI net/ wire protocol, LAN transport, lobby, match driver score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store settings/ DataStore-backed preferences diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt new file mode 100644 index 0000000..c8d0a69 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt @@ -0,0 +1,319 @@ +package org.prolibertate.games.game.catan + +import org.prolibertate.games.game.engine.GameAi + +/** + * A heuristic Catan opponent. + * + * It plays a legal, mildly strategic game: it settles on the best-producing + * intersections it can reach, raises cities, chases Largest Army with its + * knights, spends its progress cards when they complete something, trades + * its surplus to the bank or the table to close a one-card gap, and robs + * whoever is winning. + * + * Honesty is a design constraint, not an accident: this runs on the host, + * where the authoritative state is fully visible. It reads only public + * information — terrain, numbers, buildings, roads, resource counts, + * dev-card counts, knights played — plus its own hand, and never looks + * into the deck, another seat's development cards, or the seed beyond the + * roll the rules derive from it. + */ +class CatanAi : GameAi { + + override fun chooseMove(state: CatanState, seat: Int, legal: List): CatanMove { + require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) { + "No legal move for seat $seat" + } + return when (state.phase) { + CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> + chooseSetup(state, seat, legal.filterIsInstance()) + + CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice } + + CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance()) + + CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance()) + + CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal) + + CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal) + + CatanPhase.PLAYING -> choosePlaying(state, seat, legal) + + CatanPhase.GAME_OVER -> legal.firstOrNull() + ?: error("The game is over; nobody chooses a move") + } + } + + // ----------------------------------------------------------------------- + // Setup + // ----------------------------------------------------------------------- + + private fun chooseSetup(state: CatanState, seat: Int, places: List): SetupPlace { + require(places.isNotEmpty()) { "No legal settlement placement" } + return places.maxByOrNull { place -> + spotValue(state, place.vertex) + + 0.25 * placeDirection(state, place) + } ?: places.first() + } + + /** + * Where the setup road points: the best settlement spot two edges out + * through the far end of the road. The vertex next to the new + * settlement can never hold one (the distance rule), so what a road is + * 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] } + .maxOfOrNull { spotValue(state, it) } + ?: 0.0 + } + + // ----------------------------------------------------------------------- + // Dice, discards and the robber + // ----------------------------------------------------------------------- + + private fun chooseDiscard(state: CatanState, seat: Int, discards: List): Discard { + require(discards.isNotEmpty()) { "No legal discard" } + // Throw away what it wants least; grain and ore are the city, wool + // is mostly a dev-card tax. + return discards.minByOrNull { discard -> + discard.cards.sumOf { want(it) } + } ?: discards.first() + } + + private fun chooseRobber(state: CatanState, seat: Int, moves: List): MoveRobber { + require(moves.isNotEmpty()) { "No legal robber move" } + val points = CatanRules.publicVictoryPoints(state) + return moves.maxByOrNull { move -> + val hex = CatanBoard.hexIndex.getValue(move.hex) + var score = 0.0 + for (v in CatanBoard.HEX_CORNERS[hex]) { + val building = state.buildings[v] ?: continue + if (building.owner == seat) continue + val worth = if (building.type == BuildingType.CITY) 2 else 1 + score += worth * CatanBoard.numberOdds(state.numbers[hex]) / 36.0 + } + if (move.victim != null) { + score += 0.15 * state.resourceCount(move.victim) + + 0.3 * points[move.victim] + } + score + } ?: moves.first() + } + + // ----------------------------------------------------------------------- + // The main phase + // ----------------------------------------------------------------------- + + private fun choosePlaying(state: CatanState, seat: Int, legal: List): CatanMove { + // A city doubles the best production the seat already owns. + legal.filterIsInstance().maxByOrNull { + spotValue(state, it.vertex) + }?.let { return it } + + legal.filterIsInstance().maxByOrNull { + spotValue(state, it.vertex) + }?.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().firstOrNull()?.let { return it } + + yearOfPlenty(state, legal)?.let { return it } + + monopoly(state, legal)?.let { return it } + + // Road Building is worth two free segments whenever there is + // somewhere to put them. + if (legal.filterIsInstance().isNotEmpty() && + legal.filterIsInstance().size >= 2 + ) { + return legal.filterIsInstance().first() + } + + legal.filterIsInstance().firstOrNull()?.let { return it } + + // A paid road only when it reaches toward a spot worth settling. + legal.filterIsInstance() + .filter { roadValue(state, it) > 0.1 } + .maxByOrNull { roadValue(state, it) } + ?.let { return it } + + // A bank trade only when it completes a build this turn; trading + // four for one on spec is how hands evaporate. + legal.filterIsInstance() + .filter { completesBuild(state, seat, it) } + .maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 } + ?.let { return it } + + offer(state, seat)?.let { return it } + + return legal.lastOrNull { it is EndTurn } ?: legal.first() + } + + /** Year of Plenty: two of whatever the nearest build is shortest of. */ + private fun yearOfPlenty(state: CatanState, legal: List): PlayYearOfPlenty? { + val takes = legal.filterIsInstance() + if (takes.isEmpty()) return null + val shortage = shortage(state) ?: return null + return takes.maxByOrNull { take -> + take.take.count { it == shortage } * 2 + take.take.size + } + } + + /** Monopoly: declare the resource everyone else is hoarding, of the ones wanted. */ + private fun monopoly(state: CatanState, legal: List): PlayMonopoly? { + val plays = legal.filterIsInstance() + if (plays.isEmpty()) return null + val best = plays.maxByOrNull { + 19 - CatanRules.bankSupply(state, it.resource) + want(it.resource) + } ?: return null + // Only worth the card when the table is actually holding a pile. + return if (19 - CatanRules.bankSupply(state, best.resource) >= 10) best else null + } + + /** What the seat is shortest of for its next city, else its next settlement. */ + private fun shortage(state: CatanState): Resource? { + val seat = state.turn + val targets = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST) + for (cost in targets) { + val missing = cost.entries.filter { (res, need) -> + (state.resources[seat][res] ?: 0) < need + } + if (missing.isNotEmpty()) { + return missing.maxByOrNull { (res, need) -> need - (state.resources[seat][res] ?: 0) }?.key + } + } + return null + } + + private fun completesBuild(state: CatanState, seat: Int, move: MaritimeTrade): Boolean { + val hand = state.resources[seat].toMutableMap() + val ratio = CatanRules.bestPortRatio(state, seat, move.give) + hand[move.give] = (hand[move.give] ?: 0) - ratio + hand[move.get] = (hand[move.get] ?: 0) + 1 + return covers(hand, CatanRules.CITY_COST) || covers(hand, CatanRules.SETTLEMENT_COST) + } + + private fun covers(hand: Map, cost: Map): Boolean = + cost.all { (res, need) -> (hand[res] ?: 0) >= need } + + /** + * A domestic offer for exactly the card that closes a build, paid for + * with two of the surplus — offered to whoever visibly holds the most + * of what is missing. Constructed rather than enumerated (see + * [OfferTrade]), so it is checked against the rules before it is spoken. + * + * At most one offer a turn: a declined offer changes nothing, so + * re-offering it would only bounce between the same two seats forever. + */ + private fun offer(state: CatanState, seat: Int): OfferTrade? { + if (offeredThisTurn(state, seat)) return null + val seatHand = state.resources[seat] + val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST) + for (cost in builds) { + val missing = cost.entries.filter { (res, need) -> (seatHand[res] ?: 0) < need } + if (missing.size != 1) continue + val need = missing.first().key + val surplus = Resource.entries + .filter { it != need } + .filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) } + .sortedByDescending { seatHand[it] ?: 0 } + if (surplus.isEmpty()) continue + val target = (0 until state.playerCount) + .filter { it != seat } + .filter { (state.resources[it][need] ?: 0) > 0 } + .maxByOrNull { state.resources[it][need] ?: 0 } + ?: continue + val give = listOf(surplus.first(), surplus.first()) + val trade = OfferTrade(to = target, give = give, get = listOf(need)) + if (CatanRules.isLegalMove(state, seat, trade)) return trade + } + return null + } + + /** + * Whether [seat] has already put an offer on the table this turn, read + * from the public log: the lines after this seat's last roll are this + * turn's, and an offer can only be spoken on the offerer's own turn. + */ + private fun offeredThisTurn(state: CatanState, seat: Int): Boolean { + val sinceRoll = state.log + .drop(state.log.indexOfLast { it.startsWith("Seat $seat rolls") } + 1) + return sinceRoll.any { it.startsWith("Seat $seat offers") } + } + + // ----------------------------------------------------------------------- + // Free roads and trade responses + // ----------------------------------------------------------------------- + + private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List): CatanMove { + val roads = legal.filterIsInstance() + .filter { roadValue(state, it) > 0.05 } + .maxByOrNull { roadValue(state, it) } + return roads ?: legal.firstOrNull { it is EndFreeRoads } + ?: legal.filterIsInstance().firstOrNull() + ?: legal.first() + } + + private fun chooseResponse(state: CatanState, seat: Int, legal: List): CatanMove { + val accept = legal.filterIsInstance().firstOrNull() ?: run { + return legal.first { it is DeclineTrade } + } + val offer = state.pendingTrade ?: return legal.first() + // Accept when what arrives is worth more than what leaves: a mild + // table, not a shark. + val incoming = offer.get.sumOf { want(it) } + val outgoing = offer.give.sumOf { want(it) } + return if (incoming > outgoing) accept else legal.first { it is DeclineTrade } + } + + // ----------------------------------------------------------------------- + // Valuations + // ----------------------------------------------------------------------- + + /** What a seat would think of building on [vertex]: production and ports. */ + private fun spotValue(state: CatanState, vertex: Int): Double { + if (state.buildings[vertex] != null) return -1.0 + var value = 0.0 + val kinds = mutableSetOf() + for (h in CatanBoard.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]) { + val port = state.ports.firstOrNull { it.edge == e } ?: continue + value += if (port.resource == null) 0.1 else 0.2 + } + return value + } + + /** + * What a road on this edge leads to: the best settlement spot it brings + * within reach, discounted for being one step further out. + */ + private fun roadValue(state: CatanState, move: BuildRoad): Double { + var best = 0.0 + val (from, to) = CatanBoard.EDGE_VERTICES[move.edge] + for (endpoint in listOf(from, to)) { + for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) { + best = maxOf(best, spotValue(state, near) * 0.8) + } + } + return best + } + + /** How much the AI wants to hold one card of [resource]. */ + private fun want(resource: Resource): Double = when (resource) { + Resource.ORE -> 1.20 // the city + Resource.GRAIN -> 1.15 // the city and the dev cards + Resource.LUMBER -> 1.0 // every road and settlement + Resource.BRICK -> 0.95 + Resource.WOOL -> 0.85 // abundant and only half of a dev card + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanBoard.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanBoard.kt new file mode 100644 index 0000000..d3fac50 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanBoard.kt @@ -0,0 +1,322 @@ +package org.prolibertate.games.game.catan + +import kotlinx.serialization.Serializable +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. + * + * 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]. + * + * Coordinates: hexes are addressed axially ((q, r), pointy-top). Every + * position is kept on an exact integer lattice so vertices shared by + * neighbouring hexes are recognised by position instead of by floating + * point: the centre of hex (q, r) is at lattice point (2q + r, 3r), and its + * 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). + */ +object CatanBoard { + + /** The 19 hexes: every (q, r) within distance 2 of the centre. */ + val HEXES: List = buildList { + for (r in -2..2) { + for (q in -2..2) { + if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r)) + } + } + } + + val HEX_COUNT: Int = HEXES.size + + val hexIndex: Map = 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 + // ----------------------------------------------------------------------- + + /** Lattice position of each vertex, by vertex id. */ + val VERTEX_POS: List> + + val VERTEX_COUNT: Int + + /** Vertex ids of hex h's six corners, corner 0 first (see [CORNER_OFFSETS]). */ + val HEX_CORNERS: List> + + /** Both endpoints of each edge, as vertex ids, low id first. */ + val EDGE_VERTICES: List> + + val EDGE_COUNT: Int + + /** Edges touching each vertex. */ + val VERTEX_EDGES: List> + + /** Vertices one edge away from each vertex: the distance rule's horizon. */ + val VERTEX_NEIGHBORS: List> + + /** Hexes touching each vertex. */ + val VERTEX_HEXES: List> + + /** Hexes touching each edge: two for interior edges, one on the coast. */ + val EDGE_HEXES: List> + + /** Edges adjacent to each edge (sharing a vertex). */ + val EDGE_NEIGHBORS: List> + + /** Hexes adjacent to each hex, in [NEIGHBOR_STEPS] order. */ + val HEX_NEIGHBORS: List> + + /** The coastal edges (those with only one hex) in ring order. */ + val COASTAL: List + + /** The nine fixed harbour positions, spread around the coast. */ + val PORT_POSITIONS: List + + init { + // Vertices: assign ids in first-seen order over hexes then corners. + val vertexIds = mutableMapOf, Int>() + val vertexPos = mutableListOf>() + val hexCorners = HEXES.map { hex -> + val cx = 2 * hex.q + hex.r + val cy = 3 * hex.r + CORNER_OFFSETS.map { (dx, dy) -> + val pos = (cx + dx) to (cy + dy) + vertexIds.getOrPut(pos) { + vertexPos += pos + vertexPos.size - 1 + } + } + } + VERTEX_POS = vertexPos + VERTEX_COUNT = vertexPos.size + HEX_CORNERS = hexCorners + + // Edges between a hex's consecutive corners, deduplicated by the + // pair of vertex ids. + val edgeIds = mutableMapOf, Int>() + val edgeVertices = mutableListOf>() + hexCorners.forEach { corners -> + for (k in 0..5) { + val a = corners[k] + val b = corners[(k + 1) % 6] + val key = minOf(a, b) to maxOf(a, b) + edgeIds.getOrPut(key) { + edgeVertices += key + edgeVertices.size - 1 + } + } + } + EDGE_VERTICES = edgeVertices + EDGE_COUNT = edgeVertices.size + + val vertexEdges = List(VERTEX_COUNT) { mutableListOf() } + val vertexHexes = List(VERTEX_COUNT) { mutableSetOf() } + val edgeHexes = List(EDGE_COUNT) { mutableSetOf() } + hexCorners.forEachIndexed { h, corners -> + corners.forEach { v -> vertexHexes[v] += h } + for (k in 0..5) { + val a = corners[k] + val b = corners[(k + 1) % 6] + val key = minOf(a, b) to maxOf(a, b) + val e = edgeIds.getValue(key) + edgeHexes[e] += h + vertexEdges[a] += e + vertexEdges[b] += e + } + } + // Distinct: an interior edge is walked once from each of the two hexes + // it separates, so without this every shared edge would be counted + // twice and an interior vertex would appear to have six edges. + VERTEX_EDGES = vertexEdges.map { it.distinct() } + VERTEX_HEXES = vertexHexes.map { it.sorted() } + EDGE_HEXES = edgeHexes.map { it.sorted() } + VERTEX_NEIGHBORS = List(VERTEX_COUNT) { v -> + VERTEX_EDGES[v].map { otherEnd(it, v) }.distinct().sorted() + } + EDGE_NEIGHBORS = List(EDGE_COUNT) { e -> + val (a, b) = EDGE_VERTICES[e] + (VERTEX_EDGES[a] + VERTEX_EDGES[b]).distinct().filter { it != e }.sorted() + } + HEX_NEIGHBORS = HEXES.map { hex -> + NEIGHBOR_STEPS.mapNotNull { (dq, dr) -> + hexIndex[HexCoord(hex.q + dq, hex.r + dr)] + } + } + + // 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. + 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" } + } + + /** The other endpoint of [edge], given one [vertex] on it. */ + fun otherEnd(edge: Int, vertex: Int): Int { + val (a, b) = EDGE_VERTICES[edge] + return if (vertex == a) b else a + } + + /** Lattice position of hex h's centre. */ + fun hexCenter(h: Int): Pair { + val hex = HEXES[h] + return (2 * hex.q + hex.r) to (3 * hex.r) + } + + /** 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)) + } + + /** + * 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 { + val (a, b) = EDGE_VERTICES[edge] + val pa = VERTEX_POS[a] + val pb = VERTEX_POS[b] + return ((pa.first + pb.first) / 2.0) to ((pa.second + pb.second) / 2.0) + } + + private fun angleOf(point: Pair): Double = + atan2(point.second, point.first) + + // ----------------------------------------------------------------------- + // Island generation + // ----------------------------------------------------------------------- + + /** + * The island layout for one game: terrain and number per hex, the robber + * on the desert, and the nine harbours. + */ + @Serializable + data class Generated( + val terrains: List, + /** 0 where there is no token (the desert). */ + val numbers: List, + val robber: Int, + val ports: List, + ) + + /** The terrain tile mix: four forests, four pastures, four fields, three hills, three mountains, one desert. */ + private val TERRAIN_TILES: List = listOf( + Terrain.FOREST, Terrain.FOREST, Terrain.FOREST, Terrain.FOREST, + Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE, + Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS, + Terrain.HILLS, Terrain.HILLS, Terrain.HILLS, + Terrain.MOUNTAINS, Terrain.MOUNTAINS, Terrain.MOUNTAINS, + Terrain.DESERT, + ) + + /** The 18 number tokens, the two hardest numbers withheld: 7 never appears. */ + private val RED_TOKENS = listOf(6, 6, 8, 8) + private val OTHER_TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12) + + /** Shuffle attempts before falling back to the corner placement for 6s and 8s. */ + private const val RED_ATTEMPTS = 60 + + /** + * Generates the island from [seed]. + * + * Terrain is shuffled over the hexes, exactly as shuffling the land + * tiles face down does. Number tokens are shuffled over the land hexes + * with one repair rule: no two of the red tokens (6 and 8) may sit on + * adjacent hexes, the balance convention the published game asks for. + * The robber starts on the desert. Harbour *positions* are fixed (the + * physical frame is not shuffled); the nine harbour *types* — five + * 2:1 resource ports and four 3:1 ports — are shuffled onto them. + * + * The 6/8 separation is guaranteed, not merely probable: if no shuffled + * placement separates them (about one shuffle in five does), the red + * tokens go to four of the six board-corner hexes, which are pairwise + * non-adjacent, and at most one corner can be desert. + */ + fun generate(seed: Long): Generated { + val random = Random(seed) + val terrains = TERRAIN_TILES.shuffled(random) + val desert = terrains.indexOf(Terrain.DESERT) + val land = terrains.indices.filter { it != desert } + + var redHexes: List? = null + var attempt = 0 + while (redHexes == null && attempt < RED_ATTEMPTS) { + val candidate = land.shuffled(random).take(4) + val separated = candidate.withIndex().all { (i, a) -> + candidate.drop(i + 1).none { hexesAdjacent(a, it) } + } + if (separated) redHexes = candidate + attempt++ + } + val reds = redHexes ?: CORNER_HEXES + .mapNotNull { hexIndex[it] } + .filter { it != desert } + .shuffled(random) + .take(4) + + val numbers = IntArray(HEX_COUNT) + RED_TOKENS.forEachIndexed { i, token -> numbers[reds[i]] = token } + val rest = land.filter { it !in reds }.shuffled(random) + OTHER_TOKENS.forEachIndexed { i, token -> numbers[rest[i]] = token } + + val portTypes = ( + listOf( + Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE, + ) + List(4) { null } + ).shuffled(random) + val ports = PORT_POSITIONS.mapIndexed { i, edge -> + val resource = portTypes[i] + if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2) + } + + return Generated(terrains, numbers.toList(), desert, ports) + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt new file mode 100644 index 0000000..7bd715c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt @@ -0,0 +1,307 @@ +package org.prolibertate.games.game.catan + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */ +const val NO_PLAYER = -1 + +/** Road segments required to hold Longest Road. */ +const val LONGEST_ROAD_MIN = 5 + +/** Knights required to take Largest Army. */ +const val LARGEST_ARMY_MIN = 3 + +/** Resource cards of each type the bank starts with. */ +const val BANK_PER_RESOURCE = 19 + +/** + * The five resources the island produces. + * + * Catan names two of them after the terrain rather than the commodity + * (wood becomes lumber, sheep become wool); those names travel with the + * trademark, so the engine speaks in resources only. + */ +@Serializable +enum class Resource { LUMBER, BRICK, WOOL, GRAIN, ORE } + +/** One terrain type per hex; the desert produces nothing. */ +@Serializable +enum class Terrain(val produces: Resource?) { + FOREST(Resource.LUMBER), + PASTURE(Resource.WOOL), + FIELDS(Resource.GRAIN), + HILLS(Resource.BRICK), + MOUNTAINS(Resource.ORE), + DESERT(null), +} + +/** Axial coordinate of one hex on the board. See [CatanBoard]. */ +@Serializable +data class HexCoord(val q: Int, val r: Int) + +@Serializable +enum class BuildingType { SETTLEMENT, CITY } + +/** What stands on one vertex: a settlement or city owned by one seat. */ +@Serializable +data class CatanBuilding(val type: BuildingType, val owner: Int) + +/** + * One harbour. A port sits on a coastal edge: a player who owns a + * settlement or city on *either* endpoint vertex of [edge] may trade at + * [ratio] (2 or 3) instead of the bank's 4:1. A resource port trades at 2:1 + * for its own resource only; [resource] is null for a generic 3:1 harbour. + */ +@Serializable +data class Port(val edge: Int, val resource: Resource?, val ratio: Int) + +@Serializable +enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTORY_POINT } + +/** + * One development card. [id] is stable for the life of the game (the deck + * is built once from the seed), so moves can name a card by id even after + * it moves from the deck into a hand. [boughtTurn] records when it was + * drawn: a card cannot be played on the turn it was bought. + */ +@Serializable +data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0) + +/** + * A domestic trade offer on the table. [by] is the offering seat and [to] + * the only seat that may respond — v1 has no counter-offers and no open + * auctions, so an offer is always addressed to one player. + */ +@Serializable +data class TradeOffer(val by: Int, val to: Int, val give: List, val get: List) + +/** + * Where the game stands. The phases that involve more than the player + * whose turn it is carry their own bookkeeping: [discardSeat] during + * [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING]. + */ +@Serializable +enum class CatanPhase { + /** Snake order forward: settlement + road each, seats 0..n-1. */ + SETUP_FIRST, + + /** Snake order back: settlement + road each, seats n-1..0, plus income. */ + SETUP_SECOND, + + /** The player whose turn it is must roll the dice. */ + AWAIT_ROLL, + + /** Players holding more than 7 resource cards discard half. */ + DISCARDING, + + /** The roller must move the robber and steal. */ + ROBBER, + + /** Build, trade, buy and play development cards, or end the turn. */ + PLAYING, + + /** A played Road Building card: up to two roads remain to be placed. */ + FREE_ROADS, + + /** The offered seat must accept or decline. */ + TRADE_PENDING, + + GAME_OVER, +} + +@Serializable +data class CatanOptions( + val playerCount: Int = 3, + val pointsToWin: Int = 10, +) { + init { + require(playerCount in 3..4) { "The island seats three or four players" } + require(pointsToWin >= 2) { "pointsToWin must be at least 2" } + } +} + +/** + * The whole game as one immutable value. + * + * Public information (terrain, numbers, ports, buildings, roads, resource + * counts, dev-card counts, knights played) is stored directly. Hidden + * information — the deck's order and every hand's development cards — is + * here in full because the host owns the truth; [CatanRules.viewFor] is + * what keeps it off the wire. + */ +@Serializable +data class CatanState( + val options: CatanOptions, + val seed: Long, + val phase: CatanPhase, + /** + * The seat whose turn it is — not always the seat expected to move: + * during [CatanPhase.DISCARDING] and [CatanPhase.TRADE_PENDING] another + * seat acts on the turn owner's behalf. + */ + val turn: Int, + /** How many setup placements are done, 0 until 2 * playerCount. */ + val setupIndex: Int, + /** Monotonic turn counter, 0 during setup, 1 from the first real turn. */ + val turnNumber: Int, + /** Terrain per hex, indexed like [CatanBoard.HEXES]. */ + val terrains: List, + /** Number token per hex, 0 where there is none (the desert). */ + val numbers: List, + /** Hex the robber currently occupies. */ + val robber: Int, + val ports: List, + /** What stands on each vertex, or null. */ + val buildings: List, + /** Owning seat per edge, or [NO_PLAYER]. */ + val roads: List, + /** Resource cards per seat. Card counts are public in Catan. */ + val resources: List>, + /** The bank's development cards, top first. Order is hidden. */ + val devDeck: List, + /** Development cards per seat. Hidden from everyone but the holder. */ + val devHands: List>, + /** Public mirror of the dev-hand sizes, for views and scoreboards. */ + val devHandCounts: List, + /** Knights played (not merely held) per seat. */ + val knightsPlayed: List, + /** Whether the turn owner has already played a development card. */ + val devCardPlayedThisTurn: Boolean, + val pendingTrade: TradeOffer?, + /** Roads still owed on a played Road Building card. */ + val pendingFreeRoads: Int, + /** Seat due to discard during [CatanPhase.DISCARDING]. */ + val discardSeat: Int?, + val longestRoadOwner: Int?, + val largestArmyOwner: Int?, + /** The dice as last rolled, for the table display. */ + val lastDice: List, + /** + * How many dice rolls and steals have happened so far. Random events are + * derived from the seed and this counter, so the same game replays + * identically without storing a random generator in the state. + */ + val rngStep: Int, + val winner: Int?, + val log: List, +) { + val playerCount: Int get() = options.playerCount + + /** Total resource cards held by [seat]. */ + fun resourceCount(seat: Int): Int = resources[seat].values.sum() + + /** [seat]'s resource cards as a flat list, one entry per card. */ + fun handOf(seat: Int): List = + Resource.entries.flatMap { res -> List(resources[seat][res] ?: 0) { res } } + + /** Whether [seat] holds every card in [cards], counting duplicates. */ + fun holdsAll(seat: Int, cards: List): Boolean { + val need = cards.groupingBy { it }.eachCount() + return need.all { (res, n) -> (resources[seat][res] ?: 0) >= n } + } +} + +@Serializable +sealed interface CatanMove + +/** Setup placement: a settlement on [vertex] plus a road on [edge]. */ +@Serializable +@SerialName("setup") +data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove + +/** + * Roll the dice. + * + * With [forced] null the roll is derived from the game seed, which is what + * the AI, the UI and the network always send. [forced] exists so tests and + * offline reviews can stage a specific number; it is not part of the + * enumerated move list, so the network driver's "is this move in the legal + * list" check rejects any forced roll a client tries to sneak through — + * only code holding the state directly (the host's own tests) can use it. + */ +@Serializable +@SerialName("roll") +data class RollDice(val forced: List? = null) : CatanMove + +/** Discard exactly these cards. */ +@Serializable +@SerialName("discard") +data class Discard(val cards: List) : CatanMove + +/** Move the robber to [hex] and steal from [victim], if anyone can be robbed. */ +@Serializable +@SerialName("robber") +data class MoveRobber(val hex: HexCoord, val victim: Int? = null) : CatanMove + +/** Build a road on [edge] — paid normally, or free during FREE_ROADS. */ +@Serializable +@SerialName("road") +data class BuildRoad(val edge: Int) : CatanMove + +@Serializable +@SerialName("settlement") +data class BuildSettlement(val vertex: Int) : CatanMove + +@Serializable +@SerialName("city") +data class BuildCity(val vertex: Int) : CatanMove + +@Serializable +@SerialName("buyCard") +data object BuyDevCard : CatanMove + +/** + * Play a knight. The card is named by [card] id; the robber move follows as + * its own move once the phase has advanced. + */ +@Serializable +@SerialName("knight") +data class PlayKnight(val card: Int) : CatanMove + +@Serializable +@SerialName("roadBuilding") +data class PlayRoadBuilding(val card: Int) : CatanMove + +@Serializable +@SerialName("monopoly") +data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove + +/** Take [take] (up to two cards) from the bank. */ +@Serializable +@SerialName("plenty") +data class PlayYearOfPlenty(val card: Int, val take: List) : CatanMove + +/** + * Offer a domestic trade to [to]: [give] of ours for [get] of theirs. + * + * Like a forced dice roll this is a free-form intent rather than an + * enumerated move — the space of two card multisets is combinatorial — so + * it is validated structurally by [CatanRules.isLegalMove] instead of + * appearing in [CatanRules.legalMoves]. + */ +@Serializable +@SerialName("offer") +data class OfferTrade(val to: Int, val give: List, val get: List) : CatanMove + +@Serializable +@SerialName("accept") +data object AcceptTrade : CatanMove + +@Serializable +@SerialName("decline") +data object DeclineTrade : CatanMove + +/** Bank trade: [give] at the best ratio this seat can trade at for [get]. */ +@Serializable +@SerialName("bank") +data class MaritimeTrade(val give: Resource, val get: Resource) : CatanMove + +@Serializable +@SerialName("endTurn") +data object EndTurn : CatanMove + +/** Forfeit any remaining free roads from a played Road Building card. */ +@Serializable +@SerialName("endFreeRoads") +data object EndFreeRoads : CatanMove diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt new file mode 100644 index 0000000..a47156f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt @@ -0,0 +1,905 @@ +package org.prolibertate.games.game.catan + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Catan as a pure state machine. See RULES-catan.md for the ruleset and the + * choices made where the published rules leave room. + * + * The rules the engine enforces come from the base game as published for + * three to four players: setup in snake order, one roll per turn, + * production weighted by number tokens, the robber on a seven, domestic + * and bank trade, building, development cards, Longest Road, Largest Army + * and victory at ten points. + */ +object CatanRules : GameRules { + + override val gameId: String = GameCatalog.CATAN + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + // ----------------------------------------------------------------------- + // Public constants the AI and the UI plan with + // ----------------------------------------------------------------------- + + val ROAD_COST: Map = mapOf(Resource.LUMBER to 1, Resource.BRICK to 1) + val SETTLEMENT_COST: Map = mapOf( + Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1, + ) + val CITY_COST: Map = mapOf(Resource.GRAIN to 2, Resource.ORE to 3) + val DEV_COST: Map = mapOf(Resource.WOOL to 1, Resource.GRAIN to 1, Resource.ORE to 1) + + /** The 25 development cards in the bank's deck: 14 knights, two of each progress card, five victory points. */ + val DEV_DECK: List = List(14) { DevCardType.KNIGHT } + + List(2) { DevCardType.ROAD_BUILDING } + + List(2) { DevCardType.MONOPOLY } + + List(2) { DevCardType.YEAR_OF_PLENTY } + + List(5) { DevCardType.VICTORY_POINT } + + // ----------------------------------------------------------------------- + // Life cycle + // ----------------------------------------------------------------------- + + override fun initialState(config: TableConfig): CatanState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == options.playerCount) { + "Seat count ${config.seats.size} does not match ${options.playerCount} players" + } + + val board = 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 devDeck = DEV_DECK + .mapIndexed { id, type -> DevCard(id, type) } + .shuffled(Random(config.seed + 1)) + + return CatanState( + options = options, + seed = config.seed, + phase = CatanPhase.SETUP_FIRST, + turn = 0, + setupIndex = 0, + turnNumber = 0, + terrains = board.terrains, + numbers = board.numbers, + robber = board.robber, + ports = board.ports, + buildings = List(CatanBoard.VERTEX_COUNT) { null }, + roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER }, + resources = List(options.playerCount) { + Resource.entries.associateWith { 0 } + }, + devDeck = devDeck, + devHands = List(options.playerCount) { emptyList() }, + devHandCounts = List(options.playerCount) { 0 }, + knightsPlayed = List(options.playerCount) { 0 }, + devCardPlayedThisTurn = false, + pendingTrade = null, + pendingFreeRoads = 0, + discardSeat = null, + longestRoadOwner = null, + largestArmyOwner = null, + lastDice = emptyList(), + rngStep = 0, + winner = null, + log = listOf( + "Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.", + ), + ) + } + + override fun currentSeat(state: CatanState): Int? = when (state.phase) { + CatanPhase.GAME_OVER -> null + CatanPhase.SETUP_FIRST -> state.setupIndex + CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex + CatanPhase.DISCARDING -> state.discardSeat + CatanPhase.TRADE_PENDING -> state.pendingTrade?.to + CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER, + CatanPhase.PLAYING, CatanPhase.FREE_ROADS, + -> state.turn + } + + // ----------------------------------------------------------------------- + // Legality + // ----------------------------------------------------------------------- + + /** + * Structural legality, the form the network driver and the UI should + * gate through. + * + * Two moves are free-form intents rather than members of the enumerated + * move list, because enumerating them would explode combinatorially: + * a dice roll may carry forced values (tests and offline reviews only — + * over the network the driver's membership check rejects it), and a + * domestic trade offer names any two multisets of resource cards. + */ + override fun isLegalMove(state: CatanState, seat: Int, move: CatanMove): Boolean { + if (currentSeat(state) != seat) return false + return when (move) { + is RollDice -> state.phase == CatanPhase.AWAIT_ROLL && + (move.forced == null || + (move.forced.size == 2 && move.forced.all { it in 1..6 })) + + is OfferTrade -> offerLegal(state, seat, move) + + else -> move in legalMoves(state, seat) + } + } + + override fun legalMoves(state: CatanState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + return when (state.phase) { + CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state) + + CatanPhase.AWAIT_ROLL -> listOf(RollDice()) + + CatanPhase.DISCARDING -> discardMoves(state, seat) + + CatanPhase.ROBBER -> robberMoves(state, seat) + + CatanPhase.PLAYING -> playingMoves(state, seat) + + CatanPhase.FREE_ROADS -> + roadMoves(state, seat) + listOf(EndFreeRoads) + + CatanPhase.TRADE_PENDING -> tradeResponses(state, seat) + + CatanPhase.GAME_OVER -> emptyList() + } + } + + private fun setupMoves(state: CatanState): List { + val moves = mutableListOf() + for (v in 0 until CatanBoard.VERTEX_COUNT) { + if (!vertexOpenForSettlement(state, v)) continue + for (e in CatanBoard.VERTEX_EDGES[v]) { + if (state.roads[e] != NO_PLAYER) continue + moves += SetupPlace(v, e) + } + } + return moves + } + + private fun discardMoves(state: CatanState, seat: Int): List { + val total = state.resourceCount(seat) + if (total <= 7) return emptyList() + val half = total / 2 + val held = state.resources[seat] + val moves = mutableListOf() + + fun walk(resourceIndex: Int, remaining: Int, acc: MutableList) { + if (resourceIndex == Resource.entries.size) { + if (remaining == 0) moves += Discard(acc.toList()) + return + } + val resource = Resource.entries[resourceIndex] + val have = held[resource] ?: 0 + for (take in 0..minOf(have, remaining)) { + repeat(take) { acc += resource } + walk(resourceIndex + 1, remaining - take, acc) + repeat(take) { acc.removeAt(acc.size - 1) } + } + } + walk(0, half, mutableListOf()) + return moves + } + + private fun robberMoves(state: CatanState, seat: Int): List { + val moves = mutableListOf() + for (h in 0 until CatanBoard.HEX_COUNT) { + if (h == state.robber) continue + val victims = CatanBoard.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]) + } else { + victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) } + } + } + return moves + } + + private fun playingMoves(state: CatanState, seat: Int): List { + val moves = mutableListOf() + + if (affordable(state, seat, ROAD_COST)) moves += roadMoves(state, seat) + if (affordable(state, seat, SETTLEMENT_COST)) { + moves += settlementVertices(state, seat).map { BuildSettlement(it) } + } + if (affordable(state, seat, CITY_COST)) { + moves += cityVertices(state, seat).map { BuildCity(it) } + } + if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) { + moves += BuyDevCard + } + + if (!state.devCardPlayedThisTurn) { + for (card in state.devHands[seat]) { + if (card.boughtTurn == state.turnNumber) continue + when (card.type) { + DevCardType.KNIGHT -> moves += PlayKnight(card.id) + DevCardType.ROAD_BUILDING -> moves += PlayRoadBuilding(card.id) + DevCardType.MONOPOLY -> Resource.entries.forEach { + moves += PlayMonopoly(card.id, it) + } + + DevCardType.YEAR_OF_PLENTY -> moves += plentyTakes(state).map { + PlayYearOfPlenty(card.id, it) + } + + DevCardType.VICTORY_POINT -> Unit // revealed by winning, never played + } + } + } + + for (give in Resource.entries) { + if ((state.resources[seat][give] ?: 0) < bestPortRatio(state, seat, give)) continue + for (get in Resource.entries) { + if (get == give || bankSupply(state, get) < 1) continue + moves += MaritimeTrade(give, get) + } + } + + moves += EndTurn + return moves + } + + /** Every take a Year of Plenty card could name: one card, or two. */ + private fun plentyTakes(state: CatanState): List> { + val takes = mutableListOf>() + for (a in Resource.entries) { + if (bankSupply(state, a) >= 1) takes += listOf(a) + for (b in Resource.entries) { + // Two of a kind needs a bank that can pay both, not just one. + val needOfB = if (a == b) 2 else 1 + if (bankSupply(state, a) >= 1 && bankSupply(state, b) >= needOfB) { + takes += listOf(a, b) + } + } + } + return takes + } + + /** Road placements that connect to [seat]'s network, cost aside. */ + private fun roadMoves(state: CatanState, seat: Int): List = + (0 until CatanBoard.EDGE_COUNT) + .filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) } + .map { BuildRoad(it) } + + /** + * Settlement placements: an open vertex at least two edges from every + * building, connected to [seat]'s road network. (During setup the + * connection requirement does not apply — that is why setup uses its + * own move.) + */ + private fun settlementVertices(state: CatanState, seat: Int): List = + (0 until CatanBoard.VERTEX_COUNT).filter { v -> + vertexOpenForSettlement(state, v) && + CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } + } + + private fun cityVertices(state: CatanState, seat: Int): List = + (0 until CatanBoard.VERTEX_COUNT).filter { v -> + state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) + } + + private fun tradeResponses(state: CatanState, seat: Int): List { + val offer = state.pendingTrade ?: return emptyList() + val responses = mutableListOf() + if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) { + responses += AcceptTrade + } + responses += DeclineTrade + return responses + } + + /** An empty vertex with no building on it or one edge away. */ + private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = + state.buildings[v] == null && + CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } + + /** A road on [edge] would join [seat]'s network of roads and buildings. */ + private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean { + val (a, b) = CatanBoard.EDGE_VERTICES[edge] + return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } || + CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } || + state.buildings[a]?.owner == seat || + state.buildings[b]?.owner == seat + } + + private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean { + if (state.phase != CatanPhase.PLAYING || state.turn != seat) return false + if (move.to !in 0 until state.playerCount || move.to == seat) return false + if (move.give.isEmpty() || move.get.isEmpty()) return false + // Like-for-like is not a trade; the published game rejects it too. + if (countOf(move.give) == countOf(move.get)) return false + return state.holdsAll(seat, move.give) + } + + // ----------------------------------------------------------------------- + // Move application + // ----------------------------------------------------------------------- + + override fun applyMove(state: CatanState, seat: Int, move: CatanMove): CatanState { + require(currentSeat(state) == seat) { + "Not seat $seat's move in phase ${state.phase}" + } + require(isLegalMove(state, seat, move)) { "Illegal move $move for seat $seat" } + return when (move) { + is SetupPlace -> applySetup(state, seat, move) + is RollDice -> applyRoll(state, seat, move) + is Discard -> applyDiscard(state, seat, move) + is MoveRobber -> applyRobber(state, seat, move) + is BuildRoad -> applyBuildRoad(state, seat, move) + is BuildSettlement -> applyBuildSettlement(state, seat, move) + is BuildCity -> applyBuildCity(state, seat, move) + BuyDevCard -> applyBuyDevCard(state, seat) + is PlayKnight -> applyPlayKnight(state, seat, move) + is PlayRoadBuilding -> applyPlayRoadBuilding(state, seat, move) + is PlayMonopoly -> applyPlayMonopoly(state, seat, move) + is PlayYearOfPlenty -> applyPlayYearOfPlenty(state, seat, move) + is OfferTrade -> applyOfferTrade(state, seat, move) + AcceptTrade -> applyAcceptTrade(state, seat) + DeclineTrade -> applyDeclineTrade(state, seat) + is MaritimeTrade -> applyMaritimeTrade(state, seat, move) + EndTurn -> applyEndTurn(state, seat) + EndFreeRoads -> applyEndFreeRoads(state, seat) + } + } + + private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState { + val n = state.playerCount + val second = state.phase == CatanPhase.SETUP_SECOND + var s = state.copy( + buildings = state.buildings.toMutableList().also { + it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat) + }, + roads = state.roads.toMutableList().also { it[move.edge] = seat }, + log = state.log + "Seat $seat settles at ${move.vertex} and lays a road on ${move.edge}.", + ) + + 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 granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) -> + need <= bankSupply(state, res) + } + if (granted.isNotEmpty()) { + s = s.copy( + resources = withCounts(s.resources, seat, granted, 1), + log = s.log + "Seat $seat banks ${describe(granted)} from the new lands.", + ) + } + } + + val newIndex = state.setupIndex + 1 + s = s.copy(setupIndex = newIndex) + return when { + newIndex >= 2 * n -> s.copy( + phase = CatanPhase.AWAIT_ROLL, + turn = 0, + turnNumber = 1, + log = s.log + "Setup done — seat 0 leads.", + ) + + newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND) + else -> s + } + } + + private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState { + val dice = move.forced ?: derivedDice(state) + val total = dice[0] + dice[1] + val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1) + return if (total == 7) { + val firstDiscarder = (0 until s.playerCount) + .firstOrNull { s.resourceCount(it) > 7 } + val robbed = if (firstDiscarder != null) { + s.copy(phase = CatanPhase.DISCARDING, discardSeat = firstDiscarder) + } else { + s.copy(phase = CatanPhase.ROBBER) + } + robbed.copy(log = robbed.log + "Seat $seat rolls 7 — the robber rides.") + } else { + applyProduction(s, total) + } + } + + /** Dice derived from the seed and the count of random events so far. */ + private fun derivedDice(state: CatanState): List { + val rng = Random(state.seed * 1_000_003L + state.rngStep) + return listOf(rng.nextInt(6) + 1, rng.nextInt(6) + 1) + } + + private fun applyProduction(state: CatanState, number: Int): CatanState { + val gains = List(state.playerCount) { mutableMapOf() } + for (resource in Resource.entries) { + var demand = 0 + for (h in 0 until CatanBoard.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]) { + 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 + demand += amount + } + } + // Official ruling: when the bank cannot meet the demand, nobody + // receives that resource this roll. + if (demand > bankSupply(state, resource)) { + for (perSeat in gains) perSeat.remove(resource) + } + } + + var s = state.copy(phase = CatanPhase.PLAYING) + val whoGot = mutableListOf() + for (seat in 0 until s.playerCount) { + val got = gains[seat].filterValues { it > 0 } + if (got.isEmpty()) continue + 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( + log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.", + ) + } + + private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState { + var s = state.copy(resources = withCards(state.resources, seat, move.cards, -1)) + val next = nextDiscarder(s, seat) + s = if (next != null) { + s.copy(discardSeat = next) + } else { + s.copy(phase = CatanPhase.ROBBER, discardSeat = null) + } + return s.copy(log = s.log + "Seat $seat discards ${describe(countOf(move.cards))}.") + } + + private fun nextDiscarder(state: CatanState, after: Int): Int? = + ((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 } + + private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState { + val hex = CatanBoard.hexIndex[move.hex] + ?: throw IllegalArgumentException("No hex at ${move.hex}") + var s = state.copy(robber = hex, rngStep = state.rngStep + 1) + var stolen: String? = null + if (move.victim != null) { + // One card at random from the victim's hand, derived like the + // dice: same seed, next random event. + 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 steals a ${card.name.lowercase()} from seat ${move.victim}" + } + return s.copy( + phase = CatanPhase.PLAYING, + log = s.log + "Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" + + " (${move.hex.q},${move.hex.r})$stolen.", + ) + } + + private fun applyBuildRoad(state: CatanState, seat: Int, move: BuildRoad): CatanState { + val free = state.phase == CatanPhase.FREE_ROADS + var s = state.copy(roads = state.roads.toMutableList().also { it[move.edge] = seat }) + if (!free) s = payCost(s, seat, ROAD_COST) + s = refreshLongestRoad(s) + if (free) { + val left = s.pendingFreeRoads - 1 + s = s.copy(pendingFreeRoads = left) + if (left == 0) s = s.copy(phase = CatanPhase.PLAYING) + } + return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")) + } + + private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState { + var s = state.copy( + buildings = state.buildings.toMutableList().also { + it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat) + }, + ) + s = payCost(s, seat, SETTLEMENT_COST) + // A settlement can break another player's road, so the award is + // recomputed for everyone. + s = refreshLongestRoad(s) + return checkWin(s.copy(log = s.log + "Seat $seat builds a settlement at ${move.vertex}.")) + } + + private fun applyBuildCity(state: CatanState, seat: Int, move: BuildCity): CatanState { + var s = state.copy( + buildings = state.buildings.toMutableList().also { + it[move.vertex] = CatanBuilding(BuildingType.CITY, seat) + }, + ) + s = payCost(s, seat, CITY_COST) + return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.")) + } + + private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState { + val card = state.devDeck.first().copy(boughtTurn = state.turnNumber) + val hands = state.devHands.toMutableList().also { it[seat] = it[seat] + card } + return payCost(state, seat, DEV_COST).copy( + devDeck = state.devDeck.drop(1), + devHands = hands, + devHandCounts = hands.map { it.size }, + // What was bought is not announced — only that something was. + log = state.log + "Seat $seat buys a development card.", + ) + } + + private fun applyPlayKnight(state: CatanState, seat: Int, move: PlayKnight): CatanState { + val hands = removeCard(state, seat, move.card) + val played = state.knightsPlayed.toMutableList().also { it[seat] = it[seat] + 1 } + var s = state.copy( + devHands = hands, + devHandCounts = hands.map { it.size }, + knightsPlayed = played, + devCardPlayedThisTurn = true, + phase = CatanPhase.ROBBER, + ) + s = refreshLargestArmy(s, seat) + return s.copy(log = s.log + "Seat $seat plays a knight.") + } + + private fun applyPlayRoadBuilding(state: CatanState, seat: Int, move: PlayRoadBuilding): CatanState { + val hands = removeCard(state, seat, move.card) + return state.copy( + devHands = hands, + devHandCounts = hands.map { it.size }, + devCardPlayedThisTurn = true, + phase = CatanPhase.FREE_ROADS, + pendingFreeRoads = 2, + log = state.log + "Seat $seat plays Road Building.", + ) + } + + private fun applyPlayMonopoly(state: CatanState, seat: Int, move: PlayMonopoly): CatanState { + val resource = move.resource + var resources = state.resources + var seized = 0 + for (other in 0 until state.playerCount) { + if (other == seat) continue + val held = state.resources[other][resource] ?: 0 + if (held == 0) continue + resources = withCounts(withCards(resources, other, List(held) { resource }, -1), seat, mapOf(resource to held), 1) + seized += held + } + val hands = removeCard(state, seat, move.card) + return state.copy( + resources = resources, + devHands = hands, + devHandCounts = hands.map { it.size }, + devCardPlayedThisTurn = true, + log = state.log + "Seat $seat plays Monopoly — seizes $seized ${resource.name.lowercase()}.", + ) + } + + private fun applyPlayYearOfPlenty(state: CatanState, seat: Int, move: PlayYearOfPlenty): CatanState { + val hands = removeCard(state, seat, move.card) + return state.copy( + resources = withCards(state.resources, seat, move.take, 1), + devHands = hands, + devHandCounts = hands.map { it.size }, + devCardPlayedThisTurn = true, + log = state.log + "Seat $seat plays Year of Plenty — takes ${describe(countOf(move.take))}.", + ) + } + + private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState = + state.copy( + phase = CatanPhase.TRADE_PENDING, + pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get), + log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " + + "${describe(countOf(move.get))} to seat ${move.to}.", + ) + + private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState { + val offer = state.pendingTrade!! + var resources = withCards(state.resources, offer.to, offer.get, -1) + resources = withCards(resources, offer.by, offer.give, -1) + resources = withCards(resources, offer.by, offer.get, 1) + resources = withCards(resources, offer.to, offer.give, 1) + return state.copy( + phase = CatanPhase.PLAYING, + pendingTrade = null, + resources = resources, + log = state.log + "Seat $seat accepts — the cards cross the table.", + ) + } + + private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState = + state.copy( + phase = CatanPhase.PLAYING, + pendingTrade = null, + log = state.log + "Seat $seat declines the offer.", + ) + + private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState { + val ratio = bestPortRatio(state, seat, move.give) + val paid = withCards(state.resources, seat, List(ratio) { move.give }, -1) + val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1)) + val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour" + return s.copy( + log = s.log + "Seat $seat trades $ratio ${move.give.name.lowercase()} for a " + + "${move.get.name.lowercase()} via $via.", + ) + } + + private fun applyEndTurn(state: CatanState, seat: Int): CatanState { + // Defensive: a player already at the target wins rather than passes. + val settled = checkWin(state) + if (settled.phase == CatanPhase.GAME_OVER) return settled + + val next = (state.turn + 1) % state.playerCount + val s = state.copy( + turn = next, + turnNumber = state.turnNumber + 1, + phase = CatanPhase.AWAIT_ROLL, + devCardPlayedThisTurn = false, + pendingTrade = null, + ) + // Someone can cross the target on another player's turn (a broken + // Longest Road re-awarded to a third player); they win when their + // own turn arrives. + return checkWin(s) + } + + private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState = + state.copy( + phase = CatanPhase.PLAYING, + pendingFreeRoads = 0, + log = state.log + "Seat $seat forfeits the remaining free road.", + ) + + // ----------------------------------------------------------------------- + // Scoring + // ----------------------------------------------------------------------- + + /** + * Victory points everyone at the table can count: buildings, the two + * awards, and nothing hidden. Hidden victory-point cards are added on + * top by [victoryPoints], which is only meaningful for the holder's own + * view or the host's copy. + */ + fun publicVictoryPoints(state: CatanState): List { + val points = IntArray(state.playerCount) + state.buildings.forEach { building -> + if (building != null) { + points[building.owner] += if (building.type == BuildingType.CITY) 2 else 1 + } + } + state.longestRoadOwner?.let { points[it] += 5 } + state.largestArmyOwner?.let { points[it] += 5 } + return points.toList() + } + + fun victoryPoints(state: CatanState, seat: Int): Int = + publicVictoryPoints(state)[seat] + + state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT } + + private fun checkWin(state: CatanState): CatanState { + if (state.phase == CatanPhase.GAME_OVER) return state + val seat = state.turn + val points = victoryPoints(state, seat) + if (points < state.options.pointsToWin) return state + return state.copy( + phase = CatanPhase.GAME_OVER, + winner = seat, + log = state.log + "Seat $seat reveals $points victory points and wins.", + ) + } + + /** + * Recomputes everyone's road lengths and re-awards Longest Road. + * + * The award needs a strictly longer road than the holder's; equal length + * leaves it where it is, exactly as the published rule keeps the first + * builder ahead of a builder who merely matches them. If the holder's + * road falls below five segments the award reopens to the longest road + * of five or more, lowest seat first on a tie. + */ + private fun refreshLongestRoad(state: CatanState): CatanState { + val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) } + val holder = state.longestRoadOwner + val best = lengths + .withIndex() + .filter { it.value >= LONGEST_ROAD_MIN } + .maxByOrNull { it.value } // first of the maximum: lowest seat on a tie + + val newOwner = when { + holder != null && lengths[holder] >= LONGEST_ROAD_MIN && + (best == null || best.value <= lengths[holder]) -> holder + + else -> best?.index + } + if (newOwner == holder) return state + val line = if (newOwner == null) { + "Longest Road falls vacant." + } else { + "Seat $newOwner takes Longest Road (${lengths[newOwner]} segments)." + } + return state.copy(longestRoadOwner = newOwner, log = state.log + line) + } + + /** + * The seat's longest single road: a simple path over its own edges that + * never passes through an opponent's building and never revisits a + * vertex (so a figure of two loops sharing an intersection counts as + * one road, not two — the intersection is a branch). + */ + fun longestRoadFor(state: CatanState, seat: Int): Int { + var best = 0 + val blocked = BooleanArray(CatanBoard.VERTEX_COUNT) + for (v in 0 until CatanBoard.VERTEX_COUNT) { + val building = state.buildings[v] + if (building != null && building.owner != seat) blocked[v] = 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) + best = maxOf(best, roadDfs(state, seat, start, blocked, visited)) + } + return best + } + + private fun roadDfs( + state: CatanState, + seat: Int, + v: Int, + blocked: BooleanArray, + visited: BooleanArray, + ): Int { + visited[v] = true + var best = 0 + for (e in CatanBoard.VERTEX_EDGES[v]) { + if (state.roads[e] != seat) continue + val w = CatanBoard.otherEnd(e, v) + if (visited[w]) continue + if (blocked[w]) { + // The segment may run up to an opponent's building, but not + // through it. + best = maxOf(best, 1) + } else { + best = maxOf(best, 1 + roadDfs(state, seat, w, blocked, visited)) + } + } + visited[v] = false + return best + } + + /** + * Recomputes Largest Army: first to three knights takes it, and it is + * stolen only by a strictly larger army. + */ + private fun refreshLargestArmy(state: CatanState, seat: Int): CatanState { + val holder = state.largestArmyOwner + val newOwner = when { + holder == null && state.knightsPlayed[seat] >= LARGEST_ARMY_MIN -> seat + holder != null && holder != seat && + state.knightsPlayed[seat] > state.knightsPlayed[holder] -> seat + + else -> holder + } + if (newOwner == holder) return state + return state.copy( + largestArmyOwner = newOwner, + log = state.log + "Seat $newOwner takes Largest Army (${state.knightsPlayed[seat]} knights).", + ) + } + + // ----------------------------------------------------------------------- + // Resources + // ----------------------------------------------------------------------- + + /** The bank's remaining cards of [resource]: what is not in players' hands. */ + fun bankSupply(state: CatanState, resource: Resource): Int = + BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 } + + private fun affordable(state: CatanState, seat: Int, cost: Map): Boolean = + cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need } + + private fun payCost(state: CatanState, seat: Int, cost: Map): CatanState = + state.copy(resources = withCounts(state.resources, seat, cost, -1)) + + private fun countOf(cards: List): Map = + cards.groupingBy { it }.eachCount() + + /** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */ + private fun withCounts( + resources: List>, + seat: Int, + delta: Map, + sign: Int, + ): List> { + val hand = resources[seat].toMutableMap() + for ((resource, n) in delta) hand[resource] = (hand[resource] ?: 0) + sign * n + return resources.toMutableList().also { it[seat] = hand } + } + + /** Hands with each card of [cards] added to (or removed from) [seat]'s. */ + private fun withCards( + resources: List>, + seat: Int, + cards: List, + sign: Int, + ): List> = withCounts(resources, seat, countOf(cards), sign) + + /** The card with id [card], removed from [seat]'s hand. */ + private fun removeCard(state: CatanState, seat: Int, card: Int): List> = + state.devHands.toMutableList().also { hands -> + hands[seat] = hands[seat].filterNot { it.id == card } + } + + /** + * The best ratio [seat] can trade [give] at: 2:1 through a matching + * resource harbour, 3:1 through a generic one, otherwise the bank's 4:1. + */ + fun bestPortRatio(state: CatanState, seat: Int, give: Resource): Int { + var best = 4 + 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 mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat + if (mine) best = port.ratio + } + return best + } + + // ----------------------------------------------------------------------- + // Results, redaction and wire format + // ----------------------------------------------------------------------- + + override fun isFinished(state: CatanState): Boolean = state.phase == CatanPhase.GAME_OVER + + override fun summary(state: CatanState): String = when (val winner = state.winner) { + null -> publicVictoryPoints(state) + .mapIndexed { seat, points -> "S$seat $points" } + .joinToString(" ") + + else -> "Seat $winner wins" + } + + override fun viewFor(state: CatanState, seat: Int): CatanState = state.copy( + // Hidden information stays with the holder and the host. + devDeck = emptyList(), + devHands = state.devHands.mapIndexed { index, hand -> + if (index == seat) hand else emptyList() + }, + ) + + override fun encodeState(state: CatanState): String = json.encodeToString(state) + + override fun decodeState(json: String): CatanState = this.json.decodeFromString(json) + + override fun encodeMove(move: CatanMove): String = json.encodeToString(move) + + override fun decodeMove(json: String): CatanMove = this.json.decodeFromString(json) + + // ----------------------------------------------------------------------- + // Wording + // ----------------------------------------------------------------------- + + private fun describe(counts: Map): String = + counts.entries.joinToString(" + ") { (resource, n) -> "$n ${resource.name.lowercase()}" } + + private fun terrainName(terrain: Terrain): String = + terrain.name.lowercase().replace('_', ' ') +} diff --git a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt index 89d8eb4..ae7b456 100644 --- a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt +++ b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt @@ -64,6 +64,21 @@ interface GameRules { fun legalMoves(state: S, seat: Int): List + /** + * Whether [move] is currently legal for [seat]. + * + * Defaults to membership in [legalMoves], which is everything a game + * with a small, enumerable move set needs. A game whose move space is + * combinatorial — Catan's trade offers name any two multisets of cards — + * overrides this and validates structurally instead, because enumerating + * every intent would explode the list the UI and the network driver work + * from. Gates that check a specific move should call this method rather + * than testing list membership directly, so both kinds of game behave the + * same way. + */ + fun isLegalMove(state: S, seat: Int, move: M): Boolean = + move in legalMoves(state, seat) + /** * Applies [move] on behalf of [seat]. * diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index d175acd..776a565 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -349,7 +349,7 @@ class MatchController( seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) } return } - if (move !in rules.legalMoves(current, seat)) { + if (!rules.isLegalMove(current, seat, move)) { seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) } return } @@ -424,7 +424,7 @@ class MatchController( // throws on a move that is no longer legal, and there is nothing // above this to catch it. if (rules.currentSeat(live) != seat) return@withLock false - if (move !in rules.legalMoves(live, seat)) return@withLock false + if (!rules.isLegalMove(live, seat, move)) return@withLock false authoritative = rules.applyMove(live, seat, move) publish() true diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt new file mode 100644 index 0000000..96a0a25 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt @@ -0,0 +1,145 @@ +package org.prolibertate.games.game.catan + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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 CatanAiTest { + + private val json = Json { encodeDefaults = true } + + 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, + ) + + /** Plays one table of engines out to a result, refusing every illegal move. */ + private fun playOut(options: CatanOptions, seed: Long, cap: Int = 4_000): CatanState { + val ai = CatanAi() + var state = CatanRules.initialState(config(options, seed)) + var moves = 0 + while (!CatanRules.isFinished(state)) { + assertTrue("seed $seed ran past $cap moves", moves++ < cap) + val seat = CatanRules.currentSeat(state) ?: break + val move = ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat)) + assertTrue( + "seed $seed: seat $seat chose the illegal move $move", + CatanRules.isLegalMove(state, seat, move), + ) + state = CatanRules.applyMove(state, seat, move) + } + return state + } + + @Test + fun `three engines finish a game at ten points`() { + listOf(11L, 22L, 33L).forEach { seed -> + val state = playOut(CatanOptions(), 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, + ) + } + } + + @Test + fun `four engines finish a game at ten points`() { + listOf(44L, 55L).forEach { seed -> + val state = playOut(CatanOptions(playerCount = 4), 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, + ) + } + } + + /** A mid-game state on seat 0's turn, one card short of a settlement. */ + private fun offeringState(): CatanState = + CatanRules.initialState(config(CatanOptions(), seed = 7L)) + .copy( + phase = CatanPhase.PLAYING, + turn = 0, + turnNumber = 1, + // Lumber to spare, brick missing, and a neighbour visibly + // holding brick. No roads or buildings, so no build is legal + // and no port is open — the offer is the only move forward. + resources = listOf( + hand(Resource.LUMBER to 4, Resource.WOOL to 1, Resource.GRAIN to 1), + hand(Resource.BRICK to 2), + hand(), + ), + ) + + private fun hand(vararg cards: Pair): Map = + Resource.entries.associateWith { cards.toMap()[it] ?: 0 } + + @Test + fun `the AI offers the trade that closes its build`() { + val state = offeringState() + val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0)) + assertEquals( + OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)), + move, + ) + } + + @Test + fun `the AI offers at most one trade a turn`() { + // The same table, but the offer was already made and refused once. + val state = offeringState().copy( + log = listOf( + "Seat 0 rolls 3 + 2.", + "Seat 0 offers 2 lumber for a brick to seat 1.", + "Seat 1 declines the offer.", + ), + ) + val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0)) + assertTrue("the AI re-offered within one turn: $move", move !is OfferTrade) + assertEquals(EndTurn, move) + } + + @Test + fun `the AI answers a pending offer with accept or decline`() { + val state = CatanRules.initialState(config(CatanOptions(), seed = 7L)) + .copy( + phase = CatanPhase.TRADE_PENDING, + turn = 0, + turnNumber = 1, + pendingTrade = TradeOffer( + by = 0, to = 1, + give = listOf(Resource.LUMBER, Resource.LUMBER), + get = listOf(Resource.BRICK), + ), + resources = listOf( + hand(Resource.LUMBER to 3), + hand(Resource.BRICK to 2, Resource.WOOL to 1), + hand(), + ), + ) + val legal = CatanRules.legalMoves(state, 1) + val move = CatanAi().chooseMove(state, 1, legal) + assertTrue("neither accept nor decline: $move", move == AcceptTrade || move == DeclineTrade) + assertTrue(move in legal) + + val after = CatanRules.applyMove(state, 1, move) + assertEquals(CatanPhase.PLAYING, after.phase) + assertNull(after.pendingTrade) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanBoardTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanBoardTest.kt new file mode 100644 index 0000000..fc73011 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanBoardTest.kt @@ -0,0 +1,192 @@ +package org.prolibertate.games.game.catan + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CatanBoardTest { + + // -- Geometry ----------------------------------------------------------- + + @Test + fun `the island has 19 hexes, 54 vertices, 72 edges and 30 coastal edges`() { + assertEquals(19, CatanBoard.HEX_COUNT) + assertEquals(54, CatanBoard.VERTEX_COUNT) + assertEquals(72, CatanBoard.EDGE_COUNT) + assertEquals("the coast is one ring of 30 edges", 30, CatanBoard.COASTAL.size) + assertEquals("nine harbour positions", 9, CatanBoard.PORT_POSITIONS.size) + } + + @Test + fun `every hex has six distinct corners and every edge two distinct ends`() { + for (h in 0 until CatanBoard.HEX_COUNT) { + assertEquals("hex $h", 6, CatanBoard.HEX_CORNERS[h].size) + assertEquals("hex $h repeats a corner", 6, CatanBoard.HEX_CORNERS[h].distinct().size) + } + CatanBoard.EDGE_VERTICES.forEach { (a, b) -> assertTrue("edge loops on itself", a != b) } + } + + @Test + fun `two vertices never share a lattice position`() { + assertEquals(CatanBoard.VERTEX_COUNT, CatanBoard.VERTEX_POS.distinct().size) + assertEquals(CatanBoard.HEX_COUNT, (0 until CatanBoard.HEX_COUNT) + .map { CatanBoard.hexCenter(it) } + .distinct().size) + } + + @Test + fun `adjacent hexes share exactly two vertices and one edge`() { + for (h in 0 until CatanBoard.HEX_COUNT) { + for (n in CatanBoard.HEX_NEIGHBORS[h]) { + val shared = CatanBoard.HEX_CORNERS[h].toSet() + .intersect(CatanBoard.HEX_CORNERS[n].toSet()) + assertEquals("hexes $h and $n", 2, shared.size) + val sharedEdge = CatanBoard.EDGE_HEXES.indices.count { e -> + CatanBoard.EDGE_HEXES[e].containsAll(listOf(h, n)) + } + assertEquals("hexes $h and $n", 1, sharedEdge) + } + } + } + + @Test + fun `vertices have two or three edges, each to a distinct neighbour`() { + for (v in 0 until CatanBoard.VERTEX_COUNT) { + assertTrue("vertex $v", CatanBoard.VERTEX_EDGES[v].size in 2..3) + assertEquals("vertex $v", CatanBoard.VERTEX_EDGES[v].size, CatanBoard.VERTEX_NEIGHBORS[v].size) + CatanBoard.VERTEX_NEIGHBORS[v].forEach { n -> + assertTrue( + "vertices $v and $n are not joined by an edge", + CatanBoard.VERTEX_EDGES[v].any { CatanBoard.otherEnd(it, v) == n }, + ) + } + } + } + + @Test + fun `interior edges touch two hexes and coastal edges one`() { + CatanBoard.EDGE_HEXES.forEach { hexes -> assertTrue(hexes.size in 1..2) } + // Nineteen hexes times six edges each, counted per side: 114. + assertEquals(114, CatanBoard.EDGE_HEXES.sumOf { it.size }) + val coastal = CatanBoard.EDGE_HEXES.indices.filter { CatanBoard.EDGE_HEXES[it].size == 1 } + assertEquals(CatanBoard.COASTAL.toSet(), coastal.toSet()) + } + + @Test + fun `the coast is one closed ring and the harbours are spread along it`() { + CatanBoard.COASTAL.forEachIndexed { i, e -> + val next = CatanBoard.COASTAL[(i + 1) % CatanBoard.COASTAL.size] + val shared = ends(e).intersect(ends(next)) + assertTrue("coastal edges $e and $next are not consecutive", shared.isNotEmpty()) + } + + assertEquals(9, CatanBoard.PORT_POSITIONS.distinct().size) + assertTrue(CatanBoard.PORT_POSITIONS.all { it in CatanBoard.COASTAL }) + val ringSpots = CatanBoard.PORT_POSITIONS.map { CatanBoard.COASTAL.indexOf(it) }.sorted() + val gaps = ringSpots.zipWithNext { a, b -> b - a } + + (CatanBoard.COASTAL.size - ringSpots.last() + ringSpots.first()) + assertTrue("harbours sit closer than three edges apart: $gaps", gaps.all { it >= 3 }) + } + + @Test + fun `token odds follow the dice`() { + assertEquals(5, CatanBoard.numberOdds(6)) + assertEquals(5, CatanBoard.numberOdds(8)) + assertEquals(1, CatanBoard.numberOdds(2)) + assertEquals(1, CatanBoard.numberOdds(12)) + assertEquals(36, (2..12).sumOf { CatanBoard.numberOdds(it) }) + } + + // -- Island generation -------------------------------------------------- + + private val terrainCounts = mapOf( + Terrain.FOREST to 4, + Terrain.PASTURE to 4, + Terrain.FIELDS to 4, + Terrain.HILLS to 3, + Terrain.MOUNTAINS to 3, + Terrain.DESERT to 1, + ) + + /** The 18 number tokens dealt to the land, the seven withheld. */ + private val tokens = listOf(6, 6, 8, 8, 2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12) + + @Test + fun `every seed deals the fixed terrain mix`() { + for (seed in 1L..500L) { + val island = CatanBoard.generate(seed) + assertEquals("seed $seed", 19, island.terrains.size) + terrainCounts.forEach { (terrain, n) -> + assertEquals("seed $seed $terrain", n, island.terrains.count { it == terrain }) + } + } + } + + @Test + fun `every seed deals the full token set and the desert stays bare`() { + for (seed in 1L..500L) { + val island = CatanBoard.generate(seed) + val dealt = island.numbers.filter { it > 0 } + assertEquals("seed $seed", 18, dealt.size) + assertEquals("seed $seed", tokens.sorted(), dealt.sorted()) + assertEquals("seed $seed: the desert bears no token", 0, island.numbers[island.robber]) + assertEquals( + "seed $seed: the robber does not start on the desert", + island.terrains.indexOf(Terrain.DESERT), + island.robber, + ) + } + } + + @Test + fun `no seed puts two red tokens on adjacent hexes`() { + for (seed in 1L..500L) { + val island = CatanBoard.generate(seed) + val red = island.numbers.withIndex() + .filter { it.value == 6 || it.value == 8 } + .map { it.index } + assertEquals("seed $seed", 4, red.size) + for (i in red.indices) { + for (j in i + 1 until red.size) { + assertTrue( + "seed $seed: red hexes ${red[i]} and ${red[j]} touch", + !CatanBoard.hexesAdjacent(red[i], red[j]), + ) + } + } + } + } + + @Test + fun `every seed spreads nine harbours, five special and four generic`() { + for (seed in 1L..500L) { + val island = CatanBoard.generate(seed) + assertEquals("seed $seed", 9, island.ports.size) + assertEquals("seed $seed", 5, island.ports.count { it.resource != null }) + assertEquals("seed $seed", 4, island.ports.count { it.resource == null }) + assertTrue("seed $seed", island.ports.all { port -> + port.ratio == if (port.resource == null) 3 else 2 + }) + assertEquals( + "seed $seed: every resource has its own harbour", + Resource.entries, + island.ports.mapNotNull { it.resource }.sorted(), + ) + assertEquals("seed $seed", 9, island.ports.map { it.edge }.distinct().size) + assertTrue("seed $seed", island.ports.all { it.edge in CatanBoard.COASTAL }) + } + } + + @Test + fun `the same seed builds the same island`() { + for (seed in listOf(0L, 7L, 42L, 99L, -1L)) { + assertEquals("seed $seed", CatanBoard.generate(seed), CatanBoard.generate(seed)) + } + } + + /** The two vertices of [edge], as a set. */ + private fun ends(edge: Int): Set { + val (a, b) = CatanBoard.EDGE_VERTICES[edge] + return setOf(a, b) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt new file mode 100644 index 0000000..71c2eb4 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt @@ -0,0 +1,861 @@ +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.assertNull +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 CatanRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config( + options: CatanOptions = CatanOptions(), + seed: Long = 7L, + ) = 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, + ) + + /** + * A mid-game state on some seat's turn, built from a real island so the + * terrain, tokens, robber and harbours are a genuine layout. What a test + * bends with [adjust] is its own business; the moves it then asks about + * are checked against the rules like any other. + */ + private fun craft( + options: CatanOptions = CatanOptions(), + seed: Long = 7L, + turn: Int = 0, + adjust: CatanState.() -> CatanState = { this }, + ): CatanState = CatanRules.initialState(config(options, seed)) + .copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1) + .adjust() + + private fun hand(vararg cards: Pair): Map = + Resource.entries.associateWith { cards.toMap()[it] ?: 0 } + + private fun hands(vararg perSeat: Map, players: Int = 3): List> = + (0 until players).map { perSeat.getOrNull(it) ?: hand() } + + private fun CatanState.withBuilding( + vertex: Int, + type: BuildingType, + owner: Int, + ): CatanState = copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) }) + + private fun CatanState.withRoad(edge: Int, owner: Int): CatanState = + copy(roads = roads.toMutableList().also { it[edge] = owner }) + + /** A roll whose dice add up to [total]. */ + private fun rollFor(total: Int): RollDice { + val first = (total - 1).coerceAtMost(6) + return RollDice(forced = listOf(first, total - first)) + } + + /** A producing hex corner whose other hexes bear a different number. */ + private data class Spot(val resource: Resource, val number: Int, val vertex: Int) + + private fun CatanState.pickSpot(): Spot { + for (h in 0 until CatanBoard.HEX_COUNT) { + val resource = terrains[h].produces ?: continue + val number = numbers[h] + val vertex = CatanBoard.HEX_CORNERS[h].firstOrNull { corner -> + CatanBoard.VERTEX_HEXES[corner].all { it == h || numbers[it] != number } + } + if (vertex != null) return Spot(resource, number, vertex) + } + error("no clean producing corner on this island") + } + + /** The six edges of hex [h], in walking order around it. */ + private fun hexRing(h: Int): List { + val corners = CatanBoard.HEX_CORNERS[h] + return (0..5).map { k -> + val a = corners[k] + val b = corners[(k + 1) % 6] + CatanBoard.EDGE_VERTICES.indices.first { e -> + val (x, y) = CatanBoard.EDGE_VERTICES[e] + (x == a && y == b) || (x == b && y == a) + } + } + } + + private fun sharedVertex(a: Int, b: Int): Int = + ends(a).intersect(ends(b)).single() + + /** The two vertices of [edge], as a set. */ + private fun ends(edge: Int): Set { + val (x, y) = CatanBoard.EDGE_VERTICES[edge] + return setOf(x, y) + } + + /** [count] vertices at least two edges apart, for standing buildings on. */ + private fun scattered(count: Int): List { + val picked = mutableListOf() + var v = 0 + while (picked.size < count) { + if (CatanBoard.VERTEX_NEIGHBORS[v].none { it in picked }) picked += v + v++ + } + return picked + } + + // -- Setup --------------------------------------------------------------- + + @Test + fun `setup runs in snake order and then seat zero leads`() { + var state = CatanRules.initialState(config()) + val order = mutableListOf() + while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) { + val seat = CatanRules.currentSeat(state)!! + order += seat + state = CatanRules.applyMove( + state, seat, + CatanRules.legalMoves(state, seat).filterIsInstance().first(), + ) + } + assertEquals(listOf(0, 1, 2, 2, 1, 0), order) + assertEquals(CatanPhase.AWAIT_ROLL, state.phase) + assertEquals(0, state.turn) + assertEquals(1, state.turnNumber) + } + + @Test + fun `the second settlement earns one card from each adjacent land`() { + var state = CatanRules.initialState(config()) + while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) { + val seat = CatanRules.currentSeat(state)!! + val move = CatanRules.legalMoves(state, seat).filterIsInstance().first() + val second = state.phase == CatanPhase.SETUP_SECOND + val before = state.resources[seat] + state = CatanRules.applyMove(state, seat, move) + if (second) { + val expected = CatanBoard.VERTEX_HEXES[move.vertex] + .mapNotNull { state.terrains[it].produces } + .groupingBy { it }.eachCount() + .let { earned -> + before.toMutableMap().also { hand -> + earned.forEach { (res, n) -> hand[res] = (hand[res] ?: 0) + n } + } + } + assertEquals("seat $seat's second income", expected, state.resources[seat]) + } else { + assertEquals("no income during the first pass", before, state.resources[seat]) + } + } + } + + @Test + fun `setup leaves every settlement two edges from its neighbour`() { + var state = CatanRules.initialState(config()) + while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) { + val seat = CatanRules.currentSeat(state)!! + state = CatanRules.applyMove( + state, seat, + CatanRules.legalMoves(state, seat).filterIsInstance().first(), + ) + } + val placed = state.buildings.withIndex().filter { it.value != null }.map { it.index } + assertEquals(6, placed.size) + placed.forEach { v -> + assertTrue( + "a settlement stands beside $v", + CatanBoard.VERTEX_NEIGHBORS[v].none { state.buildings[it] != null }, + ) + } + // Every road laid in setup touches the settlement it was laid with. + state.roads.withIndex().filter { it.value != NO_PLAYER }.forEach { (edge, _) -> + val (a, b) = CatanBoard.EDGE_VERTICES[edge] + assertTrue( + "road $edge touches no settlement", + state.buildings[a] != null || state.buildings[b] != null, + ) + } + } + + @Test + fun `the island seats three or four`() { + assertThrows { CatanOptions(playerCount = 2) } + assertThrows { CatanOptions(playerCount = 5) } + val fourSeats = config(CatanOptions(playerCount = 3)) + .let { it.copy(seats = it.seats.drop(1)) } + assertThrows { CatanRules.initialState(fourSeats) } + } + + // -- Production ---------------------------------------------------------- + + @Test + fun `a roll pays settlements singly and cities double`() { + val base = craft() + val spot = base.pickSpot() + + val settled = base.withBuilding(spot.vertex, BuildingType.SETTLEMENT, 0) + .copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand())) + val afterSettlement = CatanRules.applyMove(settled, 0, rollFor(spot.number)) + assertEquals(1, afterSettlement.resources[0][spot.resource]) + assertEquals(CatanPhase.PLAYING, afterSettlement.phase) + + val raised = base.withBuilding(spot.vertex, BuildingType.CITY, 0) + .copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand())) + val afterCity = CatanRules.applyMove(raised, 0, rollFor(spot.number)) + assertEquals(2, afterCity.resources[0][spot.resource]) + } + + @Test + fun `the robber's hex produces nothing`() { + val base = craft() + val spot = base.pickSpot() + val hex = CatanBoard.VERTEX_HEXES[spot.vertex].first { base.numbers[it] == spot.number } + val state = base.withBuilding(spot.vertex, BuildingType.CITY, 0) + .copy(phase = CatanPhase.AWAIT_ROLL, robber = hex, resources = hands(hand())) + val after = CatanRules.applyMove(state, 0, rollFor(spot.number)) + assertEquals("the blocked city takes nothing", 0, after.resources[0][spot.resource]) + } + + @Test + fun `a bank-short resource pays nobody`() { + val base = craft() + val spot = base.pickSpot() + // A city wants two; the bank holds one, because seat 1 hoards the rest. + val state = base.withBuilding(spot.vertex, BuildingType.CITY, 0) + .copy(phase = CatanPhase.AWAIT_ROLL, resources = hands(hand(), hand(spot.resource to 18))) + val after = CatanRules.applyMove(state, 0, rollFor(spot.number)) + assertEquals("the city takes nothing while the bank is short", 0, after.resources[0][spot.resource]) + assertEquals(18, after.resources[1][spot.resource]) + } + + // -- The robber ---------------------------------------------------------- + + @Test + fun `a seven with no overfull hand rides the robber directly`() { + val state = craft().copy( + phase = CatanPhase.AWAIT_ROLL, + resources = hands(hand(), hand(Resource.LUMBER to 7), hand(Resource.BRICK to 7)), + ) + val after = CatanRules.applyMove(state, 0, RollDice(forced = listOf(6, 1))) + assertEquals(CatanPhase.ROBBER, after.phase) + assertNull(after.discardSeat) + assertTrue(after.log.any { it.startsWith("Seat 0 rolls 7") }) + } + + @Test + fun `a seven makes overfull hands discard half in seat order`() { + var state = craft().copy( + phase = CatanPhase.AWAIT_ROLL, + resources = hands( + hand(), + hand(Resource.LUMBER to 2, Resource.BRICK to 3, Resource.WOOL to 4), // 9 cards + hand(Resource.GRAIN to 8), // 8 cards + ), + ) + state = CatanRules.applyMove(state, 0, RollDice(forced = listOf(3, 4))) + assertEquals(CatanPhase.DISCARDING, state.phase) + assertEquals(1, state.discardSeat) + assertEquals(1, CatanRules.currentSeat(state)) + + // Nine cards means four gone, exactly, and no other number of them. + assertTrue(CatanRules.isLegalMove(state, 1, Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.BRICK)))) + assertFalse(CatanRules.isLegalMove(state, 1, Discard(listOf(Resource.LUMBER)))) + assertFalse(CatanRules.isLegalMove(state, 1, Discard(List(5) { Resource.WOOL }))) + state = CatanRules.applyMove( + state, 1, + Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.BRICK)), + ) + assertEquals("seat 2 still owes the robber", CatanPhase.DISCARDING, state.phase) + assertEquals(2, state.discardSeat) + + state = CatanRules.applyMove(state, 2, Discard(List(4) { Resource.GRAIN })) + assertEquals(CatanPhase.ROBBER, state.phase) + assertEquals("the roller moves the robber", 0, CatanRules.currentSeat(state)) + assertEquals(5, state.resourceCount(1)) + assertEquals(4, state.resourceCount(2)) + } + + @Test + fun `the robber must move and steals one card from a built-up hex`() { + val base = craft() + val spot = base.pickSpot() + val hex = CatanBoard.VERTEX_HEXES[spot.vertex].first { base.numbers[it] == spot.number } + val elsewhere = (0 until CatanBoard.HEX_COUNT) + .first { it != base.robber && it != hex } + val state = base.withBuilding(spot.vertex, BuildingType.SETTLEMENT, 1) + .copy( + phase = CatanPhase.ROBBER, + robber = elsewhere, + resources = hands(hand(), hand(Resource.WOOL to 3)), + ) + + val moves = CatanRules.legalMoves(state, 0).filterIsInstance() + assertTrue( + "the robber may not stay put", + moves.all { it.hex != CatanBoard.HEXES[state.robber] }, + ) + assertEquals("every other hex is reachable", 18, moves.map { it.hex }.distinct().size) + + val steal = moves.first { it.hex == CatanBoard.HEXES[hex] && it.victim == 1 } + val after = CatanRules.applyMove(state, 0, steal) + assertEquals(hex, after.robber) + assertEquals(CatanPhase.PLAYING, after.phase) + assertEquals(2, after.resources[1][Resource.WOOL]) + assertEquals(1, after.resourceCount(0)) + } + + @Test + fun `a forced roll must be two honest dice, and only tests may send it`() { + val state = craft().copy(phase = CatanPhase.AWAIT_ROLL) + assertTrue(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(3, 4)))) + assertFalse(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(7, 0)))) + assertFalse(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(3)))) + // The derived roll is the enumerated move; a forced one is structural only. + assertTrue(RollDice() in CatanRules.legalMoves(state, 0)) + assertFalse(RollDice(forced = listOf(3, 4)) in CatanRules.legalMoves(state, 0)) + } + + // -- Building ------------------------------------------------------------ + + @Test + fun `a road must attach to the builder's own network`() { + val state = craft().copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1))) + assertTrue("nothing to attach to", CatanRules.legalMoves(state, 0).none { it is BuildRoad }) + + val e0 = CatanBoard.VERTEX_EDGES[0][0] + val attached = state.withRoad(e0, 0) + val roads = CatanRules.legalMoves(attached, 0).filterIsInstance() + assertEquals( + "roads may only fan out from the network", + CatanBoard.EDGE_NEIGHBORS[e0].map { BuildRoad(it) }.toSet(), + roads.toSet(), + ) + } + + @Test + fun `a settlement needs an open vertex on the network, a city one of yours`() { + val base = craft() + val homes = scattered(3) + val state = base.withBuilding(homes[0], BuildingType.SETTLEMENT, 0) + .withRoad(CatanBoard.VERTEX_EDGES[homes[1]][0], 0) + .copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 2, Resource.ORE to 3))) + + // Reachable: open, two edges from anything standing, on seat 0's road. + assertTrue(BuildSettlement(homes[1]) in CatanRules.legalMoves(state, 0)) + // One edge from a standing settlement is too close, network or not. + val tooClose = CatanBoard.VERTEX_NEIGHBORS[homes[0]].first() + assertTrue(BuildSettlement(tooClose) !in CatanRules.legalMoves(state, 0)) + // Open but off the network is out of reach. + assertTrue(BuildSettlement(homes[2]) !in CatanRules.legalMoves(state, 0)) + + // Raising costs two grain and three ore. + val raised = CatanRules.applyMove(state, 0, BuildCity(homes[0])) + assertEquals(BuildingType.CITY, raised.buildings[homes[0]]?.type) + assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 0, Resource.ORE to 0), raised.resources[0]) + } + + // -- Trade --------------------------------------------------------------- + + @Test + fun `a domestic offer names any two multisets and waits on the table`() { + val state = craft().copy( + resources = hands( + hand(Resource.LUMBER to 3), + hand(Resource.BRICK to 2), + hand(), + ), + ) + val offer = OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)) + assertTrue(CatanRules.isLegalMove(state, 0, offer)) + assertFalse("not the addressee's turn", CatanRules.isLegalMove(state, 1, offer)) + assertFalse("never to oneself", CatanRules.isLegalMove(state, 0, offer.copy(to = 0))) + assertFalse( + "like for like is not a trade", + CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.LUMBER, Resource.BRICK), listOf(Resource.BRICK, Resource.LUMBER))), + ) + assertFalse( + "an empty leg is not a trade", + CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.LUMBER), emptyList())), + ) + assertFalse( + "cards the offerer does not hold cannot be promised", + CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.ORE), listOf(Resource.BRICK))), + ) + + val table = CatanRules.applyMove(state, 0, offer) + assertEquals(CatanPhase.TRADE_PENDING, table.phase) + assertEquals(1, CatanRules.currentSeat(table)) + assertEquals(TradeOffer(by = 0, to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)), table.pendingTrade) + } + + @Test + fun `an accepted offer crosses the cards and a declined one clears the table`() { + val offered = craft().copy( + phase = CatanPhase.TRADE_PENDING, + pendingTrade = TradeOffer( + by = 0, to = 1, + give = listOf(Resource.LUMBER, Resource.LUMBER), + get = listOf(Resource.BRICK), + ), + resources = hands( + hand(Resource.LUMBER to 3), + hand(Resource.BRICK to 2), + hand(), + ), + ) + val accepted = CatanRules.applyMove(offered, 1, AcceptTrade) + assertEquals(CatanPhase.PLAYING, accepted.phase) + assertNull(accepted.pendingTrade) + assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), accepted.resources[0]) + assertEquals(hand(Resource.LUMBER to 2, Resource.BRICK to 1), accepted.resources[1]) + + val declined = CatanRules.applyMove(offered, 1, DeclineTrade) + assertEquals(CatanPhase.PLAYING, declined.phase) + assertNull(declined.pendingTrade) + assertEquals("a refusal leaves the cards where they were", offered.resources, declined.resources) + } + + @Test + fun `an offer the addressee cannot honour can only be declined`() { + val offered = craft().copy( + phase = CatanPhase.TRADE_PENDING, + pendingTrade = TradeOffer( + by = 0, to = 1, + give = listOf(Resource.LUMBER, Resource.LUMBER), + get = listOf(Resource.BRICK), + ), + resources = hands(hand(Resource.LUMBER to 3), hand(), hand()), + ) + assertEquals(listOf(DeclineTrade), CatanRules.legalMoves(offered, 1)) + } + + @Test + fun `the bank trades four for one and harbours do better`() { + val base = craft() + val lumberPort = base.ports.first { it.resource == Resource.LUMBER } + val genericPort = base.ports.first { it.resource == null } + val portSide = { port: Port -> CatanBoard.EDGE_VERTICES[port.edge].first } + + val plain = base.copy(resources = hands(hand(Resource.LUMBER to 4))) + assertEquals(4, CatanRules.bestPortRatio(plain, 0, Resource.LUMBER)) + val viaBank = CatanRules.applyMove(plain, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK)) + assertEquals(hand(Resource.BRICK to 1), viaBank.resources[0]) + + val special = base.withBuilding(portSide(lumberPort), BuildingType.SETTLEMENT, 0) + .copy(resources = hands(hand(Resource.LUMBER to 2))) + assertEquals(2, CatanRules.bestPortRatio(special, 0, Resource.LUMBER)) + assertEquals(4, CatanRules.bestPortRatio(special, 0, Resource.BRICK)) + val viaPort = CatanRules.applyMove(special, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK)) + assertEquals(hand(Resource.BRICK to 1), viaPort.resources[0]) + + val generic = base.withBuilding(portSide(genericPort), BuildingType.SETTLEMENT, 0) + .copy(resources = hands(hand(Resource.LUMBER to 3))) + assertEquals(3, CatanRules.bestPortRatio(generic, 0, Resource.LUMBER)) + val viaGeneric = CatanRules.applyMove(generic, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK)) + assertEquals(hand(Resource.BRICK to 1), viaGeneric.resources[0]) + } + + @Test + fun `a bank trade needs the cards and a bank with something to give`() { + val base = craft() + assertFalse( + "three is one short of the bank's four", + CatanRules.isLegalMove( + base.copy(resources = hands(hand(Resource.LUMBER to 3))), + 0, + MaritimeTrade(Resource.LUMBER, Resource.BRICK), + ), + ) + assertFalse( + "the bank holds no brick to give", + CatanRules.isLegalMove( + base.copy(resources = hands(hand(Resource.LUMBER to 4), hand(Resource.BRICK to 19))), + 0, + MaritimeTrade(Resource.LUMBER, Resource.BRICK), + ), + ) + assertFalse( + "no resource trades for itself", + CatanRules.isLegalMove( + base.copy(resources = hands(hand(Resource.LUMBER to 4))), + 0, + MaritimeTrade(Resource.LUMBER, Resource.LUMBER), + ), + ) + assertTrue( + CatanRules.isLegalMove( + base.copy(resources = hands(hand(Resource.LUMBER to 4))), + 0, + MaritimeTrade(Resource.LUMBER, Resource.BRICK), + ), + ) + } + + // -- Development cards --------------------------------------------------- + + @Test + fun `buying a card costs wool, grain and ore and hides it until next turn`() { + val base = craft().copy(resources = hands(hand(Resource.WOOL to 1, Resource.GRAIN to 1, Resource.ORE to 1))) + val top = base.devDeck.first() + val state = CatanRules.applyMove(base, 0, BuyDevCard) + assertEquals(hand(), state.resources[0]) + assertEquals(listOf(top.copy(boughtTurn = 1)), state.devHands[0]) + assertEquals(listOf(1, 0, 0), state.devHandCounts) + assertEquals(base.devDeck.drop(1), state.devDeck) + assertTrue("bought this turn, playable next", CatanRules.legalMoves(state, 0).none { it is PlayKnight }) + } + + @Test + fun `a knight may not be played before the roll`() { + val state = craft().copy( + phase = CatanPhase.AWAIT_ROLL, + devHands = listOf(listOf(DevCard(90, DevCardType.KNIGHT, 0)), emptyList(), emptyList()), + devHandCounts = listOf(1, 0, 0), + ) + assertEquals(listOf(RollDice()), CatanRules.legalMoves(state, 0)) + } + + @Test + fun `a card bought on an earlier turn is playable, one per turn`() { + val knight = DevCard(90, DevCardType.KNIGHT, 0) + val roadBuilding = DevCard(91, DevCardType.ROAD_BUILDING, 0) + val state = craft().copy( + devHands = listOf(listOf(knight, roadBuilding), emptyList(), emptyList()), + devHandCounts = listOf(2, 0, 0), + ) + val moves = CatanRules.legalMoves(state, 0) + assertTrue(PlayKnight(90) in moves) + assertTrue(PlayRoadBuilding(91) in moves) + + val after = CatanRules.applyMove(state, 0, PlayKnight(90)) + assertEquals(CatanPhase.ROBBER, after.phase) + assertEquals(1, after.knightsPlayed[0]) + assertEquals(listOf(roadBuilding), after.devHands[0]) + assertTrue(after.devCardPlayedThisTurn) + + // The robber rides first, then the turn owner may build and trade — + // but not play a second card. + val robberMove = CatanRules.legalMoves(after, 0).filterIsInstance().first() + val playing = CatanRules.applyMove(after, 0, robberMove) + assertEquals(CatanPhase.PLAYING, playing.phase) + assertTrue( + "one development card a turn", + CatanRules.legalMoves(playing, 0).none { it is PlayRoadBuilding }, + ) + } + + @Test + fun `three knights take Largest Army, and only a strictly larger army steals it`() { + val firstToThree = craft().copy( + devHands = listOf(listOf(DevCard(90, DevCardType.KNIGHT, 0)), emptyList(), emptyList()), + devHandCounts = listOf(1, 0, 0), + knightsPlayed = listOf(2, 0, 0), + ) + val took = CatanRules.applyMove(firstToThree, 0, PlayKnight(90)) + assertEquals(0, took.largestArmyOwner) + assertEquals(5, CatanRules.publicVictoryPoints(took)[0]) + + // A tie does not steal it. + val tied = craft().copy( + devHands = listOf(listOf(DevCard(91, DevCardType.KNIGHT, 0)), emptyList(), emptyList()), + devHandCounts = listOf(1, 0, 0), + knightsPlayed = listOf(2, 3, 0), + largestArmyOwner = 1, + ) + val kept = CatanRules.applyMove(tied, 0, PlayKnight(91)) + assertEquals("the incumbent keeps a tie", 1, kept.largestArmyOwner) + + // A fourth knight does. + val stronger = craft().copy( + devHands = listOf(listOf(DevCard(92, DevCardType.KNIGHT, 0)), emptyList(), emptyList()), + devHandCounts = listOf(1, 0, 0), + knightsPlayed = listOf(3, 3, 0), + largestArmyOwner = 1, + ) + val stolen = CatanRules.applyMove(stronger, 0, PlayKnight(92)) + assertEquals(0, stolen.largestArmyOwner) + assertEquals(5, CatanRules.publicVictoryPoints(stolen)[0]) + assertEquals(0, CatanRules.publicVictoryPoints(stolen)[1]) + } + + @Test + fun `Road Building lays two free segments and may be forfeited`() { + val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0)) + val ring = hexRing(centre) + val base = craft() + .copy( + resources = hands(hand(Resource.LUMBER to 9, Resource.BRICK to 9)), + devHands = listOf(listOf(DevCard(93, DevCardType.ROAD_BUILDING, 0)), emptyList(), emptyList()), + devHandCounts = listOf(1, 0, 0), + ) + .withRoad(ring[0], 0) + + val opened = CatanRules.applyMove(base, 0, PlayRoadBuilding(93)) + assertEquals(CatanPhase.FREE_ROADS, opened.phase) + assertEquals(2, opened.pendingFreeRoads) + assertTrue(EndFreeRoads in CatanRules.legalMoves(opened, 0)) + + val first = CatanRules.applyMove(opened, 0, BuildRoad(ring[1])) + assertEquals(CatanPhase.FREE_ROADS, first.phase) + assertEquals(1, first.pendingFreeRoads) + assertEquals("free roads cost nothing", base.resources[0], first.resources[0]) + + val forfeited = CatanRules.applyMove(first, 0, EndFreeRoads) + assertEquals(CatanPhase.PLAYING, forfeited.phase) + assertEquals(0, forfeited.pendingFreeRoads) + assertEquals(base.resources[0], forfeited.resources[0]) + assertEquals(2, forfeited.roads.count { it == 0 }) + } + + @Test + fun `Monopoly seizes every card of the named resource`() { + val state = craft().copy( + resources = hands( + hand(Resource.WOOL to 2, Resource.GRAIN to 1), + hand(Resource.WOOL to 3, Resource.BRICK to 1), + hand(Resource.WOOL to 1), + ), + devHands = listOf(listOf(DevCard(94, DevCardType.MONOPOLY, 0)), emptyList(), emptyList()), + devHandCounts = listOf(1, 0, 0), + ) + val after = CatanRules.applyMove(state, 0, PlayMonopoly(94, Resource.WOOL)) + assertEquals(hand(Resource.WOOL to 6, Resource.GRAIN to 1), after.resources[0]) + assertEquals(hand(Resource.BRICK to 1), after.resources[1]) + assertEquals(hand(), after.resources[2]) + assertTrue(after.devHands[0].isEmpty()) + assertTrue(after.devCardPlayedThisTurn) + assertEquals(CatanPhase.PLAYING, after.phase) + } + + @Test + fun `Year of Plenty takes two cards the bank can pay`() { + val base = craft().copy( + devHands = listOf(listOf(DevCard(95, DevCardType.YEAR_OF_PLENTY, 0)), emptyList(), emptyList()), + devHandCounts = listOf(1, 0, 0), + ) + val took = CatanRules.applyMove(base, 0, PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.ORE))) + assertEquals(hand(Resource.GRAIN to 1, Resource.ORE to 1), took.resources[0]) + + // The bank holds a single grain, seat 1 keeping the other eighteen: + // two grain cannot be taken. + val shortBank = base.copy(resources = hands(hand(), hand(Resource.GRAIN to 18), hand())) + assertTrue( + "two of a kind needs a bank that can pay both", + PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.GRAIN)) !in CatanRules.legalMoves(shortBank, 0), + ) + assertTrue( + PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.ORE)) in CatanRules.legalMoves(shortBank, 0), + ) + } + + // -- Longest Road -------------------------------------------------------- + + @Test + fun `a closed ring counts as a simple path, not as two loops`() { + val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0)) + val ring = hexRing(centre) + var state = craft() + ring.forEach { state = state.withRoad(it, 0) } + assertEquals("six segments around one hex count five", 5, CatanRules.longestRoadFor(state, 0)) + } + + @Test + fun `five segments take Longest Road, a tie keeps it, a settlement breaks it`() { + val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0)) + val ring = hexRing(centre) + // A challenger's path on a hex that touches neither the centre nor its ring. + val far = (0 until CatanBoard.HEX_COUNT).first { h -> + h != centre && CatanBoard.HEX_NEIGHBORS[h].none { it == centre } + }.let { hexRing(it) } + + var state = craft().copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1))) + ring.take(4).forEach { state = state.withRoad(it, 0) } + state = CatanRules.applyMove(state, 0, BuildRoad(ring[4])) + assertEquals("the fifth segment takes the award", 0, state.longestRoadOwner) + assertEquals(5, CatanRules.longestRoadFor(state, 0)) + + // An equal five does not steal it. + var challenger = state.copy(turn = 1, resources = hands(hand(), hand(Resource.LUMBER to 1, Resource.BRICK to 1))) + far.take(4).forEach { challenger = challenger.withRoad(it, 1) } + challenger = CatanRules.applyMove(challenger, 1, BuildRoad(far[4])) + assertEquals("the incumbent keeps a tie", 0, challenger.longestRoadOwner) + assertEquals(5, CatanRules.longestRoadFor(challenger, 1)) + + // An opponent's settlement in the middle breaks the holder's road in + // two, and the award falls to the challenger. + val breakAt = sharedVertex(ring[2], ring[3]) + val spoke = CatanBoard.VERTEX_EDGES[breakAt].first { it != ring[2] && it != ring[3] } + val breaker = challenger + .copy( + resources = hands( + hand(), + hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1), + ), + ) + .withRoad(spoke, 1) + val broken = CatanRules.applyMove(breaker, 1, BuildSettlement(breakAt)) + assertEquals("the broken road loses the award", 1, broken.longestRoadOwner) + assertEquals(3, CatanRules.longestRoadFor(broken, 0)) + assertEquals(5, CatanRules.longestRoadFor(broken, 1)) + assertEquals(6, CatanRules.publicVictoryPoints(broken)[1]) + assertEquals(0, CatanRules.publicVictoryPoints(broken)[0]) + } + + // -- Winning ------------------------------------------------------------- + + @Test + fun `a tenth point on the builder's own move wins`() { + val homes = scattered(7) + var state = craft() + .withBuilding(homes[0], BuildingType.CITY, 0) + .withBuilding(homes[1], BuildingType.CITY, 0) + homes.drop(2).forEach { state = state.withBuilding(it, BuildingType.SETTLEMENT, 0) } + state = state.copy(resources = hands(hand(Resource.GRAIN to 2, Resource.ORE to 3))) + + val won = CatanRules.applyMove(state, 0, BuildCity(homes[2])) + assertEquals(CatanPhase.GAME_OVER, won.phase) + assertEquals(0, won.winner) + assertTrue(won.log.any { it.contains("Seat 0 reveals 10 victory points and wins") }) + } + + @Test + fun `hidden victory-point cards count and are revealed by winning`() { + val homes = scattered(7) + var state = craft() + .withBuilding(homes[0], BuildingType.CITY, 0) + .withBuilding(homes[1], BuildingType.CITY, 0) + homes.drop(2).forEach { state = state.withBuilding(it, BuildingType.SETTLEMENT, 0) } + state = state.copy( + devHands = listOf(listOf(DevCard(96, DevCardType.VICTORY_POINT, 0)), emptyList(), emptyList()), + devHandCounts = listOf(1, 0, 0), + ) + + // Nine points stand in public, the tenth hides in hand: ending the + // turn wins before the turn passes. + val won = CatanRules.applyMove(state, 0, EndTurn) + assertEquals(CatanPhase.GAME_OVER, won.phase) + assertEquals(0, won.winner) + assertTrue(won.log.any { it.contains("Seat 0 reveals 10 victory points and wins") }) + } + + @Test + fun `a seat already at the target wins when its own turn arrives`() { + val homes = scattered(5) + // Seat 2 ends its turn, so seat 0 is next in the three-seat rotation. + var state = craft(turn = 2) + homes.forEach { state = state.withBuilding(it, BuildingType.CITY, 0) } + + val won = CatanRules.applyMove(state, 2, EndTurn) + assertEquals(CatanPhase.GAME_OVER, won.phase) + assertEquals(0, won.winner) + assertTrue("seat 0 never had to roll", won.log.none { it.startsWith("Seat 0 rolls") }) + } + + // -- Redaction and wire format ------------------------------------------- + + @Test + fun `a seat view hides the deck and other hands but not the counts`() { + val state = craft().copy( + devDeck = listOf( + DevCard(1, DevCardType.KNIGHT), + DevCard(2, DevCardType.VICTORY_POINT), + ), + devHands = listOf( + listOf(DevCard(3, DevCardType.KNIGHT, 1)), + listOf(DevCard(4, DevCardType.MONOPOLY, 1)), + listOf(DevCard(5, DevCardType.YEAR_OF_PLENTY, 1)), + ), + devHandCounts = listOf(1, 1, 1), + ) + val view = CatanRules.viewFor(state, seat = 1) + assertTrue("the deck order is hidden", view.devDeck.isEmpty()) + assertEquals(state.devHands[1], view.devHands[1]) + assertTrue(view.devHands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() }) + assertEquals(state.devHandCounts, view.devHandCounts) + // Public information travels whole. + assertEquals(state.resources, view.resources) + assertEquals(state.buildings, view.buildings) + assertEquals(state.log, view.log) + } + + @Test + fun `state and every move survive a wire round trip`() { + val homes = scattered(3) + val state = craft( + options = CatanOptions(playerCount = 4), + seed = 99L, + ) + .withBuilding(homes[0], BuildingType.CITY, 2) + .withRoad(CatanBoard.VERTEX_EDGES[homes[1]][0], 3) + .copy( + resources = hands( + hand(Resource.LUMBER to 2), + hand(Resource.BRICK to 1), + hand(Resource.WOOL to 1, Resource.GRAIN to 1), + hand(Resource.ORE to 4), + players = 4, + ), + pendingTrade = TradeOffer( + by = 0, to = 1, + give = listOf(Resource.LUMBER), + get = listOf(Resource.BRICK, Resource.WOOL), + ), + ) + assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state))) + + val moves = listOf( + SetupPlace(3, 5), + RollDice(), + RollDice(listOf(3, 4)), + Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK)), + MoveRobber(HexCoord(1, -1), 2), + MoveRobber(HexCoord(-2, 0)), + BuildRoad(11), + BuildSettlement(13), + BuildCity(13), + BuyDevCard, + PlayKnight(21), + PlayRoadBuilding(22), + PlayMonopoly(23, Resource.ORE), + PlayYearOfPlenty(24, listOf(Resource.GRAIN, Resource.GRAIN)), + OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)), + AcceptTrade, + DeclineTrade, + MaritimeTrade(Resource.WOOL, Resource.GRAIN), + EndTurn, + EndFreeRoads, + ) + moves.forEach { assertEquals(it, CatanRules.decodeMove(CatanRules.encodeMove(it))) } + } + + @Test + fun `summary reports the standing points until somebody wins`() { + val state = craft() + assertTrue(CatanRules.summary(state).contains("S0 0")) + val homes = scattered(5) + // Seat 2 ends, seat 0 is next and wins on arrival. + var won = craft(turn = 2) + homes.forEach { won = won.withBuilding(it, BuildingType.CITY, 0) } + won = CatanRules.applyMove(won, 2, EndTurn) + assertEquals("Seat 0 wins", CatanRules.summary(won)) + } + + // -- Helpers ------------------------------------------------------------- + + private fun assertThrows(block: () -> Unit) { + try { + block() + throw AssertionError("expected the rules to reject that") + } catch (expected: IllegalArgumentException) { + // Rejected, which is the point. + } + } +} diff --git a/docs/README.md b/docs/README.md index ee74ff3..1ce10a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +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) | | Klondike | — (TODO) | | Mastermind | [RULES-mastermind.md](RULES-mastermind.md) | | Morris | [RULES-morris.md](RULES-morris.md) | diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md new file mode 100644 index 0000000..c1db5fc --- /dev/null +++ b/docs/RULES-catan.md @@ -0,0 +1,304 @@ +# Katan — implemented rules + +This is the ruleset the engine enforces. Anything marked **toggle** is exposed +in the game setup screen. + +Katan is this app's name for the island game the collection is built around. +The catalogue id is `catan` (so this file is `RULES-catan.md` and the code lives +in `game/catan/`), but the printed name is trademarked, so the game is listed +and titled **Katan**. The rules themselves are the base game for three to four +players, as published. + +The engine is a pure state machine (`CatanRules`) with no Android types, and its +behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`. + +## Table + +- **Players — *toggle*.** Three or four. `CatanOptions.playerCount`; the engine + refuses anything else. +- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at + least 2. A lower target makes a test game short. + +The setup screen that exposes these lands with the screen task; the engine reads +them from the table configuration today. + +## Board + +The island is the standard layout, derived once from axial hex coordinates in +`CatanBoard` and checked at class-init: + +- **19 hexes**, the hexes within distance 2 of the centre. +- **54 vertices** and **72 edges**, shared between neighbouring hexes by exact + integer position, not floating point. `HEX_CORNERS`, `EDGE_VERTICES`, + `VERTEX_EDGES`, `VERTEX_HEXES` and `EDGE_HEXES` are the shared graph. +- **30 coastal edges**, one closed ring. +- **9 harbours**, on fixed positions around the coast. + +Terrain is dealt from the physical mix and shuffled: **4 forest, 4 pasture, +4 fields, 3 hills, 3 mountains, 1 desert**. + +Number tokens are the 18 the game ships, shuffled over the land hexes: four red +tokens (**6, 6, 8, 8**) and fourteen others +(**2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12**). Seven never appears. The +desert bears no token and the robber starts on it. + +### Keeping 6s and 8s apart + +The published game asks that the two red numbers not sit on adjacent hexes, and +`generate` guarantees it rather than hoping for it: + +1. Shuffle the land and take four hexes; accept the draw if none of the four + touch each other. Retry up to 60 times. +2. If no draw separates them, fall back to four of the six board-corner hexes, + which are pairwise non-adjacent by construction and at most one of which can + be the desert. + +`no seed puts two red tokens on adjacent hexes` runs the whole thing over seeds +1..500. + +Harbours: the nine **positions** are fixed (the physical frame is not shuffled), +but the nine **types** are shuffled onto them — **five 2:1 resource harbours** +(one per resource) and **four generic 3:1 harbours**. The frame positions are +chosen so no two harbours sit closer than three coastal edges apart. + +## Setup + +Placement runs in snake order: seats `0, 1, …, n-1` then `n-1, …, 1, 0`, each +placing one settlement and one road. During setup the distance rule still holds +(a settlement may not sit one edge from any other building) but the road need +not connect to anything already placed. + +A player's **second** settlement earns one resource card for each adjacent +producing hex, supply permitting — duplicates counted, so a second settlement on +two forests earns two lumber. The first pass earns nothing. + +After the last placement, seat 0 leads and the first real turn begins. + +## Turn + +A turn runs through the phases in `CatanPhase`: + +1. **AWAIT_ROLL** — the player on the clock rolls. +2. A seven goes to **DISCARDING** and then **ROBBER**; anything else produces and + goes straight to **PLAYING**. +3. **PLAYING** — build, trade, buy and play development cards, then end the turn. +4. **FREE_ROADS** is a detour inside PLAYING while a Road Building card is being + spent. + +### Rolling + +The dice are derived from the game seed and a monotonic counter (`rngStep`), not +from a random generator stored in the state: the same seed and the same moves +replay identically. `RollDice(forced = …)` exists so tests can stage a number; +it is deliberately **not** a member of the enumerated move list, so a client +cannot send one over the network — only code holding the state directly can use +it. + +### Production + +For a non-seven total, each hex whose number matches pays its producing terrain: +a settlement takes **1**, a city takes **2**. The robber's hex pays nothing. + +**When the bank cannot meet the demand for a resource, nobody receives that +resource this roll.** This is the official ruling, implemented per-resource: +demand is totalled first and, if it exceeds the bank's supply, every gain of +that resource is dropped. + +## The robber + +A **seven** sends the robber: first every player holding **more than seven** +resource cards discards half their hand (rounded down, so nine cards means four +gone), in seat order starting from the lowest overfull seat; then the roller +moves the robber and may steal. + +The robber **must move to a different hex**. Moving onto a hex lets the roller +steal one card from one opponent who has a building on that hex and at least one +card in hand. The card stolen is chosen at random, derived like the dice. A +hex with no opponent holding cards offers only the move, no steal. + +A played Knight enters the same ROBBER phase, so the robber move after a Knight +is the same code path. + +## Trading + +### Domestic + +An offer names **one** addressee and two card multisets: what the offerer gives, +what they want. There are no counter-offers and no open auctions in v1. + +An offer is legal only when the offerer's turn is in PLAYING, the addressee is +another seat, both legs are non-empty, the two legs are not the same multiset +(`countOf(give) != countOf(get)` — like-for-like is not a trade), and the +offerer actually holds the cards being offered. + +The addressee may **accept** only if they hold what was asked and the offerer +still holds what was promised; otherwise only **decline** is offered. A decline +clears the table and leaves every card where it was. + +Offers are free-form intents rather than enumerated moves (the space of card +multisets is combinatorial), so they are validated structurally by +`CatanRules.isLegalMove` and checked by the network driver's gate like any other +move. + +### Maritime + +Bank trade gives `ratio` of one resource for one of another, where the ratio is +the best the seat can get: **4:1** through the bank, **3:1** through a generic +harbour, **2:1** through a harbour matching the resource being given. A harbour +counts when the seat owns a settlement or city on **either** endpoint vertex of +its edge. + +## Building and development cards + +| Build | Cost | +| --- | --- | +| Road | 1 lumber + 1 brick | +| Settlement | 1 lumber + 1 brick + 1 wool + 1 grain | +| City | 2 grain + 3 ore | +| Development card | 1 wool + 1 grain + 1 ore | + +A settlement goes on an **empty vertex** at least two edges from every standing +building and connected to the seat's own road network. A city upgrades one of +the seat's own settlements. A road goes on an empty edge that connects to the +seat's network (a road or a building at one of its ends). + +The development deck is the published 25 cards: **14 Knights, 2 Road Building, +2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the +top of the deck. + +Timing rules the engine enforces: + +- **One development card per turn.** `devCardPlayedThisTurn` gates every play. +- **A card cannot be played the turn it was bought.** Each card records + `boughtTurn` and is skipped while that equals the current turn. +- **Victory Point cards are never played.** They are counted straight from the + hand by `victoryPoints`, and are only revealed by winning. +- **Knight** → the robber phase, and counts toward Largest Army. +- **Road Building** → two free road placements (FREE_ROADS); the player may + forfeit any that remain. +- **Monopoly** → seizes every card of the named resource from the other seats. +- **Year of Plenty** → takes up to two cards from the bank; taking two of a kind + requires the bank to hold both. + +## Scoring and winning + +Victory points are: + +- **Settlement 1, city 2.** +- **Longest Road 5** — the longest single road of **five or more** segments. +- **Largest Army 5** — first to **three** played knights. +- **Victory Point cards**, one each. + +A seat wins on reaching **points to win** (default 10). The win is checked after +every build, when a turn ends, and when a seat's turn arrives — the last covers +points that changed on someone else's turn, such as a broken Longest Road being +re-awarded. A seat already at the target therefore wins at the start of its own +turn rather than having to act. + +### Longest Road + +The award goes to the longest **simple path** over a seat's own roads. The path +never passes *through* an opponent's building (it may run up to one) and never +revisits a vertex — so a figure of two loops sharing an intersection counts as +one road of five, not two roads, because the intersection is a branch. + +Once held, the award is kept until another seat builds a **strictly longer** +road; an equal length leaves it where it is. If the holder's road falls below +five segments the award reopens to the longest road of five or more, lowest seat +first on a tie. A settlement placed across an opponent's road breaks it, and the +award is recomputed for everyone after any settlement. + +### Largest Army + +First to three knights takes it. It is stolen only by a **strictly larger** army: +a tie leaves the incumbent ahead, exactly as Longest Road does. + +## The computer opponent + +`CatanAi` plays a legal, mildly strategic game so a solo player can host against +computers, with the lobby filling empty seats. + +It reads **only public information** — terrain, numbers, buildings, roads, +resource counts, development-card counts and knights played — plus its own hand. +It never looks into the deck, another seat's development cards, or the seed +beyond the roll the rules derive from it. That is a design constraint, not an +accident: it runs on the host, where the authoritative state is fully visible. + +What it does: + +- **Setup.** Rates a settlement by the production its three hexes give (weighted + by the token odds and by how much it wants each resource), plus a bonus for + resource variety and for sitting on a harbour, plus a small term for where the + accompanying road points. +- **Main phase**, in priority order: raise a city, build a settlement, play a + knight, play Year of Plenty or Monopoly when it closes a gap, play Road + Building when two segments are useful, buy a development card, build a road + that reaches a spot worth settling, trade at the bank only when the trade + completes a build this turn, make one domestic offer that closes a build, else + end the turn. +- **The robber.** Moves onto the hex where it blocks the most opposing + production (weighted by token odds), preferring to rob whoever is holding the + most cards and the most points. +- **Trades.** Accepts an offer when what arrives is worth more than what leaves + by its own resource valuations; a mild table, not a shark. +- **Discards.** Throws away what it values least (wool, then brick, then lumber, + before grain and ore). + +It makes **at most one domestic offer a turn**. A declined offer changes nothing, +so re-offering the same thing would only bounce between two seats forever; the +engine allows more offers, the AI chooses not to. + +`CatanAiTest` plays three- and four-seat tables of engines out to a result from +several seeds, refusing any illegal move the AI chooses, and checks the winner +reached the target. + +## Provenance and rule choices + +The rules are the published base game. Where the published rules leave room, the +engine picks one reading and records it here. + +**Followed from the published rules:** the board and its component mix; the +number tokens; 6s and 8s not adjacent; snake-order setup and second-settlement +income; production weighting; the robber on a seven; discard half over seven; +domestic and maritime trade; build costs; the 25-card development deck and its +timing rules; Longest Road and Largest Army worth five; victory at ten. + +**Choices made here, and why:** + +- **A bank short of a resource pays nobody that roll.** This is the official + ruling, and it is implemented per-resource rather than per-player. +- **Longest Road and Largest Army are kept on a tie**, and stolen only by a + strictly longer road or a strictly larger army. The alternative reading lets a + tie steal the award; "strictly better" is the common tournament reading and + keeps the award from flickering. +- **A ring road counts as a simple path.** No vertex is revisited, so six + segments around one hex are worth five, not six. +- **The win is checked when a seat's turn arrives**, not only on that seat's own + action, so points that move on another player's turn still win. +- **A bought Victory Point card does not win immediately.** It is counted when + the win is next checked (a build or the end of the turn), which is when the + holder would reveal it. +- **Year of Plenty two-of-a-kind needs the bank to hold both cards**, not one. +- **The robber must move.** Some printings allow staying put; this engine always + requires a different hex. +- **Offers are one-to-one, no auctions or counter-offers** in v1. + +## Known gaps + +- **No domestic auction or counter-offer.** An offer is addressed to a single + seat. Open trading to the whole table, and counter-offers, are the obvious next + step and are not implemented. +- **No player-to-player trade over the wire beyond accept/decline.** The engine + supports it; whether the UI surfaces it is the screen task's concern. +- **No trading away a development card.** Not part of the base game. +- **The setup screen is not part of this task.** `CatanOptions` (players, target + points) is read from the table configuration; the screen that sets it, and the + catalogue descriptor's `available` flag, land with the screen and verification + tasks. +- **The AI does not look ahead.** It scores one move deep with a fixed resource + valuation. It plays legally and plausibly; it is not a strong opponent, and no + strength claim is made. +- **The board is the standard published layout, generated.** Unlike the Sequence + board there is no unverified substitute here — the geometry is the real one — + but the *island* (terrain and token placement) is freshly generated each game, + so it will not match any particular physical set-up. That is the intent. -- 2.47.3 From c0fc4fe7d6612ec154d1f88956df8297e2e9c9e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:20:47 +0000 Subject: [PATCH 02/19] Publish dev and prod builds to plg.wallacenet.ca (Cloudflare Pages) Both workflows gain a deploy-pages job that stages their channel into one Pages site, carrying the other channel over from the live site. The in-app updater now reads /prod|/dev/latest.json from plg.wallacenet.ca instead of the GitHub API. GitHub releases are still published for one transition deployment so existing installs can fetch the new updater. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 46 ++++++++++++ .github/workflows/ci.yml | 50 +++++++++++++ .github/workflows/release.yml | 43 ++++++++++- README.md | 26 ++++++- app/src/main/AndroidManifest.xml | 2 +- .../prolibertate/games/update/UpdatePolicy.kt | 4 +- .../org/prolibertate/games/update/Updater.kt | 75 +++++-------------- site/_headers | 7 ++ site/index.html | 34 +++++++++ 9 files changed, 223 insertions(+), 64 deletions(-) create mode 100755 .github/scripts/stage-pages-site.sh create mode 100644 site/_headers create mode 100644 site/index.html diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh new file mode 100755 index 0000000..7ef431d --- /dev/null +++ b/.github/scripts/stage-pages-site.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Assembles the Cloudflare Pages site for plg.wallacenet.ca into ./pages-out. +# +# Usage: stage-pages-site.sh +# +# A Pages deploy replaces the whole site, and both channels live in one site, +# so this stages the channel being published and then re-fetches the *other* +# channel's current files from the live site so the deploy does not erase them. +set -euo pipefail + +channel="$1"; tag="$2"; apk="$3" +base="${PAGES_BASE_URL:-https://plg.wallacenet.ca}" +out="pages-out" +limit=$((25 * 1024 * 1024)) # Cloudflare Pages: 25 MiB per file + +case "$channel" in prod) other=dev ;; dev) other=prod ;; *) echo "bad channel: $channel" >&2; exit 2 ;; esac + +rm -rf "$out" +mkdir -p "$out/$channel" "$out/$other" +cp site/index.html site/_headers "$out/" + +name="$(basename "$apk")" +cp "$apk" "$out/$channel/$name" +cp "$apk" "$out/$channel/ProLibertateGames.apk" +jq -n --arg tag "$tag" --arg name "$name" --arg ch "$channel" --arg url "$base/$channel/$name" \ + '{tag: $tag, apkName: $name, apkUrl: $url, channel: $ch}' > "$out/$channel/latest.json" + +# Carry the other channel over. 404 means it was never published; any other +# failure must stop the deploy rather than silently dropping that channel. +code="$(curl -sS -o "$out/$other/latest.json" -w '%{http_code}' "$base/$other/latest.json" || true)" +: "${code:=000}" +if [ "$code" = 404 ]; then + echo "No $other channel published yet; skipping." + rm -rf "$out/$other" +elif [ "$code" = 200 ]; then + other_name="$(jq -er .apkName "$out/$other/latest.json")" + curl -fsS -o "$out/$other/$other_name" "$base/$other/$other_name" + cp "$out/$other/$other_name" "$out/$other/ProLibertateGames.apk" +else + echo "Fetching $other/latest.json returned HTTP $code; refusing to deploy." >&2 + exit 1 +fi + +find "$out" -name '*.apk' -size +"${limit}c" -print | grep . >&2 && { + echo "APK exceeds Cloudflare Pages' 25 MiB per-file limit." >&2; exit 1; } || true +find "$out" -type f | sort diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc2f564..237edcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: build: name: Build and check runs-on: ubuntu-latest + outputs: + version: ${{ steps.stage.outputs.version }} env: # Stamp the APK as a dev-channel build exactly when it is a build of the # dev branch, which is exactly when it can be published below. Pull @@ -108,6 +110,15 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT" + - name: Upload staged dev APK for the Pages deploy + if: github.ref == 'refs/heads/dev' + uses: actions/upload-artifact@v4 + with: + name: plg-dev-staged + path: ${{ steps.stage.outputs.apk }} + if-no-files-found: error + retention-days: 1 + - name: Publish dev channel prerelease if: github.ref == 'refs/heads/dev' env: @@ -125,3 +136,42 @@ jobs: Preview build: newer than production, and less tested. Switch to the Dev update channel in Settings to be offered these. Marked as a prerelease so production-channel installs never see it." + + # Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages). Runs + # alongside the GitHub release above for one deployment so already-installed + # apps, which still read GitHub, can fetch the updater that reads Cloudflare. + # + # Both workflows deploy into one Pages site, and a deploy replaces the whole + # site, so they share a concurrency group and never run at the same time. + deploy-pages: + name: Deploy to Cloudflare Pages + needs: build + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + concurrency: + group: plg-pages-deploy + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download staged APK + uses: actions/download-artifact@v4 + with: + name: plg-dev-staged + path: staged + + - name: Assemble site + run: .github/scripts/stage-pages-site.sh dev "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk" + + - name: Deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + # Creates the project on first run; fails harmlessly if it exists. + npx --yes wrangler@3 pages project create plg --production-branch main || true + # --branch main makes this the production deployment, which is what + # plg.wallacenet.ca serves, whichever branch the workflow ran on. + npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ef3a4a..bedfa2a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,8 @@ jobs: # anything else is a no-op rather than shipping unreviewed code to everyone. if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + outputs: + version: ${{ steps.stage.outputs.version }} env: # Stamps the APK as a production-channel build, versioned with the run # number that matches the tag published below. @@ -69,7 +71,7 @@ jobs: - name: Upload APK as workflow artifact uses: actions/upload-artifact@v4 with: - name: ProLibertateGames-apk + name: plg-prod-staged path: ${{ steps.stage.outputs.apk }} if-no-files-found: error @@ -91,3 +93,42 @@ jobs: Both files below are the same build. \`ProLibertateGames.apk\` is a copy under a fixed name, so this link always fetches the newest release and can be bookmarked or shared: https://github.com/${{ github.repository }}/releases/latest/download/ProLibertateGames.apk" + + # Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages). Runs + # alongside the GitHub release above for one deployment so already-installed + # apps, which still read GitHub, can fetch the updater that reads Cloudflare. + # + # Both workflows deploy into one Pages site, and a deploy replaces the whole + # site, so they share a concurrency group and never run at the same time. + deploy-pages: + name: Deploy to Cloudflare Pages + needs: build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + concurrency: + group: plg-pages-deploy + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download staged APK + uses: actions/download-artifact@v4 + with: + name: plg-prod-staged + path: staged + + - name: Assemble site + run: .github/scripts/stage-pages-site.sh prod "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk" + + - name: Deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + # Creates the project on first run; fails harmlessly if it exists. + npx --yes wrangler@3 pages project create plg --production-branch main || true + # --branch main makes this the production deployment, which is what + # plg.wallacenet.ca serves, whichever branch the workflow ran on. + npx --yes wrangler@3 pages deploy pages-out --project-name plg --branch main --commit-dirty=true diff --git a/README.md b/README.md index c7d19c9..a87876c 100644 --- a/README.md +++ b/README.md @@ -198,9 +198,31 @@ production package instead. publishes it as a GitHub Release tagged `v.`. The in-app updater reads that tag, which is why the APK's `versionCode` is the same run number. +### Hosting: plg.wallacenet.ca + +Both channels are also deployed to one Cloudflare Pages project (`plg`), served at +`https://plg.wallacenet.ca`: + +``` +/ landing page (reads both latest.json files) +/prod/latest.json {tag, apkName, apkUrl, channel} — what the updater reads +/prod/ProLibertateGames.apk, /prod/ProLibertateGames-v..apk +/dev/... same, for the dev channel +``` + +Each workflow's `deploy-pages` job runs `.github/scripts/stage-pages-site.sh`, +which stages its own channel and re-fetches the other channel from the live site, +because a Pages deploy replaces everything. The two share a concurrency group so +they cannot race. Needs repo secrets `CLOUDFLARE_API_TOKEN` (Account → Cloudflare +Pages → Edit) and `CLOUDFLARE_ACCOUNT_ID`. Pages caps files at 25 MiB. + +The in-app updater reads these `latest.json` files. GitHub Releases are still +published for one transition deployment so installs that predate this change can +fetch it; after that, remove the GitHub release steps from both workflows. + ### Installing without the app -The newest production build is always at this link, which never changes: +The newest production build is always at **https://plg.wallacenet.ca/prod/ProLibertateGames.apk** (dev: `/dev/ProLibertateGames.apk`). The GitHub link below remains during the transition: **https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk** @@ -367,7 +389,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested net/ wire protocol, LAN transport, lobby, match driver score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store settings/ DataStore-backed preferences -update/ GitHub Releases OTA updater, and the channel rule — pure, tested +update/ Cloudflare Pages OTA updater, and the channel rule — pure, tested ui/ Compose screens ``` diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bdb6768..6aac16d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt index f57aff7..b2e7aa2 100644 --- a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt +++ b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt @@ -21,7 +21,7 @@ enum class UpdateChannel(val label: String, val blurb: String) { /** Marks a tag or a version name as belonging to the dev channel. */ const val DEV_SUFFIX = "-dev" -/** One published build, as read off a GitHub release. */ +/** One published build, as read off a channel's `latest.json`. */ data class Release( val tag: String, val versionCode: Int, @@ -70,7 +70,7 @@ fun versionCodeOfTag(tag: String): Int? = * nothing at all. * * Only an actual update is worth a word. Opening the app is not asking a - * question, so a check that finds nothing — or that cannot reach GitHub at all + * question, so a check that finds nothing — or that cannot reach the update server at all * — stays quiet; a phone on a train would otherwise be told off for it every * single launch. The button in Settings *is* asking a question, and reports * every answer including those two. diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index 598bf7a..e0ea123 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -8,19 +8,17 @@ import android.provider.Settings import androidx.core.content.FileProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.json.JSONArray import org.json.JSONObject import java.io.File import java.net.HttpURLConnection import java.net.URL /** - * Over-the-air updates from the repository's GitHub Releases. + * Over-the-air updates from plg.wallacenet.ca (Cloudflare Pages). * * Release tags are v. and the APK's versionCode is that same run * number, so comparing the two is enough to tell whether a newer build exists. - * The repository's releases must be publicly downloadable — no token is - * embedded in the app, and GitHub blocks anonymous access to private assets. + * Everything there is public — no token is embedded in the app. * * There is nothing to choose here. The channel comes from the running APK, so * a dev install looks only at dev builds and a production install only at @@ -48,65 +46,37 @@ class Updater(private val activity: Activity) { } /** - * Production reads GitHub's "latest release", which by definition skips - * prereleases. Dev walks the release list for the newest prerelease, which - * is what the CI pipeline publishes from the dev branch. + * Each channel publishes a `latest.json` next to its APK on Cloudflare + * Pages: `/prod/latest.json` and `/dev/latest.json`. The two live in one + * site but separate paths, so a build can only ever look down its own line. */ - private fun fetchLatestRelease(channel: UpdateChannel): Release? = when (channel) { - UpdateChannel.PRODUCTION -> - getJsonObject("https://api.github.com/repos/$OWNER/$REPO/releases/latest") - ?.let { toRelease(it) } - - UpdateChannel.DEV -> - getJsonArray("https://api.github.com/repos/$OWNER/$REPO/releases?per_page=30") - ?.let { releases -> - // GitHub returns newest first. - (0 until releases.length()) - .map { releases.getJSONObject(it) } - .firstOrNull { it.optBoolean("prerelease") && !it.optBoolean("draft") } - ?.let { toRelease(it) } - } + private fun fetchLatestRelease(channel: UpdateChannel): Release? { + val path = if (channel == UpdateChannel.DEV) "dev" else "prod" + return getJsonObject("$BASE_URL/$path/latest.json")?.let { toRelease(it) } } private fun toRelease(json: JSONObject): Release? { - val tag = json.optString("tag_name").ifBlank { return null } + val tag = json.optString("tag").ifBlank { return null } val versionCode = versionCodeOfTag(tag) ?: return null - val channel = channelOfTag(tag, json.optBoolean("prerelease")) - - val assets = json.optJSONArray("assets") ?: return null - val apks = (0 until assets.length()) - .map { assets.getJSONObject(it) } - .filter { it.optString("name").endsWith(".apk") } - - // A production release carries the same build twice: once named for its - // version, and once under a fixed name so a permanent download link - // exists. Pick the versioned one explicitly rather than whichever was - // uploaded first, so what gets installed is traceable to its tag. - val chosen = apks.firstOrNull { it.optString("name").contains(tag) } - ?: apks.firstOrNull() - ?: return null - + val apkUrl = json.optString("apkUrl").ifBlank { return null } return Release( tag = tag, versionCode = versionCode, - apkUrl = chosen.getString("browser_download_url"), - apkName = chosen.optString("name"), - channel = channel, + apkUrl = apkUrl, + apkName = json.optString("apkName"), + channel = channelOfTag(tag, prerelease = false), ) } - private fun openConnection(url: String): HttpURLConnection = - (URL(url).openConnection() as HttpURLConnection).apply { + private fun getJsonObject(url: String): JSONObject? { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { requestMethod = "GET" - setRequestProperty("Accept", "application/vnd.github+json") setRequestProperty("User-Agent", "ProLibertateGames-Updater") connectTimeout = 15_000 readTimeout = 15_000 } - - private fun getJsonObject(url: String): JSONObject? { - val connection = openConnection(url) try { + // 404 means the channel has nothing published yet. if (connection.responseCode != 200) return null return JSONObject(connection.inputStream.bufferedReader().use { it.readText() }) } finally { @@ -114,16 +84,6 @@ class Updater(private val activity: Activity) { } } - private fun getJsonArray(url: String): JSONArray? { - val connection = openConnection(url) - try { - if (connection.responseCode != 200) return null - return JSONArray(connection.inputStream.bufferedReader().use { it.readText() }) - } finally { - connection.disconnect() - } - } - /** Read back from the installed APK's own version name. */ fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName()) @@ -175,7 +135,6 @@ class Updater(private val activity: Activity) { activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown" private companion object { - const val OWNER = "adrianoftyriel" - const val REPO = "ProLibertateGames" + const val BASE_URL = "https://plg.wallacenet.ca" } } diff --git a/site/_headers b/site/_headers new file mode 100644 index 0000000..7a2fe55 --- /dev/null +++ b/site/_headers @@ -0,0 +1,7 @@ +/*/latest.json + Cache-Control: no-cache + Access-Control-Allow-Origin: * + +/*.apk + Content-Type: application/vnd.android.package-archive + Cache-Control: public, max-age=300 diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..cd93ac0 --- /dev/null +++ b/site/index.html @@ -0,0 +1,34 @@ + + + + + +Pro Libertate Games + + + +

Pro Libertate Games

+

Android builds. Allow Install unknown apps for your browser to sideload.

+

Production

Loading…

+

Dev

Loading…

+ + + -- 2.47.3 From 4369d15cec6a77d7d9aa1139e18f33e83c164ff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:39:03 +0000 Subject: [PATCH 03/19] Add first_deploy dispatch input so the first Cloudflare deploy can bootstrap The domain answers 403 (error 1014) until the Pages project exists, which made the carry-over fetch abort the very deploy that would create it. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 9 +++++++++ .github/workflows/ci.yml | 7 +++++++ .github/workflows/release.yml | 7 +++++++ README.md | 2 ++ 4 files changed, 25 insertions(+) diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh index 7ef431d..13fa60e 100755 --- a/.github/scripts/stage-pages-site.sh +++ b/.github/scripts/stage-pages-site.sh @@ -25,6 +25,15 @@ cp "$apk" "$out/$channel/ProLibertateGames.apk" jq -n --arg tag "$tag" --arg name "$name" --arg ch "$channel" --arg url "$base/$channel/$name" \ '{tag: $tag, apkName: $name, apkUrl: $url, channel: $ch}' > "$out/$channel/latest.json" +# PLG_SKIP_OTHER=1 is for the very first deploy, when the site does not exist +# yet and the domain answers 403 (Cloudflare error 1014) instead of 404. +if [ -n "${PLG_SKIP_OTHER:-}" ]; then + echo "PLG_SKIP_OTHER set; not carrying over $other." + rm -rf "$out/$other" + find "$out" -type f | sort + exit 0 +fi + # Carry the other channel over. 404 means it was never published; any other # failure must stop the deploy rather than silently dropping that channel. code="$(curl -sS -o "$out/$other/latest.json" -w '%{http_code}' "$base/$other/latest.json" || true)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 237edcc..d41f320 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,11 @@ on: pull_request: branches: [ main, dev ] workflow_dispatch: + inputs: + first_deploy: + description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)" + type: boolean + default: false # Needed to publish the dev-channel prerelease below. permissions: @@ -162,6 +167,8 @@ jobs: path: staged - name: Assemble site + env: + PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }} run: .github/scripts/stage-pages-site.sh dev "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk" - name: Deploy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bedfa2a..91260c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,11 @@ on: push: branches: [ main ] workflow_dispatch: + inputs: + first_deploy: + description: "First Cloudflare deploy: skip carrying over the other channel (the site does not exist yet)" + type: boolean + default: false # Allow the built-in token to create releases and upload the APK. permissions: @@ -119,6 +124,8 @@ jobs: path: staged - name: Assemble site + env: + PLG_SKIP_OTHER: ${{ inputs.first_deploy && '1' || '' }} run: .github/scripts/stage-pages-site.sh prod "${{ needs.build.outputs.version }}" "staged/ProLibertateGames-${{ needs.build.outputs.version }}.apk" - name: Deploy diff --git a/README.md b/README.md index a87876c..cd3414f 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ because a Pages deploy replaces everything. The two share a concurrency group so they cannot race. Needs repo secrets `CLOUDFLARE_API_TOKEN` (Account → Cloudflare Pages → Edit) and `CLOUDFLARE_ACCOUNT_ID`. Pages caps files at 25 MiB. +**First deploy:** the site does not exist yet, so the domain answers 403 (Cloudflare error 1014) and the carry-over fetch would abort. Dispatch the workflow once with `first_deploy` ticked, then attach `plg.wallacenet.ca` as a custom domain on the new `plg` Pages project. Later runs need no input. + The in-app updater reads these `latest.json` files. GitHub Releases are still published for one transition deployment so installs that predate this change can fetch it; after that, remove the GitHub release steps from both workflows. -- 2.47.3 From 2df140a3c1ee031a113d987b53a5501f5ebcb73f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:53:15 +0000 Subject: [PATCH 04/19] Treat Cloudflare error 1014 as 'channel not published yet' A push-triggered first deploy cannot set the first_deploy input, and the domain answers 403/1014 until the Pages project exists. 1014 means nothing exists to preserve, so handle it like a 404. Other 403s still abort. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh index 13fa60e..f9947f4 100755 --- a/.github/scripts/stage-pages-site.sh +++ b/.github/scripts/stage-pages-site.sh @@ -38,6 +38,12 @@ fi # failure must stop the deploy rather than silently dropping that channel. code="$(curl -sS -o "$out/$other/latest.json" -w '%{http_code}' "$base/$other/latest.json" || true)" : "${code:=000}" +# Cloudflare error 1014 (403 + "error code: 1014") means the domain's CNAME +# points at a Pages project that does not exist yet, so there is nothing to +# preserve — the same as a 404, and what a push-triggered first deploy sees. +if [ "$code" = 403 ] && grep -q 'error code: 1014' "$out/$other/latest.json" 2>/dev/null; then + code=404 +fi if [ "$code" = 404 ]; then echo "No $other channel published yet; skipping." rm -rf "$out/$other" -- 2.47.3 From 6fbe63f787ec5ac08ea56e81e0778a510005cdce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:08:28 +0000 Subject: [PATCH 05/19] Fix carry-over on Pages: add 404.html and tolerate 200-with-HTML Pages serves index.html with 200 for unknown paths when there is no 404.html, so a never-published channel looked published and jq failed on the HTML. Add a real 404 page and treat a 200 without a latest.json as absent. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 7 ++++++- site/404.html | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 site/404.html diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh index f9947f4..4521fe9 100755 --- a/.github/scripts/stage-pages-site.sh +++ b/.github/scripts/stage-pages-site.sh @@ -17,7 +17,7 @@ case "$channel" in prod) other=dev ;; dev) other=prod ;; *) echo "bad channel: $ rm -rf "$out" mkdir -p "$out/$channel" "$out/$other" -cp site/index.html site/_headers "$out/" +cp site/index.html site/404.html site/_headers "$out/" name="$(basename "$apk")" cp "$apk" "$out/$channel/$name" @@ -47,6 +47,11 @@ fi if [ "$code" = 404 ]; then echo "No $other channel published yet; skipping." rm -rf "$out/$other" +elif [ "$code" = 200 ] && ! jq -e .apkName "$out/$other/latest.json" >/dev/null 2>&1; then + # A 200 that is not our JSON: Pages without a 404.html answers unknown paths + # with index.html. Nothing is published on that channel. + echo "No $other channel published yet (200 but no latest.json); skipping." + rm -rf "$out/$other" elif [ "$code" = 200 ]; then other_name="$(jq -er .apkName "$out/$other/latest.json")" curl -fsS -o "$out/$other/$other_name" "$base/$other/$other_name" diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..356ca3b --- /dev/null +++ b/site/404.html @@ -0,0 +1,3 @@ + +Not found +

404

Not found. Pro Libertate Games downloads

-- 2.47.3 From ad30ab9deacbdc69ddc8316c618a7e95622e8585 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:00:01 +0000 Subject: [PATCH 06/19] Move CI to Gitea: run-number offset, Cloudflare-only publishing - plgVersionRunOffset=1000: stamped build number is run number + offset, so Gitea's restarted run counter cannot produce a versionCode below an installed one (GitHub reached prod 30, dev ~172). Tags and versionName use the same number. - Drop the GitHub release steps; plg.wallacenet.ca is the only channel. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/workflows/ci.yml | 28 ++++++---------------------- .github/workflows/release.yml | 29 ++++++----------------------- CLAUDE.md | 6 ++++++ README.md | 14 ++++++++++---- app/build.gradle.kts | 12 +++++++++--- gradle.properties | 8 ++++++++ 6 files changed, 45 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d41f320..0debc3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,10 @@ jobs: # it cannot name different versions. series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" test -n "$series" - version="v${series}.${{ github.run_number }}-dev" + offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)" + 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 ))-dev" src="app/build/outputs/apk/debug/app-debug.apk" dest="ProLibertateGames-${version}.apk" cp "$src" "$dest" @@ -124,27 +127,8 @@ jobs: if-no-files-found: error retention-days: 1 - - name: Publish dev channel prerelease - if: github.ref == 'refs/heads/dev' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh release create "${{ steps.stage.outputs.version }}" \ - "${{ steps.stage.outputs.apk }}" \ - --repo "${{ github.repository }}" \ - --target "${{ github.sha }}" \ - --title "ProLibertateGames ${{ steps.stage.outputs.version }}" \ - --prerelease \ - --notes "Automated **dev channel** build from \`${{ github.sha }}\` on \`dev\`. - - Preview build: newer than production, and less tested. Switch to the - Dev update channel in Settings to be offered these. Marked as a - prerelease so production-channel installs never see it." - - # Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages). Runs - # alongside the GitHub release above for one deployment so already-installed - # apps, which still read GitHub, can fetch the updater that reads Cloudflare. + # Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is + # the only release channel: the in-app updater reads latest.json from there. # # Both workflows deploy into one Pages site, and a deploy replaces the whole # site, so they share a concurrency group and never run at the same time. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91260c3..8e9bc93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,10 @@ jobs: # it cannot name different versions. series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" test -n "$series" - version="v${series}.${{ github.run_number }}" + offset="$(sed -n 's/^plgVersionRunOffset=//p' gradle.properties)" + 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" dest="ProLibertateGames-${version}.apk" cp "$src" "$dest" @@ -80,28 +83,8 @@ jobs: path: ${{ steps.stage.outputs.apk }} if-no-files-found: error - - name: Create GitHub release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh release create "${{ steps.stage.outputs.version }}" \ - "${{ steps.stage.outputs.apk }}" \ - "ProLibertateGames.apk" \ - --repo "${{ github.repository }}" \ - --target "${{ github.sha }}" \ - --title "ProLibertateGames ${{ steps.stage.outputs.version }}" \ - --notes "Automated release built from \`${{ github.sha }}\` on push to \`main\`. - - Download **${{ steps.stage.outputs.apk }}** below and install it on an Android device (you may need to allow *Install unknown apps*). This is a debug-signed build. - - Both files below are the same build. \`ProLibertateGames.apk\` is a copy under a fixed name, so this link always fetches the newest release and can be bookmarked or shared: - - https://github.com/${{ github.repository }}/releases/latest/download/ProLibertateGames.apk" - - # Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages). Runs - # alongside the GitHub release above for one deployment so already-installed - # apps, which still read GitHub, can fetch the updater that reads Cloudflare. + # Publishes the build to https://plg.wallacenet.ca (Cloudflare Pages), which is + # the only release channel: the in-app updater reads latest.json from there. # # Both workflows deploy into one Pages site, and a deploy replaces the whole # site, so they share a concurrency group and never run at the same time. diff --git a/CLAUDE.md b/CLAUDE.md index 858e8b5..8c4d71d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,9 @@ +> **Moved to Gitea.** CI now runs on Gitea Actions at +> `git.wallacenet.ca/devadmin/ProLibertateGames`, publishing to Cloudflare Pages +> (`plg.wallacenet.ca`) only. The sandbox notes below describe the earlier GitHub +> setup (no CI run from session pushes, `workflow_dispatch` as the workaround); +> re-verify them against Gitea before relying on them. + # Working on this repo with Claude ## Nothing a session does triggers CI. Dispatch it explicitly. diff --git a/README.md b/README.md index cd3414f..1f3a454 100644 --- a/README.md +++ b/README.md @@ -218,13 +218,19 @@ Pages → Edit) and `CLOUDFLARE_ACCOUNT_ID`. Pages caps files at 25 MiB. **First deploy:** the site does not exist yet, so the domain answers 403 (Cloudflare error 1014) and the carry-over fetch would abort. Dispatch the workflow once with `first_deploy` ticked, then attach `plg.wallacenet.ca` as a custom domain on the new `plg` Pages project. Later runs need no input. -The in-app updater reads these `latest.json` files. GitHub Releases are still -published for one transition deployment so installs that predate this change can -fetch it; after that, remove the GitHub release steps from both workflows. +The in-app updater reads these `latest.json` files, and this is the only release +channel: the workflows no longer create GitHub or Gitea releases. They run on +Gitea Actions (`https://git.wallacenet.ca/devadmin/ProLibertateGames`); the GitHub +repository is frozen and should stay public with its last releases, because +installs that predate the Cloudflare updater still read them to reach it. + +Build numbers are `run number + plgVersionRunOffset` (1000, in `gradle.properties`), +so moving CI hosts, which restarts run numbers, can never publish a `versionCode` +lower than one already installed. ### Installing without the app -The newest production build is always at **https://plg.wallacenet.ca/prod/ProLibertateGames.apk** (dev: `/dev/ProLibertateGames.apk`). The GitHub link below remains during the transition: +The newest production build is always at **https://plg.wallacenet.ca/prod/ProLibertateGames.apk** (dev: `/dev/ProLibertateGames.apk`). The GitHub link below is the frozen pre-migration release: **https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk** diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4a0628f..1e36084 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,6 +16,12 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty() val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val stampVersion = buildChannel.isNotEmpty() && runNumber > 0 +// See plgVersionRunOffset in gradle.properties: the stamped build number is the +// CI run number plus a permanent floor, so a counter that restarts on a new CI +// host can never produce a lower versionCode than one already installed. +val runOffset = (project.findProperty("plgVersionRunOffset") as String?)?.toIntOrNull() ?: 0 +val buildNumber = runNumber + runOffset + // The version series, from gradle.properties. Both workflows read the same // property when they name a release tag, so the tag and the APK inside it // cannot disagree about which series they belong to. @@ -51,10 +57,10 @@ android { // versionCode stays the run number and nothing else, so it keeps // climbing across a change of series: bumping 1.0 to 1.1 must not make // an update look like a downgrade to Android. - versionCode = if (stampVersion) runNumber else 1 + versionCode = if (stampVersion) buildNumber else 1 versionName = when { - stampVersion && buildChannel == "production" -> "$versionSeries.$runNumber" - stampVersion -> "$versionSeries.$runNumber-dev" + stampVersion && buildChannel == "production" -> "$versionSeries.$buildNumber" + stampVersion -> "$versionSeries.$buildNumber-dev" isDevBuild -> "$versionSeries.0-dev" else -> "$versionSeries.0" } diff --git a/gradle.properties b/gradle.properties index 8b173d2..c301034 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,3 +23,11 @@ android.nonTransitiveRClass=true # it keeps climbing on its own and an APK's versionCode never goes backwards # when this changes. plgVersionSeries=1.4 + +# The permanent floor under every stamped build number: versionCode, versionName +# and the release tag are all `run number + this`. Run numbers restart at 1 on a +# new CI host (Gitea Actions), and Android refuses any versionCode lower than the +# one installed, so without a floor the first build after a move would look like +# a downgrade. 1000 sits above every number GitHub reached (prod 30, dev ~172). +# Set once and never changed. +plgVersionRunOffset=1000 -- 2.47.3 From 2a193f8d5b6ca7cfcbe2deddaf8c8f266b4341f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:01:05 +0000 Subject: [PATCH 07/19] Temporary: runner egress probe (to be removed) --- .github/workflows/net-check.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/workflows/net-check.yml diff --git a/.github/workflows/net-check.yml b/.github/workflows/net-check.yml new file mode 100644 index 0000000..ceef59a --- /dev/null +++ b/.github/workflows/net-check.yml @@ -0,0 +1,12 @@ +name: Net check +on: workflow_dispatch +jobs: + net: + runs-on: ubuntu-latest + steps: + - name: Probe outbound HTTPS from the runner + run: | + cat /etc/os-release | head -2; command -v java node npm curl git || true + for u in https://api.github.com https://github.com https://api.adoptium.net https://dl.google.com/android/repository/repository2-3.xml https://registry.npmjs.org/wrangler https://api.cloudflare.com/client/v4/ https://plg.wallacenet.ca/dev/latest.json https://services.gradle.org https://repo.maven.apache.org/maven2/ https://plugins.gradle.org; do + printf '%s -> ' "$u"; curl -sS -o /dev/null -m 15 -w '%{http_code}\n' "$u" 2>&1 | tail -1 + done -- 2.47.3 From 2314f5f842c364a8f6e252c57535fe793bbac346 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:01:57 +0000 Subject: [PATCH 08/19] Temporary: probe JDK hosts --- .github/workflows/net-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/net-check.yml b/.github/workflows/net-check.yml index ceef59a..ebd2cac 100644 --- a/.github/workflows/net-check.yml +++ b/.github/workflows/net-check.yml @@ -7,6 +7,6 @@ jobs: - name: Probe outbound HTTPS from the runner run: | cat /etc/os-release | head -2; command -v java node npm curl git || true - for u in https://api.github.com https://github.com https://api.adoptium.net https://dl.google.com/android/repository/repository2-3.xml https://registry.npmjs.org/wrangler https://api.cloudflare.com/client/v4/ https://plg.wallacenet.ca/dev/latest.json https://services.gradle.org https://repo.maven.apache.org/maven2/ https://plugins.gradle.org; do + for u in https://api.azul.com/metadata/v1/zulu/packages/?java_version=17\&os=linux\&arch=x64\&archive_type=tar.gz\&java_package_type=jdk\&latest=true https://cdn.azul.com https://corretto.aws/downloads/latest/amazon-corretto-17-x64-linux-jdk.tar.gz https://aka.ms/download-jdk/microsoft-jdk-17-linux-x64.tar.gz https://api.adoptium.net/v3/info/available_releases https://github.com/adoptium/temurin17-binaries/releases https://objects.githubusercontent.com https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz https://api.foojay.io/disco/v3.0/packages; do printf '%s -> ' "$u"; curl -sS -o /dev/null -m 15 -w '%{http_code}\n' "$u" 2>&1 | tail -1 done -- 2.47.3 From e63c2cf7ac0cc92d98163455705de1692d06dc2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:02:54 +0000 Subject: [PATCH 09/19] Use Zulu JDK 17: the Gitea runner cannot reach api.adoptium.net setup-java's temurin distribution resolves versions through api.adoptium.net, which fails TLS from the runner (EPROTO) while api.azul.com is reachable. Any JDK 17 builds the project. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/workflows/ci.yml | 2 +- .github/workflows/net-check.yml | 12 ------------ .github/workflows/release.yml | 2 +- 3 files changed, 2 insertions(+), 14 deletions(-) delete mode 100644 .github/workflows/net-check.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0debc3b..b2ab603 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v4 with: - distribution: temurin + distribution: zulu java-version: '17' cache: gradle diff --git a/.github/workflows/net-check.yml b/.github/workflows/net-check.yml deleted file mode 100644 index ebd2cac..0000000 --- a/.github/workflows/net-check.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Net check -on: workflow_dispatch -jobs: - net: - runs-on: ubuntu-latest - steps: - - name: Probe outbound HTTPS from the runner - run: | - cat /etc/os-release | head -2; command -v java node npm curl git || true - for u in https://api.azul.com/metadata/v1/zulu/packages/?java_version=17\&os=linux\&arch=x64\&archive_type=tar.gz\&java_package_type=jdk\&latest=true https://cdn.azul.com https://corretto.aws/downloads/latest/amazon-corretto-17-x64-linux-jdk.tar.gz https://aka.ms/download-jdk/microsoft-jdk-17-linux-x64.tar.gz https://api.adoptium.net/v3/info/available_releases https://github.com/adoptium/temurin17-binaries/releases https://objects.githubusercontent.com https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz https://api.foojay.io/disco/v3.0/packages; do - printf '%s -> ' "$u"; curl -sS -o /dev/null -m 15 -w '%{http_code}\n' "$u" 2>&1 | tail -1 - done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e9bc93..7dd7c43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v4 with: - distribution: temurin + distribution: zulu java-version: '17' cache: gradle -- 2.47.3 From 7b5b041c4295eeb7c2775287bd464261efdfd29e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:23:42 +0000 Subject: [PATCH 10/19] Test: wait for the guest's sheet before comparing it The sheet arrives over the socket just after connected flips, so on a slower runner the comparison raced it (ScorekeeperSessionTest, line 167). Same pattern the sibling test already uses. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .../java/org/prolibertate/games/net/ScorekeeperSessionTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt b/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt index 0e8c65e..7f10647 100644 --- a/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt +++ b/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt @@ -164,6 +164,9 @@ class ScorekeeperSessionTest { await("the guest to be let in") { guest.state.value.connected } assertFalse(guest.state.value.awaiting) + // The sheet follows the connection over the socket, so on a slow runner + // it can still be in flight when `connected` flips: wait for it. + await("the guest to receive the sheet") { guest.state.value.sheet == host.state.sheet } assertEquals(host.state.sheet, guest.state.value.sheet) assertTrue(guest.state.value.sheet.started) assertTrue(host.state.requests.isEmpty()) -- 2.47.3 From fd352b4d14ab23d4b0c638b2fa72d062a33f8af7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:43:13 +0000 Subject: [PATCH 11/19] Back to Temurin JDK 17 now that api.adoptium.net is reachable from the runner Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2ab603..0debc3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v4 with: - distribution: zulu + distribution: temurin java-version: '17' cache: gradle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dd7c43..8e9bc93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v4 with: - distribution: zulu + distribution: temurin java-version: '17' cache: gradle -- 2.47.3 From 4ffe817d5f2dbd08b3b4ec658784ebeecbd69535 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:13:21 +0000 Subject: [PATCH 12/19] Stage script: use node instead of jq The Gitea runner's container image has node but no jq, so the first dev deploy died with exit 127 in stage-pages-site.sh. Same behaviour, tested against mock servers for: other channel published, 200-with-HTML, 404, and unreachable. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .github/scripts/stage-pages-site.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/scripts/stage-pages-site.sh b/.github/scripts/stage-pages-site.sh index 4521fe9..e91432e 100755 --- a/.github/scripts/stage-pages-site.sh +++ b/.github/scripts/stage-pages-site.sh @@ -22,8 +22,9 @@ cp site/index.html site/404.html site/_headers "$out/" name="$(basename "$apk")" cp "$apk" "$out/$channel/$name" cp "$apk" "$out/$channel/ProLibertateGames.apk" -jq -n --arg tag "$tag" --arg name "$name" --arg ch "$channel" --arg url "$base/$channel/$name" \ - '{tag: $tag, apkName: $name, apkUrl: $url, channel: $ch}' > "$out/$channel/latest.json" +# node rather than jq: the Gitea runner's image has node but not jq. +node -e 'console.log(JSON.stringify({tag: process.argv[1], apkName: process.argv[2], apkUrl: process.argv[3], channel: process.argv[4]}, null, 2))' \ + "$tag" "$name" "$base/$channel/$name" "$channel" > "$out/$channel/latest.json" # PLG_SKIP_OTHER=1 is for the very first deploy, when the site does not exist # yet and the domain answers 403 (Cloudflare error 1014) instead of 404. @@ -47,13 +48,12 @@ fi if [ "$code" = 404 ]; then echo "No $other channel published yet; skipping." rm -rf "$out/$other" -elif [ "$code" = 200 ] && ! jq -e .apkName "$out/$other/latest.json" >/dev/null 2>&1; then +elif [ "$code" = 200 ] && ! other_name="$(node -p 'const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if (!j.apkName) process.exit(1); j.apkName' "$out/$other/latest.json" 2>/dev/null)"; then # A 200 that is not our JSON: Pages without a 404.html answers unknown paths # with index.html. Nothing is published on that channel. echo "No $other channel published yet (200 but no latest.json); skipping." rm -rf "$out/$other" elif [ "$code" = 200 ]; then - other_name="$(jq -er .apkName "$out/$other/latest.json")" curl -fsS -o "$out/$other/$other_name" "$base/$other/$other_name" cp "$out/$other/$other_name" "$out/$other/ProLibertateGames.apk" else -- 2.47.3 From 33a253d80ce3100d85ed45bbdda0f9c39533d3ae Mon Sep 17 00:00:00 2001 From: root Date: Wed, 14 Oct 2026 14:34:10 -0600 Subject: [PATCH 13/19] CI: keep JVM crash logs and try C1-only JIT on the runner Runs on the Gitea runner crash intermittently on three JDK vendors. Upload hs_err/replay logs on failure so the failing frame survives the job container, and drop C2 (TieredStopAtLevel=1) for the Gradle and Kotlin daemons to test whether the optimising JIT is the trigger. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YGRgChhwbwWr5yYrHTd9pL --- .github/workflows/ci.yml | 13 +++++++++++++ gradle.properties | 9 ++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0debc3b..9850557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,19 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Upload JVM crash logs + # A JVM crash writes hs_err_pid*.log into the workspace, which dies + # with the job container; keep it so the failing frame can be read. + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ProLibertateGames-jvm-crash + path: | + hs_err_pid*.log + replay_pid*.log + if-no-files-found: ignore + retention-days: 14 + - name: Upload lint and test reports # Reports are most useful precisely when a step above failed. if: always() diff --git a/gradle.properties b/gradle.properties index c301034..018ef72 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,12 @@ # Project-wide Gradle settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# C1-only JIT (-XX:TieredStopAtLevel=1): the Gitea runner's JVMs have been +# crashing intermittently (SIGSEGV in HotSpot's compiler, bad Kotlin compiler +# codegen) on Zulu, Corretto and Temurin alike. Dropping C2 is the test for +# whether the optimising JIT is the trigger; it costs some build speed. The +# Kotlin daemon needs its own copy: setting its options stops it inheriting +# org.gradle.jvmargs. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:TieredStopAtLevel=1 +kotlin.daemon.jvm.options=-Xmx2048m,-XX:TieredStopAtLevel=1 org.gradle.parallel=true org.gradle.caching=true -- 2.47.3 From fb59372802cc88ee1076e6b4e7f45af9cebc4d24 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:55:38 +0000 Subject: [PATCH 14/19] Restore the Katan catalogue entry the engine depends on The Option 2 migration dropped Gitea's 5c797c8, which added GameCatalog.CATAN and a Katan entry (available = false: Coming soon on dev, hidden from production). CatanRules.kt references GameCatalog.CATAN, so the merge did not compile without it. Taken verbatim from feature/catan-engine, with the matching CatalogTest assertions. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .../org/prolibertate/games/game/Catalog.kt | 20 +++++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 20 ++++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 112e84f..ca9ae3f 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -94,6 +94,13 @@ object GameCatalog { const val MORRIS = "morris" const val PIRATES = "pirates" + /** + * The island game the collection is named for. Listed under Board Games + * and marked *Coming soon*; the engine and its screen land with the game + * task, and `available` flips on once two-device play is verified. + */ + const val CATAN = "catan" + val all: List = listOf( GameDescriptor( id = EUCHRE, @@ -333,6 +340,19 @@ object GameCatalog { "because your score multiplies them.", available = true, ), + GameDescriptor( + id = CATAN, + title = "Katan", + menus = setOf(GameMenu.BOARD), + minPlayers = 3, + maxPlayers = 4, + teamBased = false, + blurb = "Trade, build, and settle the island.", + // Listed, not playable: the engine and screen land with the Katan + // game task. Dev builds show the entry as *Coming soon*; a + // production build lists only what can actually be played. + available = false, + ), ) fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index f4b0663..465e065 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -106,17 +106,19 @@ class CatalogTest { } @Test - fun `every game in the catalogue can be played`() { - // There is nothing left marked "coming soon". The machinery that hides - // unfinished games from a production build is still there and still - // tested above — it simply has nothing to hide at the moment, and this - // is what will fail the day something is added to the list before its - // engine is written. - assertTrue(GameCatalog.all.all { it.available }) + fun `every reference game in the catalogue can be played`() { + // Every game the app ships is playable. The one exception is Katan, + // listed so the slot exists and marked *Coming soon* until its engine + // and screen land — `available = false` is what keeps an unfinished + // game out of a production build while leaving it visible on a dev one. assertEquals( - GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false), - GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true), + listOf(GameCatalog.CATAN), + GameCatalog.all.filterNot { it.available }.map { it.id }, ) + // It is also the only difference the two channels see in a menu. + val hiddenFromProduction = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) - + GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false) + assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction) } @Test -- 2.47.3 From 30dcf026bd208834007fe3df1636114b73f32b99 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 22:24:45 +0000 Subject: [PATCH 15/19] Katan: playable screen, setup options, dev-preview catalogue gate Adds the Katan screen on top of the WALA-119 engine, built in separable layers so the look, wording, layout and interaction can each be changed alone: - CatanStyle: every colour and proportion (light/dark), via LocalCatanStyle - CatanText: every word on screen (pure functions of state) - CatanGeometry: lattice -> pixels and tap hit-testing (pure, unit-tested) - CatanInteraction: what is tappable and what a tap means, derived only from the engine's legal-move list (pure, unit-tested) - CatanBoardView / CatanPanels / CatanDialogs: stateless composables - CatanScreen: wiring to MatchController, portrait and landscape layouts - CatanPreviews: Android Studio previews of real positions - docs/UI-catan.md: how to adapt each layer, rules of the road, known gaps Wiring: PlayScreen branch, GameSetupScreen options (3-4 players, game to 5/8/10/12), and a GameDescriptor.devPreview flag so dev builds can open Katan while production still hides it until two-device play is verified. Tests: CatanGeometryTest, CatanInteractionTest, CatanTextTest, CatalogTest. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .../org/prolibertate/games/game/Catalog.kt | 21 +- .../java/org/prolibertate/games/ui/AppRoot.kt | 27 +- .../prolibertate/games/ui/GameSetupScreen.kt | 29 + .../org/prolibertate/games/ui/PlayScreen.kt | 29 + .../games/ui/game/catan/CatanBoardView.kt | 400 +++++++++++++ .../games/ui/game/catan/CatanDialogs.kt | 531 ++++++++++++++++++ .../games/ui/game/catan/CatanGeometry.kt | 123 ++++ .../games/ui/game/catan/CatanInteraction.kt | 273 +++++++++ .../games/ui/game/catan/CatanPanels.kt | 337 +++++++++++ .../games/ui/game/catan/CatanPreviews.kt | 133 +++++ .../games/ui/game/catan/CatanScreen.kt | 382 +++++++++++++ .../games/ui/game/catan/CatanStyle.kt | 130 +++++ .../games/ui/game/catan/CatanText.kt | 213 +++++++ .../prolibertate/games/game/CatalogTest.kt | 14 + .../games/ui/game/catan/CatanFixtures.kt | 53 ++ .../games/ui/game/catan/CatanGeometryTest.kt | 117 ++++ .../ui/game/catan/CatanInteractionTest.kt | 325 +++++++++++ .../games/ui/game/catan/CatanTextTest.kt | 101 ++++ docs/README.md | 2 + docs/RULES-catan.md | 10 +- docs/UI-catan.md | 134 +++++ 21 files changed, 3366 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt create mode 100644 app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt create mode 100644 app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/ui/game/catan/CatanTextTest.kt create mode 100644 docs/UI-catan.md diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index ca9ae3f..abb732a 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -42,7 +42,20 @@ data class GameDescriptor( val teamBased: Boolean, val blurb: String, val available: Boolean, + /** + * Playable on a dev build while still hidden from production. + * + * [available] is the release gate: it is what a production build lists and + * lets people open. A game that has an engine and a screen but has not yet + * been verified on real devices sets this instead, so it can be played and + * tested from the dev channel without being offered to everyone. Flip + * [available] on (and this off) once it has been verified. + */ + val devPreview: Boolean = false, ) { + /** Whether the menu should let this game be opened in the given kind of build. */ + fun playableIn(devBuild: Boolean): Boolean = available || (devBuild && devPreview) + /** * The one menu this game is listed under. * @@ -348,10 +361,12 @@ object GameCatalog { maxPlayers = 4, teamBased = false, blurb = "Trade, build, and settle the island.", - // Listed, not playable: the engine and screen land with the Katan - // game task. Dev builds show the entry as *Coming soon*; a - // production build lists only what can actually be played. + // Engine and screen exist but have not been verified on two real + // devices yet. Dev builds can open it as a *Preview*; a production + // build does not list it. Flip `available` on, and `devPreview` + // off, once two-device play has been verified. available = false, + devPreview = true, ), ) diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index fd5c69b..b01ffd3 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -364,7 +364,11 @@ fun SectionScreen( } } items(games) { game -> - GameTile(game = game, onClick = { onPickGame(game) }) + GameTile( + game = game, + playable = game.playableIn(devBuild = showComingSoon), + onClick = { onPickGame(game) }, + ) } } } @@ -403,12 +407,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { +private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) { Card( onClick = onClick, - enabled = game.available, + enabled = playable, colors = CardDefaults.cardColors( - containerColor = if (game.available) { + containerColor = if (playable) { MaterialTheme.colorScheme.surfaceVariant } else { MaterialTheme.colorScheme.surface @@ -427,13 +431,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp), ) + val players = when { + // A puzzle for one, rather than "1 players". + game.maxPlayers == 1 -> "On your own" + game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" + else -> "${game.minPlayers}–${game.maxPlayers} players" + } Text( text = when { - !game.available -> "Coming soon" - // A puzzle for one, rather than "1 players". - game.maxPlayers == 1 -> "On your own" - game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" - else -> "${game.minPlayers}–${game.maxPlayers} players" + !playable -> "Coming soon" + // Playable on this dev build only; production hides it. + !game.available -> "Preview · $players" + else -> players }, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp), diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 80632ea..eca5a11 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -57,6 +57,7 @@ import org.prolibertate.games.game.morris.MorrisOptions import org.prolibertate.games.game.pirates.PiratesLevel import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.president.PresidentOptions +import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuOptions @@ -104,6 +105,7 @@ fun GameSetupScreen( var freecell by remember { mutableStateOf(FreeCellOptions()) } var spider by remember { mutableStateOf(SpiderOptions()) } var pyramid by remember { mutableStateOf(PyramidOptions()) } + var catan by remember { mutableStateOf(CatanOptions()) } // Not an engine option: which seat the local player takes. Seat 0 is // always White, so choosing Black means sitting in seat 1. var playWhite by remember { mutableStateOf(true) } @@ -134,6 +136,7 @@ fun GameSetupScreen( GameCatalog.FREECELL -> setupJson.encodeToString(freecell) GameCatalog.SPIDER -> setupJson.encodeToString(spider) GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) + GameCatalog.CATAN -> setupJson.encodeToString(catan) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -199,6 +202,8 @@ fun GameSetupScreen( GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } + GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it } + else -> Text("No options yet for this game.") } @@ -303,6 +308,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int { GameCatalog.TAYU -> setupJson.decodeFromString(optionsJson).playerCount + GameCatalog.CATAN -> + setupJson.decodeFromString(optionsJson).playerCount + else -> fallback } }.getOrDefault(fallback) @@ -326,6 +334,27 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId) else -> seat } +@Composable +private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Players", + values = listOf(3, 4), + selected = options.playerCount, + display = { "$it players" }, + onSelect = { onChange(options.copy(playerCount = it)) }, + ) + ChipRow( + label = "Game to", + // Five is a short game for trying things out; ten is the published target. + values = listOf(5, 8, 10, 12), + selected = options.pointsToWin, + display = { "$it points" }, + onSelect = { onChange(options.copy(pointsToWin = it)) }, + ) + } +} + @Composable private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 4235d36..4dda622 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -13,6 +13,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.catan.CatanAi +import org.prolibertate.games.game.catan.CatanMove +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.chess.ChessAi import org.prolibertate.games.game.chess.ChessMove import org.prolibertate.games.game.chess.ChessRules @@ -110,6 +114,7 @@ import org.prolibertate.games.game.wizard.WizardState import org.prolibertate.games.net.MatchController import org.prolibertate.games.settings.Settings import org.prolibertate.games.ui.game.BackgammonScreen +import org.prolibertate.games.ui.game.catan.CatanScreen import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.CrazyEightsScreen @@ -668,6 +673,30 @@ fun PlayScreen( ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.CATAN -> { + val controller = remember(route) { + MatchController( + rules = CatanRules, + ai = CatanAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // A computer's turn is several small moves (roll, build, + // trade, end), so the pause is per move, not per turn. + aiThinkingMillis = { settings.scaled(450L) }, + ) + } + StartMatch(env, controller, route) + CatanScreen( + controller = controller, + localSeat = route.localSeat, + seatNames = route.config.seats.sortedBy { it.seat }.map { it.name }, + onExit = onExit, + ) + } + else -> Text("That game isn't playable yet.") } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt new file mode 100644 index 0000000..c4cfbfa --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -0,0 +1,400 @@ +package org.prolibertate.games.ui.game.catan + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntSize +import org.prolibertate.games.game.catan.BuildingType +import org.prolibertate.games.game.catan.CatanBoard +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.NO_PLAYER + +/** + * The island, drawn and tappable. + * + * A stateless view: it is handed the state, the parts of the board that are + * currently tappable ([targets]) and the one setup settlement that is waiting + * for its road ([selectedVertex]), and it reports taps. It knows no rules; what + * a tap *means* is decided by [CatanInteraction]. Where things are is decided by + * [BoardLayout], and what they look like by [CatanStyle], so this file is only + * the glue between the three. + * + * To change how a piece is drawn, edit the matching `draw…` function below. To + * change a colour or a proportion, edit [CatanStyle] instead. + */ +@Composable +fun CatanBoardView( + state: CatanState, + targets: BoardTargets, + selectedVertex: Int?, + onVertexTap: (Int) -> Unit, + onEdgeTap: (Int) -> Unit, + onHexTap: (Int) -> Unit, + modifier: Modifier = Modifier, + style: CatanStyle = LocalCatanStyle.current, +) { + val measurer = rememberTextMeasurer() + var size by remember { mutableStateOf(IntSize.Zero) } + val layout = remember(size) { + if (size.width > 0 && size.height > 0) { + CatanGeometry.fit(size.width.toFloat(), size.height.toFloat()) + } else { + null + } + } + + // Held in State so the gesture detector, which is only restarted when the + // layout or targets change, always calls the latest callbacks. + val vertexTap by rememberUpdatedState(onVertexTap) + val edgeTap by rememberUpdatedState(onEdgeTap) + val hexTap by rememberUpdatedState(onHexTap) + + Canvas( + modifier = modifier + .aspectRatio(CatanGeometry.ASPECT) + .onSizeChanged { size = it } + .semantics { contentDescription = "Katan island board" } + .pointerInput(layout, targets, style) { + detectTapGestures { tap -> + val board = layout ?: return@detectTapGestures + val at = Pt(tap.x, tap.y) + val reach = board.radius * style.tapReach + when { + targets.vertices.isNotEmpty() -> + board.nearestVertex(at, reach, targets.vertices)?.let { vertexTap(it) } + + targets.edges.isNotEmpty() -> + board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) } + + targets.hexes.isNotEmpty() -> + // A hex is big: accept a tap anywhere near its middle. + board.nearestHex(at, board.radius * 0.95f, targets.hexes)?.let { hexTap(it) } + } + } + }, + ) { + val board = layout ?: return@Canvas + drawRect(color = style.sea) + drawPorts(state, board, style, measurer) + drawHexes(state, board, style, measurer) + drawRobber(state, board, style) + drawRoads(state, board, style) + drawEdgeTargets(targets, board, style) + drawBuildings(state, board, style) + drawVertexTargets(targets, selectedVertex, board, style) + drawHexTargets(targets, board, style) + } +} + +// --------------------------------------------------------------------------- +// Terrain and tokens +// --------------------------------------------------------------------------- + +private fun DrawScope.drawHexes( + state: CatanState, + board: BoardLayout, + style: CatanStyle, + measurer: TextMeasurer, +) { + for (hex in 0 until CatanBoard.HEX_COUNT) { + val centre = board.hexCenter(hex) + val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) } + val path = polygon(corners) + drawPath(path, color = style.terrain.getValue(state.terrains[hex])) + drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f)) + + val number = state.numbers[hex] + if (number != 0) drawToken(number, centre, board, style, measurer) + } +} + +private fun DrawScope.drawToken( + number: Int, + at: Pt, + board: BoardLayout, + style: CatanStyle, + measurer: TextMeasurer, +) { + val radius = board.radius * style.tokenRadius + drawCircle(color = style.token, radius = radius, center = at.offset()) + drawCircle( + color = style.hexEdge, + radius = radius, + center = at.offset(), + style = Stroke(width = board.radius * 0.02f), + ) + val hot = number == 6 || number == 8 + drawCentered( + measurer = measurer, + text = number.toString(), + at = Pt(at.x, at.y - radius * 0.12f), + fontPx = board.radius * style.tokenText, + color = if (hot) style.tokenHotInk else style.tokenInk, + weight = if (hot) FontWeight.ExtraBold else FontWeight.Bold, + ) + // One pip per thirty-sixth of the odds, so the likelier numbers read at a glance. + val pips = CatanBoard.numberOdds(number) + val pipRadius = radius * 0.075f + val gap = pipRadius * 2.6f + val startX = at.x - gap * (pips - 1) / 2f + for (i in 0 until pips) { + drawCircle( + color = if (hot) style.tokenHotInk else style.tokenInk, + radius = pipRadius, + center = Offset(startX + i * gap, at.y + radius * 0.52f), + ) + } +} + +private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) { + val hex = board.hexCenter(state.robber) + // Beside the number token rather than on it, so the number stays readable. + val x = hex.x + board.radius * 0.5f + val y = hex.y + board.radius * 0.3f + val s = board.radius * style.robberSize + val pawn = Path().apply { + moveTo(x - s * 0.55f, y + s * 0.9f) + lineTo(x - s * 0.25f, y - s * 0.15f) + lineTo(x + s * 0.25f, y - s * 0.15f) + lineTo(x + s * 0.55f, y + s * 0.9f) + close() + } + drawPath(pawn, color = style.robber) + drawCircle(color = style.robber, radius = s * 0.38f, center = Offset(x, y - s * 0.42f)) + drawPath(pawn, color = Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f)) + drawCircle( + color = Color.White.copy(alpha = 0.7f), + radius = s * 0.38f, + center = Offset(x, y - s * 0.42f), + style = Stroke(width = s * 0.07f), + ) +} + +// --------------------------------------------------------------------------- +// Harbours +// --------------------------------------------------------------------------- + +private fun DrawScope.drawPorts( + state: CatanState, + board: BoardLayout, + style: CatanStyle, + measurer: TextMeasurer, +) { + for (port in state.ports) { + val anchor = board.portAnchor(port.edge) + val (a, b) = board.edgeEnds(port.edge) + val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk + // Two short piers from the marker to the vertices that can use the harbour. + for (end in listOf(a, b)) { + drawLine( + color = style.portMarker.copy(alpha = 0.9f), + start = anchor.offset(), + end = end.offset(), + strokeWidth = board.radius * 0.07f, + cap = StrokeCap.Round, + ) + } + val radius = board.radius * style.portRadius + drawCircle(color = style.portMarker, radius = radius, center = anchor.offset()) + drawCircle( + color = tint, + radius = radius, + center = anchor.offset(), + style = Stroke(width = board.radius * 0.07f), + ) + drawCentered( + measurer = measurer, + text = "${port.ratio}:1", + at = anchor, + fontPx = board.radius * style.portText, + color = style.portInk, + weight = FontWeight.Bold, + ) + } +} + +// --------------------------------------------------------------------------- +// Pieces +// --------------------------------------------------------------------------- + +private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) { + for (edge in 0 until CatanBoard.EDGE_COUNT) { + val owner = state.roads[edge] + if (owner == NO_PLAYER) continue + val (a, b) = board.edgeEnds(edge) + // Pulled in from the ends so adjacent roads do not fuse into a blob. + val from = lerp(a, b, 0.12f) + val to = lerp(a, b, 0.88f) + val width = board.radius * style.roadWidth + drawLine( + color = style.pieceOutline, + start = from.offset(), + end = to.offset(), + strokeWidth = width * 1.45f, + cap = StrokeCap.Round, + ) + drawLine( + color = style.seats[owner % style.seats.size], + start = from.offset(), + end = to.offset(), + strokeWidth = width, + cap = StrokeCap.Round, + ) + } +} + +private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) { + for (vertex in 0 until CatanBoard.VERTEX_COUNT) { + val building = state.buildings[vertex] ?: continue + val at = board.vertex(vertex) + val fill = style.seats[building.owner % style.seats.size] + val path = when (building.type) { + BuildingType.SETTLEMENT -> housePath(at, board.radius * style.settlementSize) + BuildingType.CITY -> cityPath(at, board.radius * style.citySize * 0.8f) + } + drawPath(path, color = fill) + drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.045f)) + } +} + +/** One gable: a settlement. [s] is its half-width. */ +private fun housePath(at: Pt, s: Float): Path = Path().apply { + moveTo(at.x - s, at.y + s * 0.7f) + lineTo(at.x - s, at.y - s * 0.1f) + lineTo(at.x, at.y - s) + lineTo(at.x + s, at.y - s * 0.1f) + lineTo(at.x + s, at.y + s * 0.7f) + close() +} + +/** Two gables, the taller on the left: a city. [s] is the unit its outline is scaled by. */ +private fun cityPath(at: Pt, s: Float): Path = Path().apply { + moveTo(at.x - s, at.y + s * 0.7f) + lineTo(at.x - s, at.y - s * 0.5f) + lineTo(at.x - s * 0.45f, at.y - s * 1.1f) + lineTo(at.x + s * 0.1f, at.y - s * 0.5f) + lineTo(at.x + s * 0.1f, at.y - s * 0.1f) + lineTo(at.x + s * 0.55f, at.y - s * 0.55f) + lineTo(at.x + s * 1.1f, at.y - s * 0.1f) + lineTo(at.x + s * 1.1f, at.y + s * 0.7f) + close() +} + +// --------------------------------------------------------------------------- +// Tap targets +// --------------------------------------------------------------------------- + +private fun DrawScope.drawVertexTargets( + targets: BoardTargets, + selected: Int?, + board: BoardLayout, + style: CatanStyle, +) { + val radius = board.radius * style.targetRadius + for (vertex in targets.vertices) { + val at = board.vertex(vertex).offset() + drawCircle(color = style.target.copy(alpha = 0.85f), radius = radius, center = at) + drawCircle( + color = style.pieceOutline, + radius = radius, + center = at, + style = Stroke(width = board.radius * 0.04f), + ) + } + if (selected != null) { + val at = board.vertex(selected).offset() + drawCircle( + color = style.targetSelected, + radius = radius * 1.6f, + center = at, + style = Stroke(width = board.radius * 0.09f), + ) + } +} + +private fun DrawScope.drawEdgeTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) { + for (edge in targets.edges) { + val (a, b) = board.edgeEnds(edge) + val from = lerp(a, b, 0.15f) + val to = lerp(a, b, 0.85f) + drawLine( + color = style.pieceOutline, + start = from.offset(), + end = to.offset(), + strokeWidth = board.radius * style.roadWidth * 1.5f, + cap = StrokeCap.Round, + ) + drawLine( + color = style.target.copy(alpha = 0.9f), + start = from.offset(), + end = to.offset(), + strokeWidth = board.radius * style.roadWidth, + cap = StrokeCap.Round, + ) + } +} + +private fun DrawScope.drawHexTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) { + for (hex in targets.hexes) { + val centre = board.hexCenter(hex) + val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.08f) }) + drawPath(path, color = style.target.copy(alpha = 0.38f)) + drawPath(path, color = style.target, style = Stroke(width = board.radius * 0.07f)) + } +} + +// --------------------------------------------------------------------------- +// Drawing helpers +// --------------------------------------------------------------------------- + +private fun Pt.offset(): Offset = Offset(x, y) + +private fun lerp(a: Pt, b: Pt, t: Float): Pt = Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t) + +/** [p] moved [fraction] of the way toward [centre]. */ +private fun insetToward(p: Pt, centre: Pt, fraction: Float): Pt = lerp(p, centre, fraction) + +private fun polygon(points: List): Path = Path().apply { + points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) } + close() +} + +private fun DrawScope.drawCentered( + measurer: TextMeasurer, + text: String, + at: Pt, + fontPx: Float, + color: Color, + weight: FontWeight, +) { + val layout = measurer.measure( + text = text, + style = TextStyle(fontSize = fontPx.toSp(), fontWeight = weight, color = color), + ) + drawText( + textLayoutResult = layout, + topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f), + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt new file mode 100644 index 0000000..d475779 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt @@ -0,0 +1,531 @@ +package org.prolibertate.games.ui.game.catan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +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.getValue +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.catan.CatanMove +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.DevCardType +import org.prolibertate.games.game.catan.Discard +import org.prolibertate.games.game.catan.MaritimeTrade +import org.prolibertate.games.game.catan.OfferTrade +import org.prolibertate.games.game.catan.PlayKnight +import org.prolibertate.games.game.catan.PlayRoadBuilding +import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.TradeOffer + +/* + * Every Katan dialog. Each is a stateless composable apart from the selection it + * is helping the player make (cards picked, a partner chosen); the move it ends + * in is reported through a lambda and sent by the screen. What is *allowed* is + * always asked of [CatanInteraction], never decided here. + */ + +// --------------------------------------------------------------------------- +// Shared pieces +// --------------------------------------------------------------------------- + +/** A resource tile with a minus and a plus, for choosing how many cards. */ +@Composable +private fun ResourceCounter( + resource: Resource, + count: Int, + max: Int, + onChange: (Int) -> Unit, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + ResourceTile(resource, count, enabled = max > 0 || count > 0) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedButton( + onClick = { onChange(count - 1) }, + enabled = count > 0, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.width(34.dp).height(32.dp), + ) { Text("−") } + OutlinedButton( + onClick = { onChange(count + 1) }, + enabled = count < max, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.width(34.dp).height(32.dp), + ) { Text("+") } + } + } +} + +private fun Map.asCards(): List = + Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } } + +// --------------------------------------------------------------------------- +// Discarding on a seven +// --------------------------------------------------------------------------- + +/** + * A seven was rolled and this player holds more than seven cards: choose which + * half to give up. Not dismissable — the game is waiting on it. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanDiscardDialog( + held: Map, + quota: Int, + legal: List, + onSubmit: (Discard) -> Unit, +) { + var picked by remember { mutableStateOf(emptyMap()) } + val selection = picked.asCards() + val move = CatanInteraction.discardMove(legal, selection) + + AlertDialog( + onDismissRequest = {}, + title = { Text("Discard $quota cards") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "A seven was rolled. Choose $quota cards to give up " + + "(${selection.size} of $quota chosen).", + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Resource.entries.forEach { resource -> + val have = held[resource] ?: 0 + if (have > 0) { + ResourceCounter( + resource = resource, + count = picked[resource] ?: 0, + max = if (selection.size < quota) have else (picked[resource] ?: 0), + onChange = { n -> picked = picked + (resource to n.coerceIn(0, have)) }, + ) + } + } + } + } + }, + confirmButton = { + Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text("Discard") } + }, + ) +} + +// --------------------------------------------------------------------------- +// The robber +// --------------------------------------------------------------------------- + +/** More than one player can be robbed from this hex: choose whom. */ +@Composable +fun CatanVictimDialog( + victims: List, + seats: CatanSeats, + onPick: (Int) -> Unit, + onCancel: () -> Unit, +) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text("Steal from…") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + victims.forEach { seat -> + OutlinedButton(onClick = { onPick(seat) }, modifier = Modifier.fillMaxWidth()) { + Text(seats.label(seat)) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } }, + ) +} + +// --------------------------------------------------------------------------- +// Development cards +// --------------------------------------------------------------------------- + +/** + * The player's development cards. A Knight or Road Building plays at once; a + * Monopoly or Year of Plenty asks for its resource choice in a second step. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanPlayCardDialog( + hand: List, + onPlay: (CatanMove) -> Unit, + onCancel: () -> Unit, +) { + var chosen by remember { mutableStateOf(null) } + var picked by remember { mutableStateOf(emptyList()) } + val card = chosen + + AlertDialog( + onDismissRequest = onCancel, + title = { Text(card?.let { CatanText.devCard(it.card.type) } ?: "Development cards") }, + text = { + Column( + modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + when { + card == null -> { + if (hand.isEmpty()) Text("You have no development cards.") + hand.forEach { entry -> DevCardRow(entry, onPlay) { chosen = it } } + } + + card.card.type == DevCardType.MONOPOLY -> { + Text("Name a resource. Every other player hands over all of it.") + val choices = CatanInteraction.monopolyChoices(card.plays) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Resource.entries.forEach { resource -> + val move = choices[resource] + ResourceTile( + resource = resource, + count = 0, + enabled = move != null, + onClick = { move?.let(onPlay) }, + ) + } + } + } + + card.card.type == DevCardType.YEAR_OF_PLENTY -> { + Text("Take two resource cards from the bank (${picked.size} of 2 chosen).") + val available = CatanInteraction.plentyAvailable(card.plays, picked) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Resource.entries.forEach { resource -> + ResourceTile( + resource = resource, + count = picked.count { it == resource }, + selected = resource in picked, + enabled = resource in available, + onClick = { picked = picked + resource }, + ) + } + } + if (picked.isNotEmpty()) { + TextButton(onClick = { picked = emptyList() }) { Text("Clear") } + } + } + } + } + }, + confirmButton = { + if (card != null && card.card.type == DevCardType.YEAR_OF_PLENTY) { + val move = CatanInteraction.plentyMove(card.plays, picked) + Button(onClick = { move?.let(onPlay) }, enabled = move != null) { Text("Take") } + } + }, + dismissButton = { + TextButton( + onClick = { + if (card == null) { + onCancel() + } else { + chosen = null + picked = emptyList() + } + }, + ) { Text(if (card == null) CatanText.CANCEL else "Back") } + }, + ) +} + +@Composable +private fun DevCardRow( + entry: DevCardInHand, + onPlay: (CatanMove) -> Unit, + onChoose: (DevCardInHand) -> Unit, +) { + val type = entry.card.type + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(CatanText.devCard(type), fontWeight = FontWeight.Bold) + Text(CatanText.devCardHelp(type), style = MaterialTheme.typography.bodySmall) + } + when { + type == DevCardType.VICTORY_POINT -> Text("Hidden", style = MaterialTheme.typography.labelSmall) + !entry.playable -> Text("Not now", style = MaterialTheme.typography.labelSmall) + entry.plays.size == 1 && (entry.plays[0] is PlayKnight || entry.plays[0] is PlayRoadBuilding) -> + Button(onClick = { onPlay(entry.plays[0]) }) { Text("Play") } + + else -> Button(onClick = { onChoose(entry) }) { Text("Play…") } + } + } +} + +// --------------------------------------------------------------------------- +// Trading +// --------------------------------------------------------------------------- + +private enum class TradeTab { BANK, PLAYER } + +/** + * Trade with the bank at the best ratio the player has, or offer cards to one + * other player. Offers are free-form, so they are checked with the engine's + * structural rule ([CatanInteraction.offerAllowed]) rather than looked up in + * the legal list. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanTradeDialog( + state: CatanState, + seat: Int, + seats: CatanSeats, + legal: List, + onBank: (MaritimeTrade) -> Unit, + onOffer: (OfferTrade) -> Unit, + onCancel: () -> Unit, +) { + var tab by remember { mutableStateOf(TradeTab.BANK) } + var bankGive by remember { mutableStateOf(null) } + var partner by remember { mutableStateOf(null) } + var give by remember { mutableStateOf(emptyMap()) } + var get by remember { mutableStateOf(emptyMap()) } + + AlertDialog( + onDismissRequest = onCancel, + title = { Text(CatanText.TRADE) }, + text = { + Column( + modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TabButton(CatanText.TRADE_WITH_BANK, tab == TradeTab.BANK) { tab = TradeTab.BANK } + TabButton(CatanText.TRADE_WITH_PLAYER, tab == TradeTab.PLAYER) { tab = TradeTab.PLAYER } + } + + if (tab == TradeTab.BANK) { + val trades = CatanInteraction.bankTrades(legal) + if (trades.isEmpty()) { + Text("You do not hold enough of any one resource to trade with the bank.") + } else { + Text("Give:", fontWeight = FontWeight.Bold) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + trades.keys.forEach { resource -> + val ratio = CatanInteraction.bankRatio(state, seat, resource) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ResourceTile( + resource = resource, + count = ratio, + selected = bankGive == resource, + onClick = { bankGive = resource }, + ) + Text("$ratio : 1", style = MaterialTheme.typography.labelSmall) + } + } + } + val chosen = bankGive + if (chosen != null && chosen in trades) { + Text("Get one:", fontWeight = FontWeight.Bold) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + trades.getValue(chosen).forEach { resource -> + ResourceTile( + resource = resource, + count = 1, + onClick = { onBank(MaritimeTrade(chosen, resource)) }, + ) + } + } + } + } + } else { + Text("Offer to:", fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + (0 until state.playerCount).filter { it != seat }.forEach { other -> + TabButton(seats.label(other), partner == other) { partner = other } + } + } + Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold) + CounterRow( + counts = give, + limit = { state.resources[seat][it] ?: 0 }, + onChange = { give = it }, + ) + Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold) + CounterRow(counts = get, limit = { 9 }, onChange = { get = it }) + } + } + }, + confirmButton = { + if (tab == TradeTab.PLAYER) { + val to = partner + val giveCards = give.asCards() + val getCards = get.asCards() + val allowed = to != null && + CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards) + Button( + onClick = { if (to != null) onOffer(OfferTrade(to, giveCards, getCards)) }, + enabled = allowed, + ) { Text(CatanText.SEND_OFFER) } + } + }, + dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CounterRow( + counts: Map, + limit: (Resource) -> Int, + onChange: (Map) -> Unit, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Resource.entries.forEach { resource -> + ResourceCounter( + resource = resource, + count = counts[resource] ?: 0, + max = limit(resource), + onChange = { n -> onChange(counts + (resource to n.coerceIn(0, limit(resource)))) }, + ) + } + } +} + +@Composable +private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) { + if (selected) { + FilledTonalButton(onClick = onClick) { Text(label) } + } else { + OutlinedButton(onClick = onClick) { Text(label) } + } +} + +/** Another player has offered the local player a trade. */ +@Composable +fun CatanTradeResponseDialog( + offer: TradeOffer, + seats: CatanSeats, + canAccept: Boolean, + onAccept: () -> Unit, + onDecline: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text("Trade offer") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + CatanText.tradeSummary( + by = seats.label(offer.by), + to = "you", + give = offer.give, + get = offer.get, + ), + ) + if (!canAccept) { + Text( + "You cannot accept this: one side no longer holds the cards.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } }, + dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } }, + ) +} + +// --------------------------------------------------------------------------- +// Ending +// --------------------------------------------------------------------------- + +/** The game is over: who won, and what everyone finished with. */ +@Composable +fun CatanGameOverDialog( + state: CatanState, + seats: CatanSeats, + onExit: () -> Unit, + onLook: () -> Unit, +) { + val points = (0 until state.playerCount).map { seat -> + // The winner's hidden victory-point cards are revealed by winning; the + // host's copy of the state has them, a client's has only its own. + seat to if (seat == seats.me) CatanRules.victoryPoints(state, seat) + else CatanRules.publicVictoryPoints(state)[seat] + } + AlertDialog( + onDismissRequest = onLook, + title = { Text(CatanText.winnerHeadline(state, seats)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + points.sortedByDescending { it.second }.forEach { (seat, vp) -> + Text("${seats.label(seat)}: $vp points") + } + } + }, + confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } }, + dismissButton = { TextButton(onClick = onLook) { Text(CatanText.LOOK_AT_BOARD) } }, + ) +} + +@Composable +fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { + Text( + "This game ends here and the position is not saved. Anyone else " + + "at the table is told you have left.", + ) + }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +@Composable +fun CatanAbandonedDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text("$notice There are no more moves to play.") }, + confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } }, + dismissButton = { TextButton(onClick = onStay) { Text(CatanText.LOOK_AT_BOARD) } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt new file mode 100644 index 0000000..3c33f01 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt @@ -0,0 +1,123 @@ +package org.prolibertate.games.ui.game.catan + +import org.prolibertate.games.game.catan.CatanBoard +import kotlin.math.sqrt + +/** A point in board pixels. Plain floats, so this file needs no Compose types. */ +data class Pt(val x: Float, val y: Float) + +/** + * Where the island sits on screen: the one place the engine's integer lattice + * (see [CatanBoard]) becomes pixels, and pixels become "which vertex / edge / + * hex did the player tap". + * + * Pure and Android-free, so it is unit-tested (`CatanGeometryTest`). The drawing + * code ([CatanBoardView]) and the tap handling both go through this, which is + * what keeps what is drawn and what is tappable from ever disagreeing. + * + * Lattice to pixel: a hex of circumradius `R` is pointy-top, so one lattice x-unit + * is `R·√3/2` and one lattice y-unit is `R/2`, and lattice +Y points up the board + * while pixel +y points down. + */ +class BoardLayout( + val width: Float, + val height: Float, + /** Circumradius of one hex, in pixels. */ + val radius: Float, +) { + private val centreX = width / 2f + private val centreY = height / 2f + private val unitX = radius * SQRT3 / 2f + private val unitY = radius / 2f + + fun point(latticeX: Int, latticeY: Int): Pt = + Pt(centreX + latticeX * unitX, centreY - latticeY * unitY) + + fun hexCenter(hex: Int): Pt { + val (lx, ly) = CatanBoard.hexCenter(hex) + return point(lx, ly) + } + + fun vertex(vertex: Int): Pt { + val (lx, ly) = CatanBoard.VERTEX_POS[vertex] + return point(lx, ly) + } + + fun hexCorners(hex: Int): List = CatanBoard.HEX_CORNERS[hex].map { vertex(it) } + + fun edgeEnds(edge: Int): Pair { + val (a, b) = CatanBoard.EDGE_VERTICES[edge] + return vertex(a) to vertex(b) + } + + fun edgeMidpoint(edge: Int): Pt { + val (a, b) = edgeEnds(edge) + return Pt((a.x + b.x) / 2f, (a.y + b.y) / 2f) + } + + /** + * Where a harbour's marker is drawn: just off the coast, straight out from + * the middle of its edge. The marker is joined to the edge's two vertices by + * the board view, which is what shows which settlements can use it. + */ + fun portAnchor(edge: Int): Pt { + val mid = edgeMidpoint(edge) + val dx = mid.x - centreX + val dy = mid.y - centreY + 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) + } + + /** The nearest of [among] within [reach] pixels of [at], or null. */ + fun nearestVertex(at: Pt, reach: Float, among: Collection): Int? = + nearest(at, reach, among) { vertex(it) } + + fun nearestEdge(at: Pt, reach: Float, among: Collection): Int? = + nearest(at, reach, among) { edgeMidpoint(it) } + + fun nearestHex(at: Pt, reach: Float, among: Collection): Int? = + nearest(at, reach, among) { hexCenter(it) } + + private fun nearest(at: Pt, reach: Float, among: Collection, position: (Int) -> Pt): Int? { + var best: Int? = null + var bestDistance = reach * reach + for (candidate in among) { + val p = position(candidate) + val dx = p.x - at.x + val dy = p.y - at.y + val d = dx * dx + dy * dy + if (d <= bestDistance) { + best = candidate + bestDistance = d + } + } + return best + } +} + +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.15f + + private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN + private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN + + /** Width ÷ height of the board including its margin, for sizing its box. */ + val ASPECT: Float = boardWidthInRadii / boardHeightInRadii + + /** 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) + } +} + +private const val SQRT3 = 1.7320508f + +/** How far past the coast a harbour marker sits, in hex radii. */ +private const val PORT_OFFSET = 0.62f diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt new file mode 100644 index 0000000..c7481d0 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -0,0 +1,273 @@ +package org.prolibertate.games.ui.game.catan + +import org.prolibertate.games.game.catan.AcceptTrade +import org.prolibertate.games.game.catan.BuildCity +import org.prolibertate.games.game.catan.BuildRoad +import org.prolibertate.games.game.catan.BuildSettlement +import org.prolibertate.games.game.catan.BuyDevCard +import org.prolibertate.games.game.catan.CatanBoard +import org.prolibertate.games.game.catan.CatanMove +import org.prolibertate.games.game.catan.CatanPhase +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.DeclineTrade +import org.prolibertate.games.game.catan.DevCard +import org.prolibertate.games.game.catan.DevCardType +import org.prolibertate.games.game.catan.Discard +import org.prolibertate.games.game.catan.EndFreeRoads +import org.prolibertate.games.game.catan.EndTurn +import org.prolibertate.games.game.catan.HexCoord +import org.prolibertate.games.game.catan.MaritimeTrade +import org.prolibertate.games.game.catan.MoveRobber +import org.prolibertate.games.game.catan.OfferTrade +import org.prolibertate.games.game.catan.PlayKnight +import org.prolibertate.games.game.catan.PlayMonopoly +import org.prolibertate.games.game.catan.PlayRoadBuilding +import org.prolibertate.games.game.catan.PlayYearOfPlenty +import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.RollDice +import org.prolibertate.games.game.catan.SetupPlace + +/** What a player can ask to build by tapping the board. */ +enum class BuildKind { ROAD, SETTLEMENT, CITY } + +/** The parts of the board the player may tap right now. Everything else is inert. */ +data class BoardTargets( + val vertices: Set = emptySet(), + val edges: Set = emptySet(), + val hexes: Set = emptySet(), +) { + val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() +} + +/** What a tap on the board amounts to. */ +sealed interface TapOutcome { + /** Nothing: the tap landed somewhere that is not a legal target. */ + data object Ignored : TapOutcome + + /** Send this move to the table. */ + data class Submit(val move: CatanMove) : 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) : TapOutcome +} + +/** + * One development card in the local player's hand, and what can be done with it + * right now. [plays] is empty when it cannot be played this turn (bought this + * turn, a card has already been played, a victory-point card, or it is not the + * player's turn). + */ +data class DevCardInHand(val card: DevCard, val plays: List) { + val playable: Boolean get() = plays.isNotEmpty() +} + +/** + * The rules of *tapping*: given the game state, the moves the engine says are + * legal, and what the player has selected, what can be tapped and what a tap does. + * + * Pure functions with no Android and no Compose, so the whole interaction model is + * unit-tested (`CatanInteractionTest`). The screen holds the two bits of UI state + * ([BuildKind] and the chosen setup settlement) and passes them in; it contains no + * game rules of its own. **Anything that decides whether a tap is allowed belongs + * here, never in a composable** — the engine's [legal] list is the single source + * of truth, so the UI can never offer a move the rules would refuse. + */ +object CatanInteraction { + + // --- Board --------------------------------------------------------------- + + fun targets( + state: CatanState, + legal: List, + building: BuildKind?, + setupVertex: Int?, + ): BoardTargets { + val setup = legal.filterIsInstance() + if (setup.isNotEmpty()) { + return if (setupVertex == null) { + BoardTargets(vertices = setup.map { it.vertex }.toSet()) + } else { + BoardTargets(edges = setup.filter { it.vertex == setupVertex }.map { it.edge }.toSet()) + } + } + + val robber = legal.filterIsInstance() + if (robber.isNotEmpty()) { + return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet()) + } + + val roads = legal.filterIsInstance().map { it.edge }.toSet() + if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads) + + return when (building) { + BuildKind.ROAD -> BoardTargets(edges = roads) + BuildKind.SETTLEMENT -> + BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) + + BuildKind.CITY -> + BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) + + null -> BoardTargets() + } + } + + fun onVertexTap( + vertex: Int, + legal: List, + building: BuildKind?, + setupVertex: Int?, + ): TapOutcome { + val setup = legal.filterIsInstance() + if (setup.isNotEmpty()) { + // Re-picking is allowed until the road is chosen, so a misplaced + // tap does not commit the settlement. + return if (setup.any { it.vertex == vertex }) { + TapOutcome.PickSetupVertex(vertex) + } else { + TapOutcome.Ignored + } + } + val move: CatanMove = when (building) { + BuildKind.SETTLEMENT -> BuildSettlement(vertex) + BuildKind.CITY -> BuildCity(vertex) + else -> return TapOutcome.Ignored + } + return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored + } + + fun onEdgeTap( + edge: Int, + legal: List, + building: BuildKind?, + setupVertex: Int?, + ): TapOutcome { + if (legal.any { it is SetupPlace }) { + if (setupVertex == null) return TapOutcome.Ignored + val move = SetupPlace(setupVertex, edge) + return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored + } + // Free roads need no build mode; a paid road does. + val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads } + 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): TapOutcome { + val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored + val moves = legal.filterIsInstance().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 }) + } + } + + // --- Action bar ------------------------------------------------------------ + + fun canRoll(legal: List): Boolean = legal.any { it is RollDice } + + fun canEndTurn(legal: List): Boolean = legal.any { it is EndTurn } + + fun canEndFreeRoads(legal: List): Boolean = legal.any { it is EndFreeRoads } + + fun canBuyDevCard(legal: List): Boolean = legal.any { it is BuyDevCard } + + fun canBuild(legal: List): Set = buildSet { + if (legal.any { it is BuildRoad }) add(BuildKind.ROAD) + if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT) + if (legal.any { it is BuildCity }) add(BuildKind.CITY) + } + + fun tradeResponses(legal: List): Pair = + legal.any { it is AcceptTrade } to legal.any { it is DeclineTrade } + + // --- Development cards ----------------------------------------------------- + + /** [seat]'s development cards, each with the plays the engine currently allows. */ + fun devHand(state: CatanState, seat: Int, legal: List): List = + state.devHands.getOrNull(seat).orEmpty().map { card -> + DevCardInHand(card, legal.filter { playOf(it) == card.id }) + }.sortedWith(compareBy({ it.card.type.ordinal }, { it.card.id })) + + private fun playOf(move: CatanMove): Int? = when (move) { + is PlayKnight -> move.card + is PlayRoadBuilding -> move.card + is PlayMonopoly -> move.card + is PlayYearOfPlenty -> move.card + else -> null + } + + /** The Monopoly moves for one card, by the resource each would name. */ + fun monopolyChoices(plays: List): Map = + plays.filterIsInstance().associateBy { it.resource } + + /** The Year of Plenty move that takes exactly [take], if the engine allows it. */ + fun plentyMove(plays: List, take: List): PlayYearOfPlenty? { + val want = take.groupingBy { it }.eachCount() + return plays.filterIsInstance().firstOrNull { + it.take.groupingBy { r -> r }.eachCount() == want + } + } + + /** + * Resources a Year of Plenty can still be asked for, given what is already + * [picked]: those that complete some take the engine allows. Empty once the + * two cards are chosen. + */ + fun plentyAvailable(plays: List, picked: List): Set { + val chosen = picked.groupingBy { it }.eachCount() + val available = mutableSetOf() + for (play in plays.filterIsInstance()) { + if (!chosen.all { (resource, n) -> play.take.count { it == resource } >= n }) continue + val remaining = play.take.toMutableList() + picked.forEach { remaining.remove(it) } + available += remaining + } + return available + } + + // --- Discarding ----------------------------------------------------------- + + /** How many cards a player holding [held] must discard on a seven. */ + fun discardQuota(held: Int): Int = held / 2 + + /** The discard move that gives up exactly [selection], if the engine allows it. */ + fun discardMove(legal: List, selection: List): Discard? { + val want = selection.groupingBy { it }.eachCount() + return legal.filterIsInstance().firstOrNull { + it.cards.groupingBy { r -> r }.eachCount() == want + } + } + + // --- Trading --------------------------------------------------------------- + + /** Bank trades on offer: what can be given, and for what. */ + fun bankTrades(legal: List): Map> = + legal.filterIsInstance() + .groupBy({ it.give }, { it.get }) + .mapValues { (_, gets) -> gets.sortedBy { it.ordinal } } + .toSortedMap(compareBy { it.ordinal }) + + /** What it costs [seat] to give one [resource] to the bank: 4, 3 or 2 cards. */ + fun bankRatio(state: CatanState, seat: Int, resource: Resource): Int = + CatanRules.bestPortRatio(state, seat, resource) + + /** + * Whether [seat] can offer [give] for [get] to [to] right now. Uses the + * engine's own structural check, since offers are not in the enumerated + * move list. + */ + fun offerAllowed( + state: CatanState, + seat: Int, + to: Int, + give: List, + get: List, + ): Boolean = give.isNotEmpty() && get.isNotEmpty() && + CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get)) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt new file mode 100644 index 0000000..65fd78d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -0,0 +1,337 @@ +package org.prolibertate.games.ui.game.catan + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prolibertate.games.game.catan.CatanPhase +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.Resource + +/* + * Stateless building blocks for the Katan screen. + * + * Every composable here takes plain values and reports events through lambdas; + * none of them holds game state or touches the controller, so each can be + * restyled, re-laid-out or replaced on its own. Colours and proportions come + * from [CatanStyle] and wording from [CatanText]. + */ + +// --------------------------------------------------------------------------- +// Resource tile +// --------------------------------------------------------------------------- + +/** + * One resource as a coloured tile with its count. The hand, the discard picker + * and the trade pickers all use this, so a resource looks the same everywhere. + */ +@Composable +fun ResourceTile( + resource: Resource, + count: Int, + modifier: Modifier = Modifier, + selected: Boolean = false, + enabled: Boolean = true, + style: CatanStyle = LocalCatanStyle.current, + onClick: (() -> Unit)? = null, +) { + val shape = RoundedCornerShape(10.dp) + Column( + modifier = modifier + .defaultMinSize(minWidth = 56.dp) + .clip(shape) + .background(style.resource.getValue(resource).copy(alpha = if (enabled) 1f else 0.4f)) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) Color.White else Color(0x33000000), + shape = shape, + ) + .then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier) + .padding(horizontal = 6.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = count.toString(), + color = style.resourceInk, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Text( + text = CatanText.resource(resource), + color = style.resourceInk, + fontSize = 11.sp, + maxLines = 1, + ) + } +} + +/** The local player's cards: one tile per resource, in a row that wraps. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanHand( + state: CatanState, + seat: Int, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Resource.entries.forEach { resource -> + ResourceTile(resource, state.resources[seat][resource] ?: 0) + } + } +} + +// --------------------------------------------------------------------------- +// Players +// --------------------------------------------------------------------------- + +/** + * One player's public standing: colour, name, points, how many cards they hold + * (not which), development cards, knights and their longest road. + * + * [points] is passed in rather than computed here because the local player's + * own count includes hidden victory-point cards and everyone else's does not; + * the screen decides which. + */ +@Composable +fun CatanPlayerCard( + state: CatanState, + seat: Int, + seats: CatanSeats, + points: Int, + modifier: Modifier = Modifier, + style: CatanStyle = LocalCatanStyle.current, +) { + val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER + val shape = RoundedCornerShape(12.dp) + Column( + modifier = modifier + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border( + width = if (onTurn) 3.dp else 1.dp, + color = if (onTurn) MaterialTheme.colorScheme.primary else Color(0x22000000), + shape = shape, + ) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .size(14.dp) + .clip(CircleShape) + .background(style.seats[seat % style.seats.size]) + .border(1.dp, style.pieceOutline, CircleShape), + ) + Text( + text = seats.label(seat), + fontWeight = FontWeight.Bold, + maxLines = 1, + style = MaterialTheme.typography.labelLarge, + ) + } + Text( + text = "$points VP", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}", + style = MaterialTheme.typography.bodySmall, + ) + val awards = buildList { + if (state.longestRoadOwner == seat) add("Longest road") + if (state.largestArmyOwner == seat) add("Largest army") + } + if (awards.isNotEmpty()) { + Text( + text = awards.joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Dice +// --------------------------------------------------------------------------- + +/** The two dice as last rolled, with their total. Blank until the first roll. */ +@Composable +fun CatanDice(dice: List, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val shown = if (dice.size == 2) dice else listOf(null, null) + shown.forEach { value -> + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White) + .border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = value?.toString() ?: "–", + color = Color(0xFF1C1B1F), + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + textAlign = TextAlign.Center, + ) + } + } + if (dice.size == 2) { + Text( + text = "= ${dice.sum()}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */ +data class CatanActions( + val canRoll: Boolean = false, + val showTurnActions: Boolean = false, + val canBuild: Set = emptySet(), + val building: BuildKind? = null, + val canBuyCard: Boolean = false, + val canPlayCard: Boolean = false, + val canTrade: Boolean = false, + val canEndTurn: Boolean = false, + val canEndFreeRoads: Boolean = false, + /** A setup settlement is chosen and a different one may still be picked. */ + val canRepickSetup: Boolean = false, +) + +/** The buttons for whatever the player can do now. Pure display: every press is a lambda. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanActionBar( + actions: CatanActions, + onRoll: () -> Unit, + onBuild: (BuildKind?) -> Unit, + onBuyCard: () -> Unit, + onPlayCard: () -> Unit, + onTrade: () -> Unit, + onEndTurn: () -> Unit, + onEndFreeRoads: () -> Unit, + onRepickSetup: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) } + + if (actions.canRepickSetup) { + OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) } + } + + if (actions.showTurnActions) { + BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) + BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) + BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) + OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { + LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) + } + OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { Text(CatanText.PLAY_CARD) } + OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { Text(CatanText.TRADE) } + } + + if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) } + if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } + } +} + +@Composable +private fun BuildButton( + kind: BuildKind, + label: String, + cost: String, + actions: CatanActions, + onBuild: (BuildKind?) -> Unit, +) { + val enabled = kind in actions.canBuild + val active = actions.building == kind + val onClick = { onBuild(if (active) null else kind) } + if (active) { + FilledTonalButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) } + } else { + OutlinedButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) } + } +} + +@Composable +private fun LabelWithCost(label: String, cost: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(label) + Text(cost, fontSize = 9.sp, maxLines = 1) + } +} + +// --------------------------------------------------------------------------- +// Log +// --------------------------------------------------------------------------- + +/** The most recent happenings, newest last. Scrolls. */ +@Composable +fun CatanLog(state: CatanState, modifier: Modifier = Modifier) { + if (state.log.isEmpty()) { + Text(CatanText.NO_LOG, style = MaterialTheme.typography.bodySmall, modifier = modifier) + return + } + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + state.log.takeLast(40).forEach { + Text(text = it, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt new file mode 100644 index 0000000..898a03f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt @@ -0,0 +1,133 @@ +package org.prolibertate.games.ui.game.catan + +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.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.catan.CatanAi +import org.prolibertate.games.game.catan.CatanOptions +import org.prolibertate.games.game.catan.CatanPhase +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/* + * Android Studio previews for the Katan UI. They exist so the look can be + * iterated on without starting a match: open this file, open the Preview pane, + * and edit CatanStyle / CatanText / the panels. Each preview draws a real + * position (the computer plays itself for a while), not a mock. + * + * Nothing here ships in a behaviour sense: previews are only compiled for tooling. + */ + +private val previewJson = Json { encodeDefaults = true } + +/** A real game, played forward by the computer for [moves] moves, for previewing. */ +private fun sampleState(players: Int = 4, moves: Int = 90, seed: Long = 11L): CatanState { + val config = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until players).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) }, + optionsJson = previewJson.encodeToString(CatanOptions(playerCount = players)), + seed = seed, + ) + val ai = CatanAi() + var state = CatanRules.initialState(config) + repeat(moves) { + if (state.phase == CatanPhase.GAME_OVER) return state + val seat = CatanRules.currentSeat(state) ?: return state + val legal = CatanRules.legalMoves(state, seat) + if (legal.isEmpty()) return state + state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal)) + } + return state +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 360) +@Composable +private fun BoardPreview() { + val state = remember { sampleState() } + CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) { + CatanBoardView( + state = state, + targets = BoardTargets(), + selectedVertex = null, + onVertexTap = {}, + onEdgeTap = {}, + onHexTap = {}, + ) + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 360) +@Composable +private fun BoardDarkPreview() { + val state = remember { sampleState() } + CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = true)) { + CatanBoardView( + state = state, + targets = BoardTargets(), + selectedVertex = null, + onVertexTap = {}, + onEdgeTap = {}, + onHexTap = {}, + ) + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 360) +@Composable +private fun BoardWithTargetsPreview() { + val state = remember { sampleState(moves = 6) } + val legal = remember(state) { CatanRules.currentSeat(state)?.let { CatanRules.legalMoves(state, it) }.orEmpty() } + CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) { + CatanBoardView( + state = state, + targets = CatanInteraction.targets(state, legal, building = null, setupVertex = null), + selectedVertex = null, + onVertexTap = {}, + onEdgeTap = {}, + onHexTap = {}, + ) + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 560) +@Composable +private fun PanelsPreview() { + val state = remember { sampleState() } + val seats = CatanSeats(me = 0, names = listOf("You", "Ann", "Bo", "Cy")) + val points = remember(state) { CatanRules.publicVictoryPoints(state) } + Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + (0 until state.playerCount).forEach { seat -> + CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f)) + } + } + CatanHand(state, seat = 0) + CatanDice(state.lastDice) + CatanActionBar( + actions = CatanActions( + showTurnActions = true, + canBuild = setOf(BuildKind.ROAD, BuildKind.CITY), + building = BuildKind.ROAD, + canBuyCard = true, + canTrade = true, + canEndTurn = true, + ), + onRoll = {}, onBuild = {}, onBuyCard = {}, onPlayCard = {}, onTrade = {}, + onEndTurn = {}, onEndFreeRoads = {}, onRepickSetup = {}, + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt new file mode 100644 index 0000000..7c58230 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -0,0 +1,382 @@ +package org.prolibertate.games.ui.game.catan + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.unit.dp +import org.prolibertate.games.game.catan.AcceptTrade +import org.prolibertate.games.game.catan.BuyDevCard +import org.prolibertate.games.game.catan.CatanMove +import org.prolibertate.games.game.catan.CatanPhase +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.DeclineTrade +import org.prolibertate.games.game.catan.EndFreeRoads +import org.prolibertate.games.game.catan.EndTurn +import org.prolibertate.games.game.catan.MoveRobber +import org.prolibertate.games.game.catan.RollDice +import org.prolibertate.games.game.catan.SetupPlace +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold + +/** + * Katan, on the island. + * + * This composable is only wiring. It reads the match from the controller, holds + * the handful of purely visual choices (which build mode is armed, which setup + * settlement is waiting for its road, which dialog is open), and hands + * everything else to single-purpose pieces: + * + * | Concern | Where it lives | + * | ------------------------------ | -------------------------- | + * | Colours, sizes, shapes | [CatanStyle] | + * | Every word on screen | [CatanText] | + * | Island → pixels, taps → hexes | [CatanGeometry] | + * | What is tappable, what a tap does | [CatanInteraction] | + * | Drawing the island | [CatanBoardView] | + * | Cards, players, dice, buttons | `CatanPanels.kt` | + * | Every dialog | `CatanDialogs.kt` | + * + * There are no game rules here: the engine's legal-move list is the only source + * of what the player may do, so the screen cannot offer a move the rules refuse. + * See `docs/UI-catan.md` for how to adapt the look and feel. + */ +@Composable +fun CatanScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, + seatNames: List = emptyList(), +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + val notice by controller.notice.collectAsState() + + // Purely visual state. Anything that affects the game goes through submit(). + var building by remember { mutableStateOf(null) } + var setupVertex by remember { mutableStateOf(null) } + var victimChoice by remember { mutableStateOf(null) } + var showCards by remember { mutableStateOf(false) } + var showTrade by remember { mutableStateOf(false) } + var confirmingEnd by remember { mutableStateOf(false) } + var resultDismissed by remember { mutableStateOf(false) } + + val dark = isSystemInDarkTheme() + val style = remember(dark) { catanStyle(dark) } + val seats = remember(localSeat, seatNames) { CatanSeats(localSeat, seatNames) } + + // The engine's list changes after every move; drop any selection it no + // longer supports (a build mode the player can no longer afford, a setup + // settlement that is no longer pending). + LaunchedEffect(legal) { + if (building != null && building !in CatanInteraction.canBuild(legal)) building = null + if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) { + setupVertex = null + } + if (legal.isEmpty()) { + showCards = false + showTrade = false + victimChoice = null + } + } + + val submit: (CatanMove) -> Unit = { move -> + controller.submit(move) + building = null + setupVertex = null + victimChoice = null + showCards = false + showTrade = false + } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text(CatanText.END_GAME) } + } + + val current = state + if (current == null) { + ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + abandoned?.let { CatanAbandonedDialog(it, onExit, controller::dismissAbandoned) } + if (confirmingEnd) CatanEndGameDialog(onExit) { confirmingEnd = false } + } + return + } + + val targets = CatanInteraction.targets(current, legal, building, setupVertex) + val devHand = CatanInteraction.devHand(current, localSeat, legal) + val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() + val actions = CatanActions( + canRoll = CatanInteraction.canRoll(legal), + showTurnActions = playing, + canBuild = CatanInteraction.canBuild(legal), + building = building, + canBuyCard = CatanInteraction.canBuyDevCard(legal), + canPlayCard = devHand.any { it.playable }, + canTrade = playing, + canEndTurn = CatanInteraction.canEndTurn(legal), + canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal), + canRepickSetup = setupVertex != null, + ) + val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building) + + val handle: (TapOutcome) -> Unit = { outcome -> + when (outcome) { + TapOutcome.Ignored -> Unit + is TapOutcome.Submit -> submit(outcome.move) + is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex + is TapOutcome.PickRobberVictim -> victimChoice = outcome + } + } + + val publicPoints = CatanRules.publicVictoryPoints(current) + val pointsOf: (Int) -> Int = { seat -> + if (seat == localSeat) CatanRules.victoryPoints(current, seat) else publicPoints[seat] + } + + CompositionLocalProvider(LocalCatanStyle provides style) { + ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier -> + BoxWithConstraints(modifier = modifier) { + val wide = maxWidth > maxHeight * 1.1f + + val board: @Composable (Modifier) -> Unit = { boardModifier -> + CatanBoardView( + state = current, + targets = targets, + selectedVertex = setupVertex, + onVertexTap = { handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex)) }, + onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex)) }, + onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) }, + modifier = boardModifier, + style = style, + ) + } + val players: @Composable (Boolean) -> Unit = { grid -> + PlayerCards(current, seats, pointsOf, style, twoPerRow = grid) + } + val promptRow: @Composable () -> Unit = { + PromptRow(prompt, notice, current) + } + val handAndActions: @Composable () -> Unit = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + CatanHand(current, localSeat) + CatanActionBar( + actions = actions, + onRoll = { submit(RollDice()) }, + onBuild = { building = it }, + onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) }, + onPlayCard = { showCards = true }, + onTrade = { showTrade = true }, + onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) }, + onEndFreeRoads = { + legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit) + }, + onRepickSetup = { setupVertex = null }, + ) + } + } + + if (wide) { + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) { + board(Modifier) + } + Column( + modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + players(true) + promptRow() + handAndActions() + CatanLog(current, Modifier.fillMaxWidth().height(120.dp)) + } + } + } else { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + players(false) + promptRow() + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + board(Modifier) + } + handAndActions() + } + } + } + + CatanDialogHost( + state = current, + seats = seats, + localSeat = localSeat, + legal = legal, + abandoned = abandoned, + confirmingEnd = confirmingEnd, + resultDismissed = resultDismissed, + victimChoice = victimChoice, + showCards = showCards, + showTrade = showTrade, + devHand = devHand, + onExit = onExit, + onSubmit = submit, + onStay = controller::dismissAbandoned, + onKeepPlaying = { confirmingEnd = false }, + onDismissResult = { resultDismissed = true }, + onCancelVictim = { victimChoice = null }, + onCancelCards = { showCards = false }, + onCancelTrade = { showTrade = false }, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Layout pieces +// --------------------------------------------------------------------------- + +@Composable +private fun PlayerCards( + state: CatanState, + seats: CatanSeats, + pointsOf: (Int) -> Int, + style: CatanStyle, + twoPerRow: Boolean, +) { + val order = (0 until state.playerCount).toList() + val rows = if (twoPerRow) order.chunked(2) else listOf(order) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + rows.forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + row.forEach { seat -> + CatanPlayerCard( + state = state, + seat = seat, + seats = seats, + points = pointsOf(seat), + modifier = Modifier.weight(1f), + style = style, + ) + } + // Keep an odd last row's card the same width as the others. + if (twoPerRow && row.size == 1) Box(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun PromptRow(prompt: String, notice: String?, state: CatanState) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(prompt, style = MaterialTheme.typography.bodyMedium) + if (notice != null) { + Text( + notice, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + CatanDice(state.lastDice) + } +} + +/** + * Chooses which dialog, if any, is showing. Pulled out of [CatanScreen] so the + * priority order — an ended table beats a result beats a forced discard beats + * an optional picker — is in one readable place. + */ +@Composable +private fun CatanDialogHost( + state: CatanState, + seats: CatanSeats, + localSeat: Int, + legal: List, + abandoned: String?, + confirmingEnd: Boolean, + resultDismissed: Boolean, + victimChoice: TapOutcome.PickRobberVictim?, + showCards: Boolean, + showTrade: Boolean, + devHand: List, + onExit: () -> Unit, + onSubmit: (CatanMove) -> Unit, + onStay: () -> Unit, + onKeepPlaying: () -> Unit, + onDismissResult: () -> Unit, + onCancelVictim: () -> Unit, + onCancelCards: () -> Unit, + onCancelTrade: () -> Unit, +) { + val offer = state.pendingTrade + when { + abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay) + + confirmingEnd -> CatanEndGameDialog(onEnd = onExit, onKeepPlaying = onKeepPlaying) + + state.phase == CatanPhase.GAME_OVER -> + if (!resultDismissed) CatanGameOverDialog(state, seats, onExit, onDismissResult) + + state.phase == CatanPhase.DISCARDING && state.discardSeat == localSeat -> + CatanDiscardDialog( + held = state.resources[localSeat], + quota = CatanInteraction.discardQuota(state.resourceCount(localSeat)), + legal = legal, + onSubmit = onSubmit, + ) + + state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat -> + CatanTradeResponseDialog( + offer = offer, + seats = seats, + canAccept = CatanInteraction.tradeResponses(legal).first, + onAccept = { onSubmit(AcceptTrade) }, + onDecline = { onSubmit(DeclineTrade) }, + ) + + victimChoice != null -> + CatanVictimDialog( + victims = victimChoice.victims, + seats = seats, + onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) }, + onCancel = onCancelVictim, + ) + + showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards) + + showTrade -> + CatanTradeDialog( + state = state, + seat = localSeat, + seats = seats, + legal = legal, + onBank = onSubmit, + onOffer = onSubmit, + onCancel = onCancelTrade, + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt new file mode 100644 index 0000000..d7c96e8 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt @@ -0,0 +1,130 @@ +package org.prolibertate.games.ui.game.catan + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.Terrain + +/** + * Every visual decision for the Katan screen, in one place. + * + * **To restyle Katan, edit this file.** Nothing else in `ui/game/catan/` hard- + * codes a colour or a proportion: the board, the hand, the player strip and the + * dialogs all read a [CatanStyle] from [LocalCatanStyle]. A different art + * direction is a different [CatanStyle] value (see [catanStyle]); a new theme + * can be supplied at the screen's root with + * `CompositionLocalProvider(LocalCatanStyle provides myStyle)`. + * + * Wording is the same story in [CatanText]; geometry is in [CatanGeometry]. + * + * Proportions are fractions of the *hex radius* (centre of a hex to one of its + * corners), so they scale with the board whatever the screen size. + */ +@Immutable +data class CatanStyle( + // --- Board surfaces ----------------------------------------------------- + /** The water around the island. */ + val sea: Color, + /** Fill of each hex by terrain. */ + val terrain: Map, + /** Outline between hexes. */ + val hexEdge: Color, + /** The number-token disc, and the ink for ordinary and hot (6 / 8) numbers. */ + val token: Color, + val tokenInk: Color, + val tokenHotInk: Color, + /** The robber's pawn. */ + val robber: Color, + /** Harbour marker disc and its ink. */ + val portMarker: Color, + val portInk: Color, + + // --- Resources (hand tiles, trade pickers, harbour markers) ------------- + val resource: Map, + val resourceInk: Color, + + // --- Players ------------------------------------------------------------ + /** One colour per seat, in seat order. Roads, settlements, cities, player strip. */ + val seats: List, + /** Outline drawn round every piece so any seat colour reads on any terrain. */ + val pieceOutline: Color, + + // --- Interaction feedback ---------------------------------------------- + /** Marks where the player may build or move right now. */ + val target: Color, + /** The setup settlement that has been chosen and is waiting for its road. */ + val targetSelected: Color, + + // --- Metrics (fractions of the hex radius) ------------------------------ + /** How far each tile polygon is pulled in from its corners, leaving a gap. */ + val hexInset: Float = 0.04f, + val roadWidth: Float = 0.20f, + val settlementSize: Float = 0.30f, + val citySize: Float = 0.42f, + val tokenRadius: Float = 0.36f, + val robberSize: Float = 0.34f, + val portRadius: Float = 0.34f, + val targetRadius: Float = 0.20f, + /** How close a tap must land to a vertex / edge / hex centre to count. */ + val tapReach: Float = 0.55f, + val tokenText: Float = 0.34f, + val portText: Float = 0.26f, +) + +/** The light palette, which is also the reference for the dark one. */ +private val LightStyle = CatanStyle( + sea = Color(0xFF3A7CA5), + terrain = mapOf( + Terrain.FOREST to Color(0xFF2F6B3A), + Terrain.PASTURE to Color(0xFF9CCC65), + Terrain.FIELDS to Color(0xFFE8C84A), + Terrain.HILLS to Color(0xFFB4532F), + Terrain.MOUNTAINS to Color(0xFF7C8794), + Terrain.DESERT to Color(0xFFE6D3A0), + ), + hexEdge = Color(0x55000000), + token = Color(0xFFF7EFD9), + tokenInk = Color(0xFF2B2B2B), + tokenHotInk = Color(0xFFC62828), + robber = Color(0xFF263238), + portMarker = Color(0xFFFFF8E1), + portInk = Color(0xFF2B2B2B), + resource = mapOf( + Resource.LUMBER to Color(0xFF2F6B3A), + Resource.BRICK to Color(0xFFB4532F), + Resource.WOOL to Color(0xFF8BC34A), + Resource.GRAIN to Color(0xFFE0B020), + Resource.ORE to Color(0xFF6B7684), + ), + resourceInk = Color(0xFFFFFFFF), + seats = listOf( + Color(0xFF1565C0), // blue + Color(0xFFEF6C00), // orange + Color(0xFFF5F5F5), // white + Color(0xFF8E24AA), // purple + ), + pieceOutline = Color(0xFF1B1B1B), + target = Color(0xFFFFEB3B), + targetSelected = Color(0xFF00E5FF), +) + +/** Deeper sea and slightly dimmer land so the board is not a glare at night. */ +private val DarkStyle = LightStyle.copy( + sea = Color(0xFF14354D), + terrain = LightStyle.terrain.mapValues { (_, c) -> c.dim(0.82f) }, + token = Color(0xFFE9DFC4), +) + +/** The style for the current light / dark setting. */ +fun catanStyle(dark: Boolean): CatanStyle = if (dark) DarkStyle else LightStyle + +/** + * What the Katan composables read their look from. Defaults to the light style + * so a preview or a test with no provider still draws something sensible; + * [CatanScreen] provides the right one for the current theme. + */ +val LocalCatanStyle = staticCompositionLocalOf { LightStyle } + +private fun Color.dim(factor: Float): Color = + Color(red * factor, green * factor, blue * factor, alpha) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt new file mode 100644 index 0000000..bd40f76 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -0,0 +1,213 @@ +package org.prolibertate.games.ui.game.catan + +import org.prolibertate.games.game.catan.CatanPhase +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.DevCardType +import org.prolibertate.games.game.catan.Port +import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.Terrain + +/** + * Who is who at the table, for wording. [names] may be empty or short — a + * client device does not always know everyone's name — and the rest fall back + * to "Seat N". + */ +class CatanSeats(val me: Int, private val names: List = emptyList()) { + fun label(seat: Int): String = when { + seat == me -> "You" + else -> names.getOrNull(seat)?.takeIf { it.isNotBlank() } ?: "Seat ${seat + 1}" + } + + /** For mid-sentence use: "you" rather than "You". */ + fun lower(seat: Int): String = if (seat == me) "you" else label(seat) +} + +/** + * Every word the Katan screen shows, in one place. + * + * **To change copy, or to localise, edit this file.** The composables never + * build a sentence themselves; they ask for it here. Keeping the sentences as + * plain functions of state also means they are unit-tested (`CatanTextTest`) + * without a device. + * + * "Katan" is this app's name for the game (see `docs/RULES-catan.md`); the + * resources are lumber, brick, wool, grain and ore. + */ +object CatanText { + + const val TITLE = "Katan" + + // --- Names --------------------------------------------------------------- + + fun resource(resource: Resource): String = when (resource) { + Resource.LUMBER -> "Lumber" + Resource.BRICK -> "Brick" + Resource.WOOL -> "Wool" + Resource.GRAIN -> "Grain" + Resource.ORE -> "Ore" + } + + fun terrain(terrain: Terrain): String = when (terrain) { + Terrain.FOREST -> "Forest" + Terrain.PASTURE -> "Pasture" + Terrain.FIELDS -> "Fields" + Terrain.HILLS -> "Hills" + Terrain.MOUNTAINS -> "Mountains" + Terrain.DESERT -> "Desert" + } + + fun devCard(type: DevCardType): String = when (type) { + DevCardType.KNIGHT -> "Knight" + DevCardType.ROAD_BUILDING -> "Road building" + DevCardType.MONOPOLY -> "Monopoly" + DevCardType.YEAR_OF_PLENTY -> "Year of plenty" + DevCardType.VICTORY_POINT -> "Victory point" + } + + fun devCardHelp(type: DevCardType): String = when (type) { + DevCardType.KNIGHT -> "Move the robber and steal a card. Three played knights take Largest Army." + DevCardType.ROAD_BUILDING -> "Build two roads for free." + DevCardType.MONOPOLY -> "Name a resource and take every card of it from the other players." + DevCardType.YEAR_OF_PLENTY -> "Take any two resource cards from the bank." + DevCardType.VICTORY_POINT -> "One victory point. Kept hidden until you win." + } + + /** "3:1" for a generic harbour, "2:1" for a resource one. */ + fun portRatio(port: Port): String = "${port.ratio}:1" + + fun portName(port: Port): String = + port.resource?.let { "${portRatio(port)} ${resource(it).lowercase()}" } ?: "3:1 any" + + // --- Costs --------------------------------------------------------------- + + fun cost(cost: Map): String = + cost.entries.joinToString(" + ") { (r, n) -> "$n ${resource(r).lowercase()}" } + + val ROAD_COST: String get() = cost(CatanRules.ROAD_COST) + val SETTLEMENT_COST: String get() = cost(CatanRules.SETTLEMENT_COST) + val CITY_COST: String get() = cost(CatanRules.CITY_COST) + val DEV_COST: String get() = cost(CatanRules.DEV_COST) + + // --- Buttons and headings -------------------------------------------------- + + const val ROLL = "Roll dice" + const val END_TURN = "End turn" + const val ROAD = "Road" + const val SETTLEMENT = "Settlement" + const val CITY = "City" + const val BUY_CARD = "Buy card" + const val PLAY_CARD = "Play card" + const val TRADE = "Trade" + const val CANCEL = "Cancel" + const val CONFIRM = "Confirm" + const val STOP_ROADS = "Done with roads" + const val BUILD = "Build" + const val YOUR_HAND = "Your hand" + const val CARDS = "cards" + const val ACCEPT = "Accept" + const val DECLINE = "Decline" + const val TRADE_WITH_PLAYER = "Offer to a player" + const val TRADE_WITH_BANK = "Trade with the bank" + const val YOU_GIVE = "You give" + const val YOU_GET = "You get" + const val SEND_OFFER = "Send offer" + const val NO_LOG = "Nothing has happened yet." + const val END_GAME = "End game" + const val LOOK_AT_BOARD = "Look at the board" + const val LEAVE_GAME = "Leave the game" + + // --- The prompt line ------------------------------------------------------ + + /** + * The one line that tells the player what is going on and what to do. + * + * [setupVertexChosen] is the setup settlement already picked and waiting + * for its road; [building] is the build mode the player has entered, if any. + */ + fun prompt( + state: CatanState, + seats: CatanSeats, + setupVertexChosen: Boolean, + building: BuildKind?, + ): String { + val me = seats.me + val onClock = CatanRules.currentSeat(state) + val mine = onClock == me + val turn = seats.label(state.turn) + + return when (state.phase) { + CatanPhase.GAME_OVER -> { + val winner = state.winner + if (winner == null) "The game is over." else "${seats.label(winner)} won the game." + } + + CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when { + !mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…" + setupVertexChosen -> "Now tap the road to place beside that settlement." + state.phase == CatanPhase.SETUP_SECOND -> + "Place your second settlement. It earns resources from the hexes around it." + + else -> "Place a settlement, then a road." + } + + CatanPhase.AWAIT_ROLL -> + if (mine) "Your turn. Roll the dice." else "$turn is rolling…" + + CatanPhase.DISCARDING -> { + val discarder = state.discardSeat + when { + discarder == me -> "A seven! You hold ${state.resourceCount(me)} cards; " + + "discard ${state.resourceCount(me) / 2}." + + discarder != null -> "A seven! ${seats.label(discarder)} must discard half." + else -> "A seven! Discarding…" + } + } + + CatanPhase.ROBBER -> + if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…" + + CatanPhase.PLAYING -> when { + !mine -> "$turn is playing…" + 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." + else -> "Your turn. Build, trade, or end the turn." + } + + CatanPhase.FREE_ROADS -> + if (mine) { + "Road building: place ${state.pendingFreeRoads} more " + + if (state.pendingFreeRoads == 1) "road." else "roads." + } else { + "$turn is placing free roads…" + } + + CatanPhase.TRADE_PENDING -> { + val offer = state.pendingTrade + when { + offer == null -> "Waiting for a reply…" + offer.to == me -> "${seats.label(offer.by)} offers you a trade." + offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…" + else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade." + } + } + } + } + + /** "3 Lumber + 1 Brick" for a flat list of cards. */ + fun cards(cards: List): String = + cards.groupingBy { it }.eachCount().entries + .sortedBy { it.key.ordinal } + .joinToString(" + ") { (r, n) -> "$n ${resource(r)}" } + .ifEmpty { "nothing" } + + fun tradeSummary(by: String, to: String, give: List, get: List): String = + "$by offers ${cards(give)} to $to for ${cards(get)}" + + fun winnerHeadline(state: CatanState, seats: CatanSeats): String { + val winner = state.winner ?: return "Game over" + return if (winner == seats.me) "You won!" else "${seats.label(winner)} won" + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 465e065..4e30831 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -121,6 +121,20 @@ class CatalogTest { assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction) } + @Test + fun `a preview game opens on a dev build and nowhere else`() { + val preview = GameCatalog.all.filter { it.devPreview } + assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN)) + preview.forEach { game -> + assertFalse("${game.id}: preview is for games that are not yet available", game.available) + assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true)) + assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false)) + } + // An ordinary finished game opens everywhere. + val chess = GameCatalog.byId(GameCatalog.CHESS)!! + assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false)) + } + @Test fun `hiding unfinished games still leaves something to play in every menu`() { // A menu filtered down to nothing would leave a stranded heading, and a diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt new file mode 100644 index 0000000..af25535 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt @@ -0,0 +1,53 @@ +package org.prolibertate.games.ui.game.catan + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.catan.BuildingType +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.CatanRules +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** Shared fixtures for the Katan UI-logic tests: real islands, bent to taste. */ +internal object CatanFixtures { + + private val json = Json { encodeDefaults = true } + + fun config(players: Int = 3, seed: Long = 7L) = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString(CatanOptions(playerCount = players)), + seed = seed, + ) + + /** A fresh table, on the first setup placement. */ + fun start(players: Int = 3, seed: Long = 7L): CatanState = + CatanRules.initialState(config(players, seed)) + + /** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */ + fun playing( + turn: Int = 0, + players: Int = 3, + adjust: CatanState.() -> CatanState = { this }, + ): CatanState = start(players) + .copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1) + .adjust() + + fun hand(vararg cards: Pair): Map = + Resource.entries.associateWith { cards.toMap()[it] ?: 0 } + + fun CatanState.withHand(seat: Int, hand: Map): CatanState = + copy(resources = resources.toMutableList().also { it[seat] = hand }) + + fun CatanState.withBuilding(vertex: Int, type: BuildingType, owner: Int): CatanState = + copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) }) + + fun CatanState.withRoad(edge: Int, owner: Int): CatanState = + copy(roads = roads.toMutableList().also { it[edge] = owner }) +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt new file mode 100644 index 0000000..69362b2 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt @@ -0,0 +1,117 @@ +package org.prolibertate.games.ui.game.catan + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.catan.CatanBoard +import kotlin.math.abs +import kotlin.math.sqrt + +class CatanGeometryTest { + + private val sizes = listOf(360f to 800f, 800f to 360f, 500f to 500f, 1000f to 1000f / CatanGeometry.ASPECT) + + private fun dist(a: Pt, b: Pt): Float = sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)) + + @Test + fun `the whole island fits inside the box at every shape`() { + for ((w, h) in sizes) { + val layout = CatanGeometry.fit(w, h) + for (v in 0 until CatanBoard.VERTEX_COUNT) { + val p = layout.vertex(v) + assertTrue("vertex $v x=${p.x} outside 0..$w", p.x in 0f..w) + assertTrue("vertex $v y=${p.y} outside 0..$h", p.y in 0f..h) + } + for (e in CatanBoard.PORT_POSITIONS) { + val p = layout.portAnchor(e) + assertTrue("harbour marker x=${p.x} outside 0..$w at ${w}x$h", p.x in 0f..w) + assertTrue("harbour marker y=${p.y} outside 0..$h at ${w}x$h", p.y in 0f..h) + } + } + } + + @Test + fun `the layout is as large as the box allows`() { + // Tall and narrow is limited by width, short and wide by height; either + // way the island should use most of the limiting side. + val tall = CatanGeometry.fit(360f, 800f) + val wide = CatanGeometry.fit(800f, 360f) + assertTrue(tall.radius > 20f) + assertTrue(wide.radius > 20f) + val xs = (0 until CatanBoard.VERTEX_COUNT).map { tall.vertex(it).x } + assertTrue("island should span most of the width", xs.max() - xs.min() > 360f * 0.7f) + } + + @Test + fun `every edge is one hex radius long and every hex corner is one radius from its centre`() { + val layout = CatanGeometry.fit(500f, 500f) + for (e in 0 until CatanBoard.EDGE_COUNT) { + val (a, b) = layout.edgeEnds(e) + assertEquals("edge $e length", layout.radius, dist(a, b), layout.radius * 0.01f) + } + for (h in 0 until CatanBoard.HEX_COUNT) { + val centre = layout.hexCenter(h) + for (corner in layout.hexCorners(h)) { + assertEquals("hex $h corner", layout.radius, dist(centre, corner), layout.radius * 0.01f) + } + } + } + + @Test + fun `all fifty-four vertices sit at distinct places`() { + val layout = CatanGeometry.fit(500f, 500f) + val places = (0 until CatanBoard.VERTEX_COUNT).map { + val p = layout.vertex(it) + Math.round(p.x) to Math.round(p.y) + }.toSet() + assertEquals(CatanBoard.VERTEX_COUNT, places.size) + } + + @Test + fun `a tap on a vertex finds that vertex, and a tap far away finds nothing`() { + val layout = CatanGeometry.fit(500f, 500f) + val all = (0 until CatanBoard.VERTEX_COUNT).toList() + for (v in all) { + val p = layout.vertex(v) + assertEquals(v, layout.nearestVertex(p, layout.radius * 0.5f, all)) + // A slightly clumsy tap still lands on it. + assertEquals(v, layout.nearestVertex(Pt(p.x + layout.radius * 0.2f, p.y - layout.radius * 0.15f), layout.radius * 0.55f, all)) + } + assertNull(layout.nearestVertex(Pt(-100f, -100f), layout.radius * 0.5f, all)) + } + + @Test + fun `hit testing only considers the candidates it is given`() { + val layout = CatanGeometry.fit(500f, 500f) + val at = layout.vertex(5) + assertNull(layout.nearestVertex(at, layout.radius * 0.3f, listOf(6))) + assertEquals(6, layout.nearestVertex(layout.vertex(6), layout.radius * 0.3f, listOf(6))) + assertEquals(3, layout.nearestEdge(layout.edgeMidpoint(3), 5f, listOf(3, 4))) + assertEquals(7, layout.nearestHex(layout.hexCenter(7), 5f, listOf(7, 8))) + } + + @Test + fun `the nearest candidate wins when several are in reach`() { + val layout = CatanGeometry.fit(500f, 500f) + val a = 10 + val b = CatanBoard.VERTEX_NEIGHBORS[a].first() + val pa = layout.vertex(a) + val pb = layout.vertex(b) + // A point two-thirds of the way toward b is nearer b, though both are in reach. + val at = Pt(pa.x + (pb.x - pa.x) * 0.66f, pa.y + (pb.y - pa.y) * 0.66f) + assertEquals(b, layout.nearestVertex(at, layout.radius * 2f, listOf(a, b))) + } + + @Test + fun `harbour markers sit outside the coast`() { + val layout = CatanGeometry.fit(500f, 500f) + val centre = Pt(500f / 2f, 500f / 2f) + for (e in CatanBoard.PORT_POSITIONS) { + val anchor = layout.portAnchor(e) + val mid = layout.edgeMidpoint(e) + assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre)) + assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * 0.62f) < 1f) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt new file mode 100644 index 0000000..61fb44e --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt @@ -0,0 +1,325 @@ +package org.prolibertate.games.ui.game.catan + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.catan.BuildCity +import org.prolibertate.games.game.catan.BuildRoad +import org.prolibertate.games.game.catan.BuildSettlement +import org.prolibertate.games.game.catan.BuildingType +import org.prolibertate.games.game.catan.CatanBoard +import org.prolibertate.games.game.catan.CatanPhase +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.DevCard +import org.prolibertate.games.game.catan.DevCardType +import org.prolibertate.games.game.catan.Discard +import org.prolibertate.games.game.catan.MaritimeTrade +import org.prolibertate.games.game.catan.MoveRobber +import org.prolibertate.games.game.catan.PlayKnight +import org.prolibertate.games.game.catan.PlayMonopoly +import org.prolibertate.games.game.catan.PlayYearOfPlenty +import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.SetupPlace +import org.prolibertate.games.ui.game.catan.CatanFixtures.hand +import org.prolibertate.games.ui.game.catan.CatanFixtures.playing +import org.prolibertate.games.ui.game.catan.CatanFixtures.start +import org.prolibertate.games.ui.game.catan.CatanFixtures.withBuilding +import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand +import org.prolibertate.games.ui.game.catan.CatanFixtures.withRoad + +class CatanInteractionTest { + + private val rich = hand( + Resource.LUMBER to 5, Resource.BRICK to 5, Resource.WOOL to 5, + Resource.GRAIN to 5, Resource.ORE to 5, + ) + + // --- Setup --------------------------------------------------------------- + + @Test + fun `setup first offers every settlement spot, then only the roads beside the chosen one`() { + val state = start() + val legal = CatanRules.legalMoves(state, 0) + val setup = legal.filterIsInstance() + + val first = CatanInteraction.targets(state, legal, null, null) + assertEquals(setup.map { it.vertex }.toSet(), first.vertices) + assertTrue(first.edges.isEmpty() && first.hexes.isEmpty()) + + val vertex = first.vertices.first() + val second = CatanInteraction.targets(state, legal, null, vertex) + assertTrue(second.vertices.isEmpty()) + assertEquals(setup.filter { it.vertex == vertex }.map { it.edge }.toSet(), second.edges) + } + + @Test + fun `a setup placement is two taps - the settlement, then its road`() { + val state = start() + val legal = CatanRules.legalMoves(state, 0) + val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first() + val edge = CatanInteraction.targets(state, legal, null, vertex).edges.first() + + assertEquals(TapOutcome.PickSetupVertex(vertex), CatanInteraction.onVertexTap(vertex, legal, null, null)) + assertEquals(TapOutcome.Submit(SetupPlace(vertex, edge)), CatanInteraction.onEdgeTap(edge, legal, null, vertex)) + // A road tapped before any settlement is chosen means nothing. + assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, null, null)) + } + + @Test + fun `a road that does not touch the chosen settlement is refused`() { + val state = start() + val legal = CatanRules.legalMoves(state, 0) + val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first() + val beside = CatanBoard.VERTEX_EDGES[vertex].toSet() + val far = (0 until CatanBoard.EDGE_COUNT).first { it !in beside } + assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(far, legal, null, vertex)) + } + + // --- Building ------------------------------------------------------------- + + /** Seat 0 owns a settlement at vertex 0 and a road two edges long leading away from it. */ + private fun established(): Triple { + val e1 = CatanBoard.VERTEX_EDGES[0][0] + val w = CatanBoard.otherEnd(e1, 0) + val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 } + val x = CatanBoard.otherEnd(e2, w) + val state = playing { + withHand(0, rich).withBuilding(0, BuildingType.SETTLEMENT, 0).withRoad(e1, 0).withRoad(e2, 0) + } + return Triple(state, x, e2) + } + + @Test + fun `nothing on the board is tappable until a build mode is chosen`() { + val (state, _, _) = established() + val legal = CatanRules.legalMoves(state, 0) + assertTrue(CatanInteraction.targets(state, legal, null, null).isEmpty) + } + + @Test + fun `build modes target exactly what the engine allows`() { + val (state, x, _) = established() + val legal = CatanRules.legalMoves(state, 0) + + assertEquals( + legal.filterIsInstance().map { it.edge }.toSet(), + CatanInteraction.targets(state, legal, BuildKind.ROAD, null).edges, + ) + assertTrue(x in CatanInteraction.targets(state, legal, BuildKind.SETTLEMENT, null).vertices) + assertEquals(setOf(0), CatanInteraction.targets(state, legal, BuildKind.CITY, null).vertices) + assertEquals(setOf(BuildKind.ROAD, BuildKind.SETTLEMENT, BuildKind.CITY), CatanInteraction.canBuild(legal)) + } + + @Test + fun `a tap builds only when the matching mode is armed and the spot is legal`() { + val (state, x, _) = established() + val legal = CatanRules.legalMoves(state, 0) + val road = legal.filterIsInstance().first().edge + + assertEquals(TapOutcome.Submit(BuildSettlement(x)), CatanInteraction.onVertexTap(x, legal, BuildKind.SETTLEMENT, null)) + assertEquals(TapOutcome.Submit(BuildCity(0)), CatanInteraction.onVertexTap(0, legal, BuildKind.CITY, null)) + assertEquals(TapOutcome.Submit(BuildRoad(road)), CatanInteraction.onEdgeTap(road, legal, BuildKind.ROAD, null)) + + // No mode armed: nothing happens. + assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, null, null)) + assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(road, legal, null, null)) + // A settlement on an occupied crossing is not offered. + assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(0, legal, BuildKind.SETTLEMENT, null)) + // The wrong mode for the spot. + assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, BuildKind.CITY, null)) + } + + @Test + fun `without the cards, nothing can be built`() { + val (state, _, _) = established() + val broke = state.withHand(0, hand()) + val legal = CatanRules.legalMoves(broke, 0) + assertTrue(CatanInteraction.canBuild(legal).isEmpty()) + assertFalse(CatanInteraction.canBuyDevCard(legal)) + assertTrue(CatanInteraction.canEndTurn(legal)) + } + + @Test + fun `free roads from a road building card need no build mode`() { + val (state, _, _) = established() + val free = state.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2) + val legal = CatanRules.legalMoves(free, 0) + val targets = CatanInteraction.targets(free, legal, null, null) + assertTrue(targets.edges.isNotEmpty()) + val edge = targets.edges.first() + assertEquals(TapOutcome.Submit(BuildRoad(edge)), CatanInteraction.onEdgeTap(edge, legal, null, null)) + assertTrue(CatanInteraction.canEndFreeRoads(legal)) + } + + // --- The robber --------------------------------------------------------------- + + @Test + fun `the robber may go to any hex but the one it is on`() { + val state = playing { copy(phase = CatanPhase.ROBBER) } + val legal = CatanRules.legalMoves(state, 0) + val targets = CatanInteraction.targets(state, legal, null, null) + assertEquals((0 until CatanBoard.HEX_COUNT).filter { it != state.robber }.toSet(), targets.hexes) + + val hex = targets.hexes.first() + // Nobody has built anything, so there is nobody to rob: the move goes straight through. + assertEquals( + TapOutcome.Submit(MoveRobber(CatanBoard.HEXES[hex], null)), + CatanInteraction.onHexTap(hex, legal), + ) + assertEquals(TapOutcome.Ignored, CatanInteraction.onHexTap(state.robber, legal)) + } + + @Test + fun `a hex with two possible victims asks which`() { + val base = playing { copy(phase = CatanPhase.ROBBER) } + val hex = (0 until CatanBoard.HEX_COUNT).first { it != base.robber } + val corners = CatanBoard.HEX_CORNERS[hex] + val state = base + .withBuilding(corners[0], BuildingType.SETTLEMENT, 1) + .withBuilding(corners[3], BuildingType.SETTLEMENT, 2) + .withHand(1, hand(Resource.ORE to 1)) + .withHand(2, hand(Resource.GRAIN to 1)) + val legal = CatanRules.legalMoves(state, 0) + + val outcome = CatanInteraction.onHexTap(hex, legal) + assertTrue(outcome is TapOutcome.PickRobberVictim) + outcome as TapOutcome.PickRobberVictim + assertEquals(CatanBoard.HEXES[hex], outcome.hex) + assertEquals(setOf(1, 2), outcome.victims.toSet()) + } + + // --- Discarding ----------------------------------------------------------------- + + @Test + fun `a seven costs half the hand, rounded down`() { + assertEquals(4, CatanInteraction.discardQuota(9)) + assertEquals(4, CatanInteraction.discardQuota(8)) + assertEquals(3, CatanInteraction.discardQuota(7)) + } + + @Test + fun `a discard is found only when it names exactly an allowed set of cards`() { + val state = playing { + copy(phase = CatanPhase.DISCARDING, discardSeat = 0) + .withHand(0, hand(Resource.LUMBER to 3, Resource.BRICK to 3, Resource.WOOL to 3)) + } + val legal = CatanRules.legalMoves(state, 0) + assertTrue(legal.all { it is Discard }) + + val good = listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.WOOL) + assertNotNull(CatanInteraction.discardMove(legal, good)) + // Order does not matter. + assertNotNull(CatanInteraction.discardMove(legal, good.reversed())) + // Too few, and more than are held. + assertNull(CatanInteraction.discardMove(legal, good.take(3))) + assertNull(CatanInteraction.discardMove(legal, List(4) { Resource.LUMBER })) + } + + // --- Trading --------------------------------------------------------------------- + + @Test + fun `bank trades list what can be given and what it can buy`() { + val state = playing { withHand(0, hand(Resource.GRAIN to 4)) } + val legal = CatanRules.legalMoves(state, 0) + assertTrue(legal.any { it is MaritimeTrade }) + + val trades = CatanInteraction.bankTrades(legal) + assertEquals(setOf(Resource.GRAIN), trades.keys) + assertEquals(Resource.entries.filter { it != Resource.GRAIN }, trades.getValue(Resource.GRAIN)) + assertEquals(4, CatanInteraction.bankRatio(state, 0, Resource.GRAIN)) + } + + @Test + fun `an offer is allowed only to another player, for something different, from cards held`() { + val state = playing { withHand(0, hand(Resource.LUMBER to 2)) } + val lumber = listOf(Resource.LUMBER) + val grain = listOf(Resource.GRAIN) + + assertTrue(CatanInteraction.offerAllowed(state, 0, 1, lumber, grain)) + assertFalse("to oneself", CatanInteraction.offerAllowed(state, 0, 0, lumber, grain)) + assertFalse("nothing offered", CatanInteraction.offerAllowed(state, 0, 1, emptyList(), grain)) + assertFalse("nothing asked", CatanInteraction.offerAllowed(state, 0, 1, lumber, emptyList())) + assertFalse("like for like", CatanInteraction.offerAllowed(state, 0, 1, lumber, lumber)) + assertFalse("more than held", CatanInteraction.offerAllowed(state, 0, 1, List(3) { Resource.LUMBER }, grain)) + assertFalse("not their turn", CatanInteraction.offerAllowed(state, 1, 0, lumber, grain)) + } + + // --- Development cards ------------------------------------------------------------- + + @Test + fun `a development card is playable unless it was bought this turn or is a victory point`() { + val state = playing { + copy( + devHands = listOf( + listOf( + DevCard(1, DevCardType.KNIGHT, boughtTurn = 0), + DevCard(2, DevCardType.VICTORY_POINT, boughtTurn = 0), + DevCard(3, DevCardType.KNIGHT, boughtTurn = 1), + ), + emptyList(), + emptyList(), + ), + ) + } + val legal = CatanRules.legalMoves(state, 0) + val hand = CatanInteraction.devHand(state, 0, legal) + + // Sorted by type then id: the two knights, then the victory point. + assertEquals(listOf(1, 3, 2), hand.map { it.card.id }) + assertEquals(listOf(true, false, false), hand.map { it.playable }) + assertEquals(listOf(PlayKnight(1)), hand.first().plays) + } + + @Test + fun `monopoly offers one play per resource`() { + val plays = Resource.entries.map { PlayMonopoly(9, it) } + val choices = CatanInteraction.monopolyChoices(plays) + assertEquals(Resource.entries.toSet(), choices.keys) + assertEquals(PlayMonopoly(9, Resource.ORE), choices.getValue(Resource.ORE)) + } + + @Test + fun `year of plenty narrows to the cards that still complete a take`() { + val plays = listOf( + PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.BRICK)), + PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.LUMBER)), + PlayYearOfPlenty(4, listOf(Resource.ORE)), + ) + assertEquals( + setOf(Resource.LUMBER, Resource.BRICK, Resource.ORE), + CatanInteraction.plentyAvailable(plays, emptyList()), + ) + assertEquals( + setOf(Resource.LUMBER, Resource.BRICK), + CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER)), + ) + assertEquals(setOf(Resource.LUMBER), CatanInteraction.plentyAvailable(plays, listOf(Resource.BRICK))) + assertTrue(CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER, Resource.LUMBER)).isEmpty()) + + assertEquals(plays[0], CatanInteraction.plentyMove(plays, listOf(Resource.BRICK, Resource.LUMBER))) + assertEquals(plays[2], CatanInteraction.plentyMove(plays, listOf(Resource.ORE))) + assertNull(CatanInteraction.plentyMove(plays, listOf(Resource.GRAIN))) + } + + // --- Trade replies ------------------------------------------------------------------ + + @Test + fun `the addressee of an offer is given accept and decline`() { + val base = playing { + withHand(0, hand(Resource.LUMBER to 1)).withHand(1, hand(Resource.GRAIN to 1)) + } + val offer = org.prolibertate.games.game.catan.TradeOffer( + by = 0, to = 1, give = listOf(Resource.LUMBER), get = listOf(Resource.GRAIN), + ) + val pending = base.copy(phase = CatanPhase.TRADE_PENDING, pendingTrade = offer) + val legal = CatanRules.legalMoves(pending, 1) + assertEquals(true to true, CatanInteraction.tradeResponses(legal)) + + // If the addressee no longer holds what was asked, only decline remains. + val short = pending.withHand(1, hand()) + assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1))) + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanTextTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanTextTest.kt new file mode 100644 index 0000000..302019e --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanTextTest.kt @@ -0,0 +1,101 @@ +package org.prolibertate.games.ui.game.catan + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.catan.CatanPhase +import org.prolibertate.games.game.catan.Port +import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.ui.game.catan.CatanFixtures.hand +import org.prolibertate.games.ui.game.catan.CatanFixtures.playing +import org.prolibertate.games.ui.game.catan.CatanFixtures.start +import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand + +class CatanTextTest { + + private val names = listOf("Ann", "Bo", "Cy") + + @Test + fun `seats are named, with You for the local player and a number when a name is unknown`() { + val seats = CatanSeats(me = 1, names = names) + assertEquals("You", seats.label(1)) + assertEquals("Ann", seats.label(0)) + assertEquals("you", seats.lower(1)) + assertEquals("Seat 4", seats.label(3)) + assertEquals("Seat 1", CatanSeats(me = 1).label(0)) + assertEquals("Seat 1", CatanSeats(me = 1, names = listOf(" ")).label(0)) + } + + @Test + fun `setup prompts say what to place next`() { + val state = start() + val mine = CatanSeats(0, names) + assertTrue(CatanText.prompt(state, mine, setupVertexChosen = false, building = null).contains("Place a settlement")) + assertTrue(CatanText.prompt(state, mine, setupVertexChosen = true, building = null).contains("road")) + assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).startsWith("Ann is placing")) + val second = state.copy(phase = CatanPhase.SETUP_SECOND, setupIndex = 3) + assertTrue(CatanText.prompt(second, CatanSeats(2, names), false, null).contains("second settlement")) + } + + @Test + fun `the turn prompts follow the phase`() { + val roll = playing().copy(phase = CatanPhase.AWAIT_ROLL) + assertTrue(CatanText.prompt(roll, CatanSeats(0, names), false, null).contains("Roll the dice")) + assertEquals("Ann is rolling…", CatanText.prompt(roll, CatanSeats(1, names), false, null)) + + val turn = playing() + assertTrue(CatanText.prompt(turn, CatanSeats(0, names), false, null).contains("Your turn")) + assertEquals("Tap an edge to build a road.", CatanText.prompt(turn, CatanSeats(0, names), false, BuildKind.ROAD)) + assertEquals("Bo is playing…", CatanText.prompt(playing(turn = 1), CatanSeats(0, names), false, null)) + + val robber = playing().copy(phase = CatanPhase.ROBBER) + assertTrue(CatanText.prompt(robber, CatanSeats(0, names), false, null).contains("robber")) + } + + @Test + fun `the discard prompt names the quota`() { + val state = playing { + copy(phase = CatanPhase.DISCARDING, discardSeat = 0) + .withHand(0, hand(Resource.LUMBER to 9)) + } + assertTrue(CatanText.prompt(state, CatanSeats(0, names), false, null).contains("discard 4")) + assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).contains("Ann must discard half")) + } + + @Test + fun `road building counts down in the singular and the plural`() { + val two = playing().copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2) + val one = two.copy(pendingFreeRoads = 1) + assertTrue(CatanText.prompt(two, CatanSeats(0, names), false, null).endsWith("2 more roads.")) + assertTrue(CatanText.prompt(one, CatanSeats(0, names), false, null).endsWith("1 more road.")) + } + + @Test + fun `the end of the game names the winner`() { + val over = playing().copy(phase = CatanPhase.GAME_OVER, winner = 1) + assertEquals("Bo won the game.", CatanText.prompt(over, CatanSeats(0, names), false, null)) + assertEquals("You won!", CatanText.winnerHeadline(over, CatanSeats(1, names))) + assertEquals("Bo won", CatanText.winnerHeadline(over, CatanSeats(0, names))) + } + + @Test + fun `cards read as a sorted tally`() { + assertEquals( + "2 Lumber + 1 Grain", + CatanText.cards(listOf(Resource.GRAIN, Resource.LUMBER, Resource.LUMBER)), + ) + assertEquals("nothing", CatanText.cards(emptyList())) + assertEquals( + "Ann offers 1 Lumber to you for 2 Ore", + CatanText.tradeSummary("Ann", "you", listOf(Resource.LUMBER), listOf(Resource.ORE, Resource.ORE)), + ) + } + + @Test + fun `costs and harbours read as the rules state them`() { + assertEquals("1 lumber + 1 brick", CatanText.ROAD_COST) + assertEquals("2 grain + 3 ore", CatanText.CITY_COST) + assertEquals("2:1 ore", CatanText.portName(Port(edge = 0, resource = Resource.ORE, ratio = 2))) + assertEquals("3:1 any", CatanText.portName(Port(edge = 0, resource = null, ratio = 3))) + } +} diff --git a/docs/README.md b/docs/README.md index 5fb3d80..d6ef5a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,8 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`. - Build, CI, releases and OTA are described in the CI section of the root [README](../README.md). +- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle, + re-copy or re-lay-out it without touching the rules. - `licenses/` — third-party licences that must travel with the app (Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`). - Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root. diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index c1db5fc..429c117 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -19,8 +19,7 @@ behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`. - **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at least 2. A lower target makes a test game short. -The setup screen that exposes these lands with the screen task; the engine reads -them from the table configuration today. +The setup screen exposes both (players: 3 or 4; game to 5, 8, 10 or 12 points). ## Board @@ -291,10 +290,9 @@ timing rules; Longest Road and Largest Army worth five; victory at ten. - **No player-to-player trade over the wire beyond accept/decline.** The engine supports it; whether the UI surfaces it is the screen task's concern. - **No trading away a development card.** Not part of the base game. -- **The setup screen is not part of this task.** `CatanOptions` (players, target - points) is read from the table configuration; the screen that sets it, and the - catalogue descriptor's `available` flag, land with the screen and verification - tasks. +- **The screen is built, not yet verified on two devices.** The Katan screen and + setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a + dev-channel preview until two-device play has been verified. - **The AI does not look ahead.** It scores one move deep with a fixed resource valuation. It plays legally and plausibly; it is not a strong opponent, and no strength claim is made. diff --git a/docs/UI-catan.md b/docs/UI-catan.md new file mode 100644 index 0000000..f9ee225 --- /dev/null +++ b/docs/UI-catan.md @@ -0,0 +1,134 @@ +# Katan UI — how it is built, and how to change it + +The Katan screen (`app/src/main/java/org/prolibertate/games/ui/game/catan/`) is +built in layers so that **look, wording, layout and interaction can each be +changed on their own**. If you are an agent asked to restyle, retheme, +re-copy or re-lay-out Katan, start here and touch only the layer you were asked +about. + +The rules live elsewhere and are not part of the UI: `game/catan/` is a pure +state machine (see [RULES-catan.md](RULES-catan.md)). **Nothing in `ui/game/catan/` +decides what is legal.** The engine's legal-move list is the single source of +truth, so the UI can only ever offer a move the rules would accept. + +## The layers + +| Layer | File | Owns | Change it to… | +| --- | --- | --- | --- | +| **Style** | `CatanStyle.kt` | Every colour, proportion and the light/dark palettes | Re-skin: new terrain colours, seat colours, piece sizes, token look | +| **Copy** | `CatanText.kt` | Every sentence, button label and name shown | Reword, localise, rename resources | +| **Geometry** | `CatanGeometry.kt` | Engine lattice → pixels, and pixel → nearest vertex/edge/hex | Change board scale, margin, harbour placement, tap reach (rarely) | +| **Interaction** | `CatanInteraction.kt` | What is tappable, what a tap means, which buttons are live | Change *how* something is selected (never *whether* it is legal) | +| **Board drawing** | `CatanBoardView.kt` | The `Canvas`: tiles, tokens, ports, robber, roads, pieces, highlights | Change how a piece is drawn (art, shapes, animation) | +| **Panels** | `CatanPanels.kt` | Resource tiles, hand, player cards, dice, action bar, log | Restyle or rearrange widgets | +| **Dialogs** | `CatanDialogs.kt` | Discard, robber victim, play card, trade, trade reply, game over | Change a dialog's flow or look | +| **Screen** | `CatanScreen.kt` | Wiring to `MatchController`, portrait/landscape layout, which dialog shows | Change the layout or add a new element | +| **Previews** | `CatanPreviews.kt` | Android Studio previews of real positions | Iterate on the look without a match | + +Everything outside this folder that mentions Katan is one-line wiring: +`PlayScreen.kt` (builds the `MatchController`), `GameSetupScreen.kt` (players and +points-to-win options), `Catalog.kt` (the catalogue entry). + +## Recipes + +**Change colours or sizes.** Edit `CatanStyle.kt`. `LightStyle` is the reference +palette; `DarkStyle` is derived from it. Metrics (`roadWidth`, `settlementSize`, +`tokenRadius`, …) are fractions of the hex radius, so they scale with the screen. +To try a whole new theme, provide it at the root: +`CompositionLocalProvider(LocalCatanStyle provides myStyle) { … }`. + +**Change wording or translate.** Edit `CatanText.kt`. Composables never build a +sentence themselves. `prompt(...)` is the one-line "what is happening / what do I +do" text; it is a pure function of the state, with tests in `CatanTextTest`. + +**Redraw a piece** (settlement, road, robber, port, token). Edit the matching +`draw…` function in `CatanBoardView.kt`. Positions come from `BoardLayout`, so you +only decide the shape. Keep the hit target generous (see Accessibility). + +**Rearrange the screen.** Edit `CatanScreen.kt`. It builds four independent +pieces (`board`, `players`, `promptRow`, `handAndActions`) and arranges them +differently in portrait and landscape. Moving or dropping a piece does not affect +the others. + +**Add a widget.** Make it a stateless composable in `CatanPanels.kt` (values in, +lambdas out). Do not give it the `MatchController`. + +**Change how a selection works** (for example a one-tap build instead of a +build-mode toggle). Edit `CatanInteraction.kt` and its tests, then adjust the +screen's UI state. The legal-move list stays the gate. + +## Rules of the road + +1. **Never put a rule in a composable.** If a button's enabled state or a tap's + result depends on game logic, it goes in `CatanInteraction` (pure, tested) and + is derived from the engine's `legal` list or `CatanRules`. +2. **Never hard-code a colour or a string** outside `CatanStyle` / `CatanText`. +3. **Stateless by default.** Panels and dialogs take values and report events. The + only state they may own is the transient selection they are helping the player + make (cards picked in a trade, for example). +4. **Hidden information.** The local view comes from `CatanRules.viewFor`: other + players' development cards and the deck are empty on a client. Do not show the + size of the deck; it is not known to a client. Card *counts* (`resourceCount`, + `devHandCounts`) are public. +5. **The state is immutable.** Never mutate it; submit a move. + +## Interaction model (what the player actually does) + +| Phase | The player… | +| --- | --- | +| Setup | taps a highlighted crossing (settlement), then a highlighted edge beside it (road). *Cancel* re-picks the settlement | +| Roll | presses **Roll dice** | +| Seven | **Discard** dialog (forced); then taps a hex to move the robber; a victim dialog appears only if more than one player can be robbed | +| Main turn | arms **Road / Settlement / City** and taps a highlighted spot; **Buy card**; **Play card**; **Trade**; **End turn** | +| Road Building | taps highlighted edges, or **Done with roads** | +| Offered a trade | **Accept / Decline** dialog (forced) | +| Game over | result dialog | + +Trade offers are free-form (two card multisets), so they are validated with +`CatanRules.isLegalMove`, not looked up in the legal list. + +## Testing + +Gradle does not run in the agent sandbox; **CI is the compile and test check** +(dispatch `ci.yml` on your branch). What is covered: + +- `CatanGeometryTest` — the island fits any box, edges and corners are one radius, + hit-testing finds the right vertex/edge/hex. +- `CatanInteractionTest` — targets and tap outcomes for setup, building, free + roads, the robber (including the victim choice), discards, bank trades, offers, + development cards and trade replies, all against real engine states. +- `CatanTextTest` — the prompts and tallies. + +Drawing and dialogs are not unit-tested; use the previews in `CatanPreviews.kt` +and play a game on a dev build. + +## Accessibility notes + +- Tap targets: vertices and edges accept a tap within `tapReach` (0.7) hex radii, + about 23 dp on a phone. If you shrink the board, keep that physical size. +- Seat colours are blue, orange, white and purple, each outlined in near-black so + they read on any terrain. Colour is the only piece identity today; adding a + shape or letter per seat would help colour-blind players (a good first task). +- The board has a content description but no per-hex semantics; a screen-reader + mode would need one. + +## Status and known gaps + +The screen is **playable and feature-complete for the engine's rules**, but is +**unverified on two real devices**, so the catalogue entry is `devPreview` +(openable on a dev build, hidden from production). To release it: verify +two-device play, then in `Catalog.kt` set `available = true` and remove +`devPreview = true`, and update `CatalogTest`. + +Not done yet, in rough priority order: + +1. Two-device verification (host + guest over the LAN) and a full game on a phone + and a tablet. +2. Feedback on events: dice-roll animation, a highlight on hexes that produced, + resource-gain flourishes, haptics. The state carries the dice and a text log; + it does not say which hexes paid out, so that needs either a diff of two states + or an engine addition. +3. Art: terrain textures, resource and harbour icons, a real robber pawn. +4. Open trade to the whole table and counter-offers (an engine limitation too). +5. A board that shows the *last* move (a faint highlight of what just changed). +6. Landscape polish on large tablets; the right-hand panel scrolls but is plain. -- 2.47.3 From 3b0b307997a07cf8bf194241689fe9fd44613121 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:55:03 +0000 Subject: [PATCH 16/19] Katan: no roads past an opponent's settlement, piece limits, stable layout Rules - A road may only start from a crossing where the player has a building, or where one of their roads ends AND no opponent's settlement or city stands. roadConnects ignored opponents' buildings, so a road (the computer's included) could be laid straight past a settlement. Also applies to Road Building. - Enforce the piece supply: 15 roads, 5 settlements, 4 cities. It was unlimited. - Tests: building past a settlement and a city, free roads, own settlement not blocking, and running out of each piece. UI - The layout rescaled the board whenever it became the player's turn, because the action buttons only existed then. Everything outside the board now has a fixed height: the action bar always shows every button (disabled, not absent), the prompt is pinned to two lines, player cards always draw every line, and the dice total is always present. Rule recorded in docs/UI-catan.md. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG --- .../games/game/catan/CatanModel.kt | 5 + .../games/game/catan/CatanRules.kt | 59 +++++-- .../games/ui/game/catan/CatanPanels.kt | 150 +++++++++++------- .../games/ui/game/catan/CatanPreviews.kt | 3 +- .../games/ui/game/catan/CatanScreen.kt | 31 ++-- .../games/ui/game/catan/CatanText.kt | 6 + .../games/game/catan/CatanRulesTest.kt | 83 ++++++++++ docs/RULES-catan.md | 14 +- docs/UI-catan.md | 8 + 9 files changed, 277 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt index 7bd715c..721fe9c 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt @@ -12,6 +12,11 @@ const val LONGEST_ROAD_MIN = 5 /** Knights required to take Largest Army. */ const val LARGEST_ARMY_MIN = 3 +/** Pieces each player has: a player who has laid them all cannot build another. */ +const val MAX_ROADS = 15 +const val MAX_SETTLEMENTS = 5 +const val MAX_CITIES = 4 + /** Resource cards of each type the bank starts with. */ const val BANK_PER_RESOURCE = 19 diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt index a47156f..82fdb1d 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt @@ -272,11 +272,18 @@ object CatanRules : GameRules { return takes } - /** Road placements that connect to [seat]'s network, cost aside. */ + /** + * Road placements that connect to [seat]'s network, cost aside. A player + * with all fifteen roads laid has none. + */ private fun roadMoves(state: CatanState, seat: Int): List = - (0 until CatanBoard.EDGE_COUNT) - .filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) } - .map { BuildRoad(it) } + 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) } + .map { BuildRoad(it) } + } /** * Settlement placements: an open vertex at least two edges from every @@ -285,16 +292,28 @@ object CatanRules : GameRules { * own move.) */ private fun settlementVertices(state: CatanState, seat: Int): List = - (0 until CatanBoard.VERTEX_COUNT).filter { v -> - vertexOpenForSettlement(state, v) && - CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } + if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) { + emptyList() + } else { + (0 until CatanBoard.VERTEX_COUNT).filter { v -> + vertexOpenForSettlement(state, v) && + CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } + } } private fun cityVertices(state: CatanState, seat: Int): List = - (0 until CatanBoard.VERTEX_COUNT).filter { v -> - state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) + if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) { + emptyList() + } else { + (0 until CatanBoard.VERTEX_COUNT).filter { v -> + state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) + } } + /** How many settlements or cities [seat] currently has standing. */ + private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int = + state.buildings.count { it?.owner == seat && it.type == type } + private fun tradeResponses(state: CatanState, seat: Int): List { val offer = state.pendingTrade ?: return emptyList() val responses = mutableListOf() @@ -310,13 +329,25 @@ object CatanRules : GameRules { state.buildings[v] == null && CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } - /** A road on [edge] would join [seat]'s network of roads and buildings. */ + /** + * A road on [edge] would join [seat]'s network of roads and buildings. + * + * It must start from a crossing the network actually reaches. An opponent's + * settlement or city on a crossing breaks the network there: roads on either + * side of it do not join, so a road cannot be laid *past* it, even with one + * 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] - return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } || - CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } || - state.buildings[a]?.owner == seat || - state.buildings[b]?.owner == seat + return networkReaches(state, seat, a) || networkReaches(state, seat, b) + } + + /** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */ + private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean { + val building = state.buildings[vertex] + // 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 } } private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index 65fd78d..eaf93f9 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -28,7 +29,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prolibertate.games.game.catan.CatanPhase @@ -118,11 +121,18 @@ fun CatanHand( /** * One player's public standing: colour, name, points, how many cards they hold - * (not which), development cards, knights and their longest road. + * (not which), and — in the roomier layout — development cards, knights and + * their longest road. + * + * **Fixed height.** Every line is always present (blank when there is nothing to + * say) and never wraps, so the card is the same height whatever the game is + * doing. The board is given whatever room is left, so anything that made this + * card taller or shorter would rescale the island under the player's thumb. * * [points] is passed in rather than computed here because the local player's * own count includes hidden victory-point cards and everyone else's does not; - * the screen decides which. + * the screen decides which. [compact] is the narrow portrait strip (four cards + * across a phone); the default has room for the detail lines. */ @Composable fun CatanPlayerCard( @@ -132,6 +142,7 @@ fun CatanPlayerCard( points: Int, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current, + compact: Boolean = false, ) { val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER val shape = RoundedCornerShape(12.dp) @@ -155,41 +166,56 @@ fun CatanPlayerCard( .background(style.seats[seat % style.seats.size]) .border(1.dp, style.pieceOutline, CircleShape), ) - Text( - text = seats.label(seat), - fontWeight = FontWeight.Bold, - maxLines = 1, - style = MaterialTheme.typography.labelLarge, + CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold) + } + CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold) + if (compact) { + CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall) + } else { + CardLine( + "${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev", + MaterialTheme.typography.bodySmall, + ) + CardLine( + "Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}", + MaterialTheme.typography.bodySmall, ) } - Text( - text = "$points VP", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = "${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev", - style = MaterialTheme.typography.bodySmall, - ) - Text( - text = "Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}", - style = MaterialTheme.typography.bodySmall, - ) val awards = buildList { - if (state.longestRoadOwner == seat) add("Longest road") - if (state.largestArmyOwner == seat) add("Largest army") - } - if (awards.isNotEmpty()) { - Text( - text = awards.joinToString(" · "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) + if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road") + if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army") } + // Always drawn, so the card does not grow when somebody takes an award. + CardLine( + text = awards.joinToString(" · ").ifEmpty { BLANK }, + style = MaterialTheme.typography.labelSmall, + weight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) } } +/** A non-breaking space: a line of text with nothing in it, still one line tall. */ +private const val BLANK = "\u00A0" + +/** One line of a player card: never wraps, so the card's height cannot change. */ +@Composable +private fun CardLine( + text: String, + style: TextStyle, + weight: FontWeight? = null, + color: Color = Color.Unspecified, +) { + Text( + text = text, + style = style, + fontWeight = weight, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + // --------------------------------------------------------------------------- // Dice // --------------------------------------------------------------------------- @@ -221,13 +247,12 @@ fun CatanDice(dice: List, modifier: Modifier = Modifier) { ) } } - if (dice.size == 2) { - Text( - text = "= ${dice.sum()}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } + // Always drawn ("= –" before the first roll) so the row never changes width. + Text( + text = if (dice.size == 2) "= ${dice.sum()}" else "= –", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) } } @@ -238,7 +263,6 @@ fun CatanDice(dice: List, modifier: Modifier = Modifier) { /** What the action bar offers right now. Built by the screen from [CatanInteraction]. */ data class CatanActions( val canRoll: Boolean = false, - val showTurnActions: Boolean = false, val canBuild: Set = emptySet(), val building: BuildKind? = null, val canBuyCard: Boolean = false, @@ -250,7 +274,16 @@ data class CatanActions( val canRepickSetup: Boolean = false, ) -/** The buttons for whatever the player can do now. Pure display: every press is a lambda. */ +/** + * The buttons for whatever the player can do now. Pure display: every press is a lambda. + * + * **The bar never changes size.** The top row holds the one "primary" action + * (roll, end turn, done with roads, cancel) and keeps its height when it is + * empty; the second row is always all six buttons, greyed out when they do not + * apply. Buttons appearing and disappearing used to resize this area and, with + * it, the board above — so nothing here may be conditionally *present*, only + * conditionally *enabled*. + */ @OptIn(ExperimentalLayoutApi::class) @Composable fun CatanActionBar( @@ -265,33 +298,42 @@ fun CatanActionBar( onRepickSetup: () -> Unit, modifier: Modifier = Modifier, ) { - FlowRow( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) } - - if (actions.canRepickSetup) { - OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) } + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) } + if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) } + if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) } + if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } } - if (actions.showTurnActions) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) } - OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { Text(CatanText.PLAY_CARD) } - OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { Text(CatanText.TRADE) } + OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { + LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST) + } + OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { + LabelWithCost(CatanText.TRADE, CatanText.NO_COST) + } } - - if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) } - if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } } } +/** Room for one button, held whether or not there is one to show. */ +private val PRIMARY_ROW_HEIGHT = 48.dp + @Composable private fun BuildButton( kind: BuildKind, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt index 898a03f..5df15d1 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt @@ -112,14 +112,13 @@ private fun PanelsPreview() { Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { (0 until state.playerCount).forEach { seat -> - CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f)) + CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f), compact = true) } } CatanHand(state, seat = 0) CatanDice(state.lastDice) CatanActionBar( actions = CatanActions( - showTurnActions = true, canBuild = setOf(BuildKind.ROAD, BuildKind.CITY), building = BuildKind.ROAD, canBuyCard = true, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 7c58230..3e97876 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.prolibertate.games.game.catan.AcceptTrade import org.prolibertate.games.game.catan.BuyDevCard @@ -130,7 +132,6 @@ fun CatanScreen( val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val actions = CatanActions( canRoll = CatanInteraction.canRoll(legal), - showTurnActions = playing, canBuild = CatanInteraction.canBuild(legal), building = building, canBuyCard = CatanInteraction.canBuyDevCard(legal), @@ -275,6 +276,7 @@ private fun PlayerCards( points = pointsOf(seat), modifier = Modifier.weight(1f), style = style, + compact = !twoPerRow, ) } // Keep an odd last row's card the same width as the others. @@ -284,6 +286,14 @@ private fun PlayerCards( } } +/** + * The one line telling the player what is going on, and the dice. + * + * **Fixed height.** The text always occupies exactly two lines (padded when it is + * shorter, clipped when it is longer) and a refused move replaces the prompt + * rather than adding to it, so this row is the same height whatever is said and + * the board below it never moves. + */ @Composable private fun PromptRow(prompt: String, notice: String?, state: CatanState) { Row( @@ -291,16 +301,15 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text(prompt, style = MaterialTheme.typography.bodyMedium) - if (notice != null) { - Text( - notice, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } + Text( + text = notice ?: prompt, + style = MaterialTheme.typography.bodyMedium, + color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) CatanDice(state.lastDice) } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt index bd40f76..2b83a41 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -103,6 +103,12 @@ object CatanText { const val CONFIRM = "Confirm" const val STOP_ROADS = "Done with roads" const val BUILD = "Build" + + /** + * The empty second line of a button that has no cost, so every button in the + * action bar is the same height. A non-breaking space: blank, but a line. + */ + const val NO_COST = "\u00A0" const val YOUR_HAND = "Your hand" const val CARDS = "cards" const val ACCEPT = "Accept" diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt index 71c2eb4..5cf8bc6 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt @@ -340,6 +340,89 @@ class CatanRulesTest { ) } + /** Seat 0 owns a settlement at vertex 0 and a two-road chain v0 - w - x; `beyond` continues past x. */ + private data class Chain(val state: CatanState, val x: Int, val beyond: Int, val spur: Int?) + + private fun chain(): Chain { + val e1 = CatanBoard.VERTEX_EDGES[0][0] + val w = CatanBoard.otherEnd(e1, 0) + val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 } + val x = CatanBoard.otherEnd(e2, w) + val beyond = CatanBoard.VERTEX_EDGES[x].first { it != e2 } + val spur = CatanBoard.VERTEX_EDGES[w].firstOrNull { it != e1 && it != e2 } + val state = craft() + .copy(resources = hands(hand(Resource.LUMBER to 5, Resource.BRICK to 5))) + .withBuilding(0, BuildingType.SETTLEMENT, 0) + .withRoad(e1, 0) + .withRoad(e2, 0) + return Chain(state, x, beyond, spur) + } + + @Test + fun `a road cannot be built past an opponent's settlement`() { + val (open, x, beyond, spur) = chain() + assertTrue("open crossing: the road continues", BuildRoad(beyond) in CatanRules.legalMoves(open, 0)) + + val blocked = open.withBuilding(x, BuildingType.SETTLEMENT, 1) + assertFalse( + "an opponent's settlement on the crossing breaks the network", + BuildRoad(beyond) in CatanRules.legalMoves(blocked, 0), + ) + // The same through a city, and while laying free roads from Road Building. + val city = open.withBuilding(x, BuildingType.CITY, 1) + assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(city, 0)) + val free = blocked.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2) + assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(free, 0)) + // The rest of the network is untouched: the spur at the other crossing still works. + if (spur != null) assertTrue(BuildRoad(spur) in CatanRules.legalMoves(blocked, 0)) + } + + @Test + fun `your own settlement on the crossing does not block your road`() { + val (open, x, beyond, _) = chain() + val own = open.withBuilding(x, BuildingType.SETTLEMENT, 0) + assertTrue(BuildRoad(beyond) in CatanRules.legalMoves(own, 0)) + } + + @Test + fun `a player runs out of pieces`() { + val rich = hands( + hand( + Resource.LUMBER to 9, Resource.BRICK to 9, Resource.WOOL to 9, + Resource.GRAIN to 9, Resource.ORE to 9, + ), + ) + + // Roads: fifteen, and no sixteenth - in the main phase or from Road Building. + var roads = craft().copy(resources = rich).withBuilding(0, BuildingType.SETTLEMENT, 0) + repeat(14) { + roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance().first().edge, 0) + } + assertTrue("fourteen laid: one more is allowed", CatanRules.legalMoves(roads, 0).any { it is BuildRoad }) + roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance().first().edge, 0) + assertEquals(15, roads.roads.count { it == 0 }) + assertTrue("fifteen laid: none left", CatanRules.legalMoves(roads, 0).none { it is BuildRoad }) + val free = roads.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2) + assertEquals(listOf(EndFreeRoads), CatanRules.legalMoves(free, 0)) + + // Settlements: five. + val homes = scattered(7) + var settled = craft().copy(resources = rich) + for (i in 0 until 4) settled = settled.withBuilding(homes[i], BuildingType.SETTLEMENT, 0) + settled = settled.withRoad(CatanBoard.VERTEX_EDGES[homes[5]][0], 0) + assertTrue("four standing: a fifth is allowed", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0)) + settled = settled.withBuilding(homes[4], BuildingType.SETTLEMENT, 0) + assertFalse("five standing: no sixth", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0)) + + // Cities: four. A raised settlement returns to the supply, so five settlements stays possible. + var raised = craft().copy(resources = rich) + for (i in 0 until 3) raised = raised.withBuilding(homes[i], BuildingType.CITY, 0) + raised = raised.withBuilding(homes[3], BuildingType.SETTLEMENT, 0) + assertTrue("three cities: a fourth is allowed", BuildCity(homes[3]) in CatanRules.legalMoves(raised, 0)) + raised = raised.withBuilding(homes[3], BuildingType.CITY, 0).withBuilding(homes[4], BuildingType.SETTLEMENT, 0) + assertFalse("four cities: no fifth", BuildCity(homes[4]) in CatanRules.legalMoves(raised, 0)) + } + @Test fun `a settlement needs an open vertex on the network, a city one of yours`() { val base = craft() diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index 429c117..c90c0e5 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -159,7 +159,19 @@ its edge. A settlement goes on an **empty vertex** at least two edges from every standing building and connected to the seat's own road network. A city upgrades one of the seat's own settlements. A road goes on an empty edge that connects to the -seat's network (a road or a building at one of its ends). +seat's network: it must start from a crossing where the seat has a building, or +where one of its roads ends. + +**An opponent's settlement or city on a crossing breaks the network there.** A +road cannot be laid past it, even from a crossing where one of your own roads +ends — only a crossing you own a building on is a way through. The same rule +applies to the free roads of a Road Building card, and it is the same wall that +cuts Longest Road (below). + +**Pieces are limited**: each player has **15 roads, 5 settlements and 4 cities**. +A player who has laid them all cannot build another of that kind. Raising a +settlement into a city returns the settlement to the supply, so the five +settlements remain available to be laid again. The development deck is the published 25 cards: **14 Knights, 2 Road Building, 2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the diff --git a/docs/UI-catan.md b/docs/UI-catan.md index f9ee225..6582b20 100644 --- a/docs/UI-catan.md +++ b/docs/UI-catan.md @@ -71,6 +71,14 @@ screen's UI state. The legal-move list stays the gate. size of the deck; it is not known to a client. Card *counts* (`resourceCount`, `devHandCounts`) are public. 5. **The state is immutable.** Never mutate it; submit a move. +6. **The layout must not move.** Everything outside the board has a *fixed + height*, and the board takes whatever is left, so the island's scale is the + same all game. Concretely: a control is *enabled or disabled*, never + *present or absent* (the action bar always shows every button); text that can + vary in length is pinned to a fixed number of lines (`minLines = maxLines`); + a line that is sometimes empty is still drawn (a non-breaking space), as the + award line on a player card is. A change that makes any non-board element + taller or shorter at runtime will rescale the board under the player's thumb. ## Interaction model (what the player actually does) -- 2.47.3 From b90058afdaeabaa42b57e2f69db45da59e6483e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:40:48 +0000 Subject: [PATCH 17/19] Katan: highlight rolled hexes, one-button Build menu on small screens, clearer harbours - Hexes that paid out for the turn's roll are lit (not on a 7, not the robber's hex, not before the roll). - Compact layouts fold Road/Settlement/City into one Build button with a menu. - Harbour markers are drawn with a resource pictogram (? for 3:1), larger, and sit a full hex radius off the coast so the piers show which vertices they serve. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X --- .../games/ui/game/catan/CatanBoardView.kt | 111 +++++++++++++++++- .../games/ui/game/catan/CatanGeometry.kt | 6 +- .../games/ui/game/catan/CatanInteraction.kt | 19 +++ .../games/ui/game/catan/CatanPanels.kt | 64 +++++++++- .../games/ui/game/catan/CatanScreen.kt | 5 + .../games/ui/game/catan/CatanStyle.kt | 7 +- .../games/ui/game/catan/CatanGeometryTest.kt | 2 +- .../ui/game/catan/CatanInteractionTest.kt | 23 ++++ 8 files changed, 226 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt index c4cfbfa..69df57b 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap @@ -30,6 +31,7 @@ import org.prolibertate.games.game.catan.BuildingType 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 /** * The island, drawn and tappable. @@ -54,6 +56,8 @@ fun CatanBoardView( onHexTap: (Int) -> Unit, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current, + /** Hexes to light up as having produced on the latest roll. */ + rolledHexes: Set = emptySet(), ) { val measurer = rememberTextMeasurer() var size by remember { mutableStateOf(IntSize.Zero) } @@ -99,6 +103,7 @@ fun CatanBoardView( drawRect(color = style.sea) drawPorts(state, board, style, measurer) drawHexes(state, board, style, measurer) + drawRolled(rolledHexes, board, style) drawRobber(state, board, style) drawRoads(state, board, style) drawEdgeTargets(targets, board, style) @@ -130,6 +135,17 @@ private fun DrawScope.drawHexes( } } +/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */ +private fun DrawScope.drawRolled(hexes: Set, board: BoardLayout, style: CatanStyle) { + for (hex in hexes) { + val centre = board.hexCenter(hex) + val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) }) + drawPath(path, color = style.rolled.copy(alpha = 0.28f)) + drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f)) + drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f)) + } +} + private fun DrawScope.drawToken( number: Int, at: Pt, @@ -206,7 +222,7 @@ private fun DrawScope.drawPorts( val anchor = board.portAnchor(port.edge) val (a, b) = board.edgeEnds(port.edge) val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk - // Two short piers from the marker to the vertices that can use the harbour. + // Two piers from the marker to the vertices that can use the harbour. for (end in listOf(a, b)) { drawLine( color = style.portMarker.copy(alpha = 0.9f), @@ -224,10 +240,26 @@ private fun DrawScope.drawPorts( center = anchor.offset(), style = Stroke(width = board.radius * 0.07f), ) + // What the harbour trades: a picture of the resource, or "?" for any. + val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f) + val iconSize = radius * 0.5f + val resource = port.resource + if (resource != null) { + drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline) + } else { + drawCentered( + measurer = measurer, + text = "?", + at = iconAt, + fontPx = board.radius * style.portText * 1.5f, + color = style.portInk, + weight = FontWeight.ExtraBold, + ) + } drawCentered( measurer = measurer, text = "${port.ratio}:1", - at = anchor, + at = Pt(anchor.x, anchor.y + radius * 0.5f), fontPx = board.radius * style.portText, color = style.portInk, weight = FontWeight.Bold, @@ -235,6 +267,81 @@ private fun DrawScope.drawPorts( } } +/** + * A small pictogram of [resource] centred on [at], about 2·[s] across: a tree, + * bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no + * image assets to ship or scale. + */ +private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) { + val outline = Stroke(width = s * 0.12f) + when (resource) { + Resource.LUMBER -> { + val crown = Path().apply { + moveTo(at.x, at.y - s) + lineTo(at.x + s * 0.8f, at.y + s * 0.3f) + lineTo(at.x - s * 0.8f, at.y + s * 0.3f) + close() + } + drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f)) + drawPath(crown, color = fill) + drawPath(crown, color = line, style = outline) + } + + Resource.BRICK -> { + val w = s * 0.9f + val h = s * 0.42f + for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) { + val top = at.y - h + row * h + val left = at.x - w + shift - w * 0.25f + for (col in 0..1) { + val x = left + col * w + drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h)) + drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline) + } + } + } + + Resource.WOOL -> { + for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) { + drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s)) + } + drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f)) + drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f)) + drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f)) + } + + Resource.GRAIN -> { + drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f) + for (i in 0..2) { + val y = at.y - s * 0.7f + i * s * 0.5f + for (side in listOf(-1f, 1f)) { + val kernel = Path().apply { + moveTo(at.x, y + s * 0.3f) + lineTo(at.x + side * s * 0.6f, y - s * 0.05f) + lineTo(at.x + side * s * 0.15f, y - s * 0.2f) + close() + } + drawPath(kernel, color = fill) + drawPath(kernel, color = line, style = Stroke(width = s * 0.08f)) + } + } + } + + Resource.ORE -> { + val peak = Path().apply { + moveTo(at.x - s, at.y + s * 0.8f) + lineTo(at.x - s * 0.3f, at.y - s * 0.6f) + lineTo(at.x + s * 0.1f, at.y + s * 0.05f) + lineTo(at.x + s * 0.45f, at.y - s * 0.3f) + lineTo(at.x + s, at.y + s * 0.8f) + close() + } + drawPath(peak, color = fill) + drawPath(peak, color = line, style = outline) + } + } +} + // --------------------------------------------------------------------------- // Pieces // --------------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt index 3c33f01..433c456 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt @@ -102,7 +102,7 @@ object CatanGeometry { 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.15f + const val MARGIN = 1.75f private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN @@ -119,5 +119,5 @@ object CatanGeometry { private const val SQRT3 = 1.7320508f -/** How far past the coast a harbour marker sits, in hex radii. */ -private const val PORT_OFFSET = 0.62f +/** How far past the coast a harbour marker sits, in hex radii. Far enough that its piers are clearly visible. */ +internal const val PORT_OFFSET = 1.0f diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt index c7481d0..9a9ca7d 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -171,6 +171,25 @@ object CatanInteraction { fun canRoll(legal: List): Boolean = legal.any { it is RollDice } + /** + * The hexes that paid out for this turn's roll, so the board can light them. + * Empty until the turn's roll has happened (the dice left over from the last + * turn are not this turn's), on a seven, and for the hex the robber sits on. + */ + fun rolledHexes(state: CatanState): Set { + if (state.lastDice.size != 2) return emptySet() + if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST || + state.phase == CatanPhase.SETUP_SECOND + ) { + return emptySet() + } + val total = state.lastDice.sum() + if (total == 7) return emptySet() + return (0 until CatanBoard.HEX_COUNT) + .filter { state.numbers[it] == total && it != state.robber } + .toSet() + } + fun canEndTurn(legal: List): Boolean = legal.any { it is EndTurn } fun canEndFreeRoads(legal: List): Boolean = legal.any { it is EndFreeRoads } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index eaf93f9..8e7ddd0 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -19,11 +19,17 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -297,6 +303,8 @@ fun CatanActionBar( onEndFreeRoads: () -> Unit, onRepickSetup: () -> Unit, modifier: Modifier = Modifier, + /** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */ + compact: Boolean = false, ) { Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( @@ -315,9 +323,13 @@ fun CatanActionBar( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) - BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) - BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) + if (compact) { + BuildMenuButton(actions, onBuild) + } else { + BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) + BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) + BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) + } OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) } @@ -352,6 +364,52 @@ private fun BuildButton( } } +/** + * The small-screen stand-in for the three build buttons: one "Build" button + * whose menu lists Road, Settlement and City with their costs. While a build + * mode is armed the button shows which, and choosing that entry again cancels it. + */ +@Composable +private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) { + var open by remember { mutableStateOf(false) } + val armed = actions.building + val anyEnabled = actions.canBuild.isNotEmpty() || armed != null + val label = { + LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST) + } + Box { + if (armed != null) { + FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() } + } else { + OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() } + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + for (kind in BuildKind.entries) { + DropdownMenuItem( + text = { LabelWithCost(buildLabel(kind), buildCost(kind)) }, + enabled = kind in actions.canBuild, + onClick = { + open = false + onBuild(if (armed == kind) null else kind) + }, + ) + } + } + } +} + +private fun buildLabel(kind: BuildKind): String = when (kind) { + BuildKind.ROAD -> CatanText.ROAD + BuildKind.SETTLEMENT -> CatanText.SETTLEMENT + BuildKind.CITY -> CatanText.CITY +} + +private fun buildCost(kind: BuildKind): String = when (kind) { + BuildKind.ROAD -> CatanText.ROAD_COST + BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST + BuildKind.CITY -> CatanText.CITY_COST +} + @Composable private fun LabelWithCost(label: String, cost: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 3e97876..9818719 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -141,6 +141,7 @@ fun CatanScreen( canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal), canRepickSetup = setupVertex != null, ) + val rolledHexes = CatanInteraction.rolledHexes(current) val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building) val handle: (TapOutcome) -> Unit = { outcome -> @@ -161,6 +162,8 @@ fun CatanScreen( ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier -> BoxWithConstraints(modifier = modifier) { val wide = maxWidth > maxHeight * 1.1f + // Phones (either way up) get the one-button build menu. + val compactBuild = !wide || maxWidth < 600.dp val board: @Composable (Modifier) -> Unit = { boardModifier -> CatanBoardView( @@ -172,6 +175,7 @@ fun CatanScreen( onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) }, modifier = boardModifier, style = style, + rolledHexes = rolledHexes, ) } val players: @Composable (Boolean) -> Unit = { grid -> @@ -195,6 +199,7 @@ fun CatanScreen( legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit) }, onRepickSetup = { setupVertex = null }, + compact = compactBuild, ) } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt index d7c96e8..256065e 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt @@ -36,6 +36,8 @@ data class CatanStyle( val tokenHotInk: Color, /** The robber's pawn. */ val robber: Color, + /** Outline and wash on hexes that produced for the latest dice roll. */ + val rolled: Color, /** Harbour marker disc and its ink. */ val portMarker: Color, val portInk: Color, @@ -64,12 +66,12 @@ data class CatanStyle( val citySize: Float = 0.42f, val tokenRadius: Float = 0.36f, val robberSize: Float = 0.34f, - val portRadius: Float = 0.34f, + val portRadius: Float = 0.46f, val targetRadius: Float = 0.20f, /** How close a tap must land to a vertex / edge / hex centre to count. */ val tapReach: Float = 0.55f, val tokenText: Float = 0.34f, - val portText: Float = 0.26f, + val portText: Float = 0.27f, ) /** The light palette, which is also the reference for the dark one. */ @@ -88,6 +90,7 @@ private val LightStyle = CatanStyle( tokenInk = Color(0xFF2B2B2B), tokenHotInk = Color(0xFFC62828), robber = Color(0xFF263238), + rolled = Color(0xFFFFFFFF), portMarker = Color(0xFFFFF8E1), portInk = Color(0xFF2B2B2B), resource = mapOf( diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt index 69362b2..5981061 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt @@ -111,7 +111,7 @@ class CatanGeometryTest { val anchor = layout.portAnchor(e) val mid = layout.edgeMidpoint(e) assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre)) - assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * 0.62f) < 1f) + assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * PORT_OFFSET) < 1f) } } } diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt index 61fb44e..ce3eb10 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt @@ -322,4 +322,27 @@ class CatanInteractionTest { val short = pending.withHand(1, hand()) assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1))) } + + // --- Rolled-hex highlight -------------------------------------------------- + + @Test + fun `rolled hexes are those carrying the number, minus the robber's`() { + val base = playing(turn = 0) + val number = base.numbers.first { it != 0 && it != 7 } + val state = base.copy(lastDice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6)) + val all = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] == number } + assertTrue(all.isNotEmpty()) + assertEquals(all.filter { it != state.robber }.toSet(), CatanInteraction.rolledHexes(state)) + val blocked = state.copy(robber = all.first()) + assertFalse(all.first() in CatanInteraction.rolledHexes(blocked)) + } + + @Test + fun `nothing is highlighted before this turn's roll, on a seven, or in setup`() { + val rolled = playing(turn = 0).copy(lastDice = listOf(3, 3)) + assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.AWAIT_ROLL)).isEmpty()) + assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = listOf(3, 4))).isEmpty()) + assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.SETUP_FIRST)).isEmpty()) + assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = emptyList())).isEmpty()) + } } -- 2.47.3 From 5e841196704186659324384802b38791ee068b82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:11:16 +0000 Subject: [PATCH 18/19] Katan: mark build-menu label lambda @Composable Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X --- .../java/org/prolibertate/games/ui/game/catan/CatanPanels.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index 8e7ddd0..30317cd 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -374,7 +374,7 @@ private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit var open by remember { mutableStateOf(false) } val armed = actions.building val anyEnabled = actions.canBuild.isNotEmpty() || armed != null - val label = { + val label: @Composable () -> Unit = { LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST) } Box { -- 2.47.3 From d7d3c8a3ed36651d63b53762cbcb8d304b7b3147 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:24:45 +0000 Subject: [PATCH 19/19] Katan: open trade offers and counter-offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Offers can go to the whole table (asked in seat order, empty hands skipped) or one seat. - The asked seat may accept (trade happens at once), decline, or counter with new terms. - If nobody accepts and there are counters, the offerer takes one or turns them all down (new TRADE_COUNTERS phase). - AI: fixes an inverted accept test (it weighed what it gives against what it gets backwards), picks the best counter. - UI: Everyone option in the offer dialog, Counter… in the reply dialog, a counter-choice dialog. - Tests and docs updated. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X --- .../prolibertate/games/game/catan/CatanAi.kt | 33 ++++- .../games/game/catan/CatanModel.kt | 56 ++++++- .../games/game/catan/CatanRules.kt | 139 ++++++++++++++++-- .../games/ui/game/catan/CatanDialogs.kt | 113 ++++++++++++-- .../games/ui/game/catan/CatanInteraction.kt | 16 +- .../games/ui/game/catan/CatanScreen.kt | 14 +- .../games/ui/game/catan/CatanText.kt | 26 +++- .../games/game/catan/CatanAiTest.kt | 46 ++++++ .../games/game/catan/CatanRulesTest.kt | 111 ++++++++++++++ docs/RULES-catan.md | 34 +++-- docs/UI-catan.md | 5 +- 11 files changed, 532 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index c8d0a69..5dd53b3 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt @@ -38,6 +38,8 @@ class CatanAi : GameAi { CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal) + CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal) + CatanPhase.PLAYING -> choosePlaying(state, seat, legal) CatanPhase.GAME_OVER -> legal.firstOrNull() @@ -260,17 +262,32 @@ class CatanAi : GameAi { } private fun chooseResponse(state: CatanState, seat: Int, legal: List): CatanMove { - val accept = legal.filterIsInstance().firstOrNull() ?: run { - return legal.first { it is DeclineTrade } - } - val offer = state.pendingTrade ?: return legal.first() - // Accept when what arrives is worth more than what leaves: a mild - // table, not a shark. - val incoming = offer.get.sumOf { want(it) } - val outgoing = offer.give.sumOf { want(it) } + val accept = legal.filterIsInstance().firstOrNull() + val offer = state.pendingTrade + if (accept == null || offer == null) return legal.first { it is DeclineTrade } + // Accept when what arrives (the offerer's give) is worth more than what + // leaves (the offerer's get): a mild table, not a shark. + val incoming = offer.give.sumOf { want(it) } + val outgoing = offer.get.sumOf { want(it) } return if (incoming > outgoing) accept else legal.first { it is DeclineTrade } } + /** + * Picks the counter-offer that gains the most by the same valuation, or + * turns them all down. The AI never counters itself: it only answers. + */ + private fun chooseCounter(state: CatanState, legal: List): CatanMove { + val offer = state.pendingTrade ?: return legal.first() + val best = legal.filterIsInstance() + .map { choice -> choice to offer.counters.first { it.by == choice.seat } } + .maxByOrNull { (_, c) -> c.give.sumOf { want(it) } - c.get.sumOf { want(it) } } + if (best != null) { + val (choice, c) = best + if (c.give.sumOf { want(it) } > c.get.sumOf { want(it) }) return choice + } + return legal.first { it is DismissCounters } + } + // ----------------------------------------------------------------------- // Valuations // ----------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt index 721fe9c..e361253 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt @@ -74,17 +74,34 @@ enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTOR data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0) /** - * A domestic trade offer on the table. [by] is the offering seat and [to] - * the only seat that may respond — v1 has no counter-offers and no open - * auctions, so an offer is always addressed to one player. + * A counter-proposal to a [TradeOffer]: seat [by] would [give] these cards to + * the offerer if the offerer would hand over [get]. */ @Serializable -data class TradeOffer(val by: Int, val to: Int, val give: List, val get: List) +data class TradeCounter(val by: Int, val give: List, val get: List) + +/** + * A domestic trade offer on the table. [by] is the offering seat. [to] is the + * one seat it is addressed to, or null for an open offer to the whole table. + * Seats are asked one at a time, in seat order after the offerer; [asking] is + * the seat that may answer now. Each may accept (the trade happens at once), + * decline, or counter; counters are kept in [counters] and, if nobody accepts, + * the offerer chooses among them in [CatanPhase.TRADE_COUNTERS]. + */ +@Serializable +data class TradeOffer( + val by: Int, + val to: Int?, + val give: List, + val get: List, + val asking: Int = to ?: -1, + val counters: List = emptyList(), +) /** * Where the game stands. The phases that involve more than the player * whose turn it is carry their own bookkeeping: [discardSeat] during - * [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING]. + * [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING] and [CatanPhase.TRADE_COUNTERS]. */ @Serializable enum class CatanPhase { @@ -109,9 +126,12 @@ enum class CatanPhase { /** A played Road Building card: up to two roads remain to be placed. */ FREE_ROADS, - /** The offered seat must accept or decline. */ + /** The [TradeOffer.asking] seat must accept, decline or counter. */ TRADE_PENDING, + /** Nobody accepted but some seats countered: the offerer picks one or none. */ + TRADE_COUNTERS, + GAME_OVER, } @@ -278,7 +298,8 @@ data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove data class PlayYearOfPlenty(val card: Int, val take: List) : CatanMove /** - * Offer a domestic trade to [to]: [give] of ours for [get] of theirs. + * Offer a domestic trade to [to] (null: everyone, asked in turn): [give] of + * ours for [get] of theirs. * * Like a forced dice roll this is a free-form intent rather than an * enumerated move — the space of two card multisets is combinatorial — so @@ -287,7 +308,7 @@ data class PlayYearOfPlenty(val card: Int, val take: List) : CatanMove */ @Serializable @SerialName("offer") -data class OfferTrade(val to: Int, val give: List, val get: List) : CatanMove +data class OfferTrade(val to: Int?, val give: List, val get: List) : CatanMove @Serializable @SerialName("accept") @@ -297,6 +318,25 @@ data object AcceptTrade : CatanMove @SerialName("decline") data object DeclineTrade : CatanMove +/** + * Answer the offer on the table with different terms: we would [give] these + * cards if the offerer handed over [get]. Free-form like [OfferTrade], so + * validated by [CatanRules.isLegalMove] rather than listed. + */ +@Serializable +@SerialName("counter") +data class CounterTrade(val give: List, val get: List) : CatanMove + +/** The offerer takes the counter-proposal made by [seat]. */ +@Serializable +@SerialName("acceptCounter") +data class AcceptCounter(val seat: Int) : CatanMove + +/** The offerer takes none of the counter-proposals. */ +@Serializable +@SerialName("dismissCounters") +data object DismissCounters : CatanMove + /** Bank trade: [give] at the best ratio this seat can trade at for [get]. */ @Serializable @SerialName("bank") diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt index 82fdb1d..6d98c86 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt @@ -101,7 +101,8 @@ object CatanRules : GameRules { CatanPhase.SETUP_FIRST -> state.setupIndex CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex CatanPhase.DISCARDING -> state.discardSeat - CatanPhase.TRADE_PENDING -> state.pendingTrade?.to + CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 } + CatanPhase.TRADE_COUNTERS -> state.turn CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER, CatanPhase.PLAYING, CatanPhase.FREE_ROADS, -> state.turn @@ -130,6 +131,8 @@ object CatanRules : GameRules { is OfferTrade -> offerLegal(state, seat, move) + is CounterTrade -> counterLegal(state, seat, move) + else -> move in legalMoves(state, seat) } } @@ -152,6 +155,8 @@ object CatanRules : GameRules { CatanPhase.TRADE_PENDING -> tradeResponses(state, seat) + CatanPhase.TRADE_COUNTERS -> counterChoices(state) + CatanPhase.GAME_OVER -> emptyList() } } @@ -324,6 +329,29 @@ object CatanRules : GameRules { return responses } + /** What the offerer may do with the counters: take one that is still affordable, or none. */ + private fun counterChoices(state: CatanState): List { + val offer = state.pendingTrade ?: return emptyList() + val moves = mutableListOf() + for (counter in offer.counters) { + if (state.holdsAll(counter.by, counter.give) && state.holdsAll(offer.by, counter.get)) { + moves += AcceptCounter(counter.by) + } + } + moves += DismissCounters + return moves + } + + private fun counterLegal(state: CatanState, seat: Int, move: CounterTrade): Boolean { + val offer = state.pendingTrade ?: return false + if (state.phase != CatanPhase.TRADE_PENDING || offer.asking != seat) return false + if (move.give.isEmpty() || move.get.isEmpty()) return false + if (countOf(move.give) == countOf(move.get)) return false + // Taking the offer as made is Accept, not a counter. + if (countOf(move.give) == countOf(offer.get) && countOf(move.get) == countOf(offer.give)) return false + return state.holdsAll(seat, move.give) && state.holdsAll(offer.by, move.get) + } + /** An empty vertex with no building on it or one edge away. */ private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = state.buildings[v] == null && @@ -352,7 +380,7 @@ object CatanRules : GameRules { private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean { if (state.phase != CatanPhase.PLAYING || state.turn != seat) return false - if (move.to !in 0 until state.playerCount || move.to == seat) return false + if (move.to != null && (move.to !in 0 until state.playerCount || move.to == seat)) return false if (move.give.isEmpty() || move.get.isEmpty()) return false // Like-for-like is not a trade; the published game rejects it too. if (countOf(move.give) == countOf(move.get)) return false @@ -384,6 +412,9 @@ object CatanRules : GameRules { is OfferTrade -> applyOfferTrade(state, seat, move) AcceptTrade -> applyAcceptTrade(state, seat) DeclineTrade -> applyDeclineTrade(state, seat) + is CounterTrade -> applyCounterTrade(state, seat, move) + is AcceptCounter -> applyAcceptCounter(state, move) + DismissCounters -> applyDismissCounters(state) is MaritimeTrade -> applyMaritimeTrade(state, seat, move) EndTurn -> applyEndTurn(state, seat) EndFreeRoads -> applyEndFreeRoads(state, seat) @@ -633,33 +664,111 @@ object CatanRules : GameRules { ) } - private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState = - state.copy( - phase = CatanPhase.TRADE_PENDING, - pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get), + private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState { + val audience = if (move.to == null) "the table" else "seat ${move.to}" + val offered = state.copy( log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " + - "${describe(countOf(move.get))} to seat ${move.to}.", + "${describe(countOf(move.get))} to $audience.", ) + val offer = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get) + val first = nextAsker(offered, offer, seat) + ?: return offered.copy(log = offered.log + "Nobody has any cards to trade.") + return offered.copy( + phase = CatanPhase.TRADE_PENDING, + pendingTrade = offer.copy(asking = first), + ) + } - private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState { - val offer = state.pendingTrade!! - var resources = withCards(state.resources, offer.to, offer.get, -1) - resources = withCards(resources, offer.by, offer.give, -1) - resources = withCards(resources, offer.by, offer.get, 1) - resources = withCards(resources, offer.to, offer.give, 1) + /** + * The next seat to ask after [after], or null when the round is over. An + * addressed offer asks only its addressee; an open one walks the table in + * seat order, skipping seats with no cards (they could neither pay nor counter). + */ + private fun nextAsker(state: CatanState, offer: TradeOffer, after: Int): Int? { + if (offer.to != null) return if (after == offer.by) offer.to else null + var s = (after + 1) % state.playerCount + while (s != offer.by) { + if (state.resourceCount(s) > 0) return s + s = (s + 1) % state.playerCount + } + return null + } + + /** Moves the offer on to the next seat, or closes the round when everyone has answered. */ + private fun advanceTrade(state: CatanState, offer: TradeOffer, after: Int, said: String): CatanState { + val next = nextAsker(state, offer, after) + val log = state.log + said + if (next != null) { + return state.copy(pendingTrade = offer.copy(asking = next), log = log) + } + if (offer.counters.isNotEmpty()) { + return state.copy( + phase = CatanPhase.TRADE_COUNTERS, + pendingTrade = offer.copy(asking = -1), + log = log, + ) + } return state.copy( phase = CatanPhase.PLAYING, pendingTrade = null, - resources = resources, + log = log + "Nobody takes the offer.", + ) + } + + /** Seat [a] hands [aGives] to seat [b] and gets [bGives] back. */ + private fun swapCards( + resources: List>, + a: Int, + aGives: List, + b: Int, + bGives: List, + ): List> { + var r = withCards(resources, a, aGives, -1) + r = withCards(r, b, bGives, -1) + r = withCards(r, b, aGives, 1) + return withCards(r, a, bGives, 1) + } + + private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState { + val offer = state.pendingTrade!! + return state.copy( + phase = CatanPhase.PLAYING, + pendingTrade = null, + resources = swapCards(state.resources, offer.by, offer.give, seat, offer.get), log = state.log + "Seat $seat accepts — the cards cross the table.", ) } private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState = + advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.") + + private fun applyCounterTrade(state: CatanState, seat: Int, move: CounterTrade): CatanState { + val offer = state.pendingTrade!! + val counter = TradeCounter(by = seat, give = move.give, get = move.get) + return advanceTrade( + state, + offer.copy(counters = offer.counters + counter), + seat, + "Seat $seat counters: ${describe(countOf(move.give))} for ${describe(countOf(move.get))}.", + ) + } + + private fun applyAcceptCounter(state: CatanState, move: AcceptCounter): CatanState { + val offer = state.pendingTrade!! + val counter = offer.counters.first { it.by == move.seat } + return state.copy( + phase = CatanPhase.PLAYING, + pendingTrade = null, + resources = swapCards(state.resources, offer.by, counter.get, counter.by, counter.give), + log = state.log + "Seat ${offer.by} takes seat ${counter.by}'s counter — the cards cross the table.", + ) + } + + private fun applyDismissCounters(state: CatanState): CatanState = state.copy( phase = CatanPhase.PLAYING, pendingTrade = null, - log = state.log + "Seat $seat declines the offer.", + log = state.log + "Seat ${state.turn} turns the counter-offers down.", ) private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt index d475779..56868b5 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt @@ -35,6 +35,9 @@ import org.prolibertate.games.game.catan.DevCardType import org.prolibertate.games.game.catan.Discard import org.prolibertate.games.game.catan.MaritimeTrade import org.prolibertate.games.game.catan.OfferTrade +import org.prolibertate.games.game.catan.TradeCounter +import org.prolibertate.games.game.catan.CounterTrade +import org.prolibertate.games.game.catan.AcceptCounter import org.prolibertate.games.game.catan.PlayKnight import org.prolibertate.games.game.catan.PlayRoadBuilding import org.prolibertate.games.game.catan.Resource @@ -312,6 +315,7 @@ fun CatanTradeDialog( ) { var tab by remember { mutableStateOf(TradeTab.BANK) } var bankGive by remember { mutableStateOf(null) } + var everyone by remember { mutableStateOf(true) } var partner by remember { mutableStateOf(null) } var give by remember { mutableStateOf(emptyMap()) } var get by remember { mutableStateOf(emptyMap()) } @@ -371,9 +375,16 @@ fun CatanTradeDialog( } } else { Text("Offer to:", fontWeight = FontWeight.Bold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TabButton(CatanText.EVERYONE, everyone) { everyone = true; partner = null } (0 until state.playerCount).filter { it != seat }.forEach { other -> - TabButton(seats.label(other), partner == other) { partner = other } + TabButton(seats.label(other), !everyone && partner == other) { + everyone = false + partner = other + } } } Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold) @@ -389,13 +400,13 @@ fun CatanTradeDialog( }, confirmButton = { if (tab == TradeTab.PLAYER) { - val to = partner + val to = if (everyone) null else partner val giveCards = give.asCards() val getCards = get.asCards() - val allowed = to != null && + val allowed = (everyone || partner != null) && CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards) Button( - onClick = { if (to != null) onOffer(OfferTrade(to, giveCards, getCards)) }, + onClick = { onOffer(OfferTrade(to, giveCards, getCards)) }, enabled = allowed, ) { Text(CatanText.SEND_OFFER) } } @@ -435,29 +446,57 @@ private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) { } } -/** Another player has offered the local player a trade. */ +/** + * Another player has put an offer to the local player (alone, or as one of the + * table). Accept, decline, or — via a second step — answer with different terms. + */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun CatanTradeResponseDialog( + state: CatanState, + seat: Int, offer: TradeOffer, seats: CatanSeats, canAccept: Boolean, onAccept: () -> Unit, onDecline: () -> Unit, + onCounter: (CounterTrade) -> Unit, ) { + var countering by remember(offer) { mutableStateOf(false) } + // What we would give the offerer, and what we would ask of them. + var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) } + var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) } + AlertDialog( onDismissRequest = {}, - title = { Text("Trade offer") }, + title = { Text(if (countering) "Counter-offer" else "Trade offer") }, text = { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column( + modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( CatanText.tradeSummary( by = seats.label(offer.by), - to = "you", + to = if (offer.to == null) "the table" else "you", give = offer.give, get = offer.get, ), ) - if (!canAccept) { + if (countering) { + Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold) + CounterRow( + counts = give, + limit = { state.resources[seat][it] ?: 0 }, + onChange = { give = it }, + ) + Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold) + CounterRow( + counts = get, + limit = { state.resources[offer.by][it] ?: 0 }, + onChange = { get = it }, + ) + } else if (!canAccept) { Text( "You cannot accept this: one side no longer holds the cards.", style = MaterialTheme.typography.bodySmall, @@ -465,8 +504,58 @@ fun CatanTradeResponseDialog( } } }, - confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } }, - dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } }, + confirmButton = { + if (countering) { + val move = CounterTrade(give.asCards(), get.asCards()) + Button( + onClick = { onCounter(move) }, + enabled = CatanInteraction.counterAllowed(state, seat, move.give, move.get), + ) { Text(CatanText.SEND_COUNTER) } + } else { + Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (countering) { + TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) } + } else { + TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) } + } + TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } + } + }, + ) +} + +/** The offerer's choice once nobody accepted: take a counter-offer, or none. */ +@Composable +fun CatanCounterChoiceDialog( + counters: List, + seats: CatanSeats, + onTake: (AcceptCounter) -> Unit, + onNoDeal: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text("Counter-offers") }, + text = { + Column( + modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (counters.isEmpty()) { + Text("None of the counter-offers can be met any more.") + } + counters.forEach { counter -> + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(CatanText.counterSummary(seats.label(counter.by), counter.give, counter.get)) + Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) } + } + } + } + }, + confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt index 9a9ca7d..153a736 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -1,5 +1,6 @@ package org.prolibertate.games.ui.game.catan +import org.prolibertate.games.game.catan.AcceptCounter import org.prolibertate.games.game.catan.AcceptTrade import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildRoad @@ -10,6 +11,7 @@ import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.CounterTrade import org.prolibertate.games.game.catan.DeclineTrade import org.prolibertate.games.game.catan.DevCard import org.prolibertate.games.game.catan.DevCardType @@ -20,6 +22,7 @@ import org.prolibertate.games.game.catan.HexCoord import org.prolibertate.games.game.catan.MaritimeTrade import org.prolibertate.games.game.catan.MoveRobber import org.prolibertate.games.game.catan.OfferTrade +import org.prolibertate.games.game.catan.TradeCounter import org.prolibertate.games.game.catan.PlayKnight import org.prolibertate.games.game.catan.PlayMonopoly import org.prolibertate.games.game.catan.PlayRoadBuilding @@ -284,9 +287,20 @@ object CatanInteraction { fun offerAllowed( state: CatanState, seat: Int, - to: Int, + to: Int?, give: List, get: List, ): Boolean = give.isNotEmpty() && get.isNotEmpty() && CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get)) + + /** Whether [seat] may answer the offer on the table with [give] for [get]. */ + fun counterAllowed(state: CatanState, seat: Int, give: List, get: List): Boolean = + give.isNotEmpty() && get.isNotEmpty() && + CatanRules.isLegalMove(state, seat, CounterTrade(give, get)) + + /** The counter-offers the offerer may take right now, in the order they came in. */ + fun counterChoices(state: CatanState, legal: List): List { + val takeable = legal.filterIsInstance().map { it.seat }.toSet() + return state.pendingTrade?.counters.orEmpty().filter { it.by in takeable } + } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 9818719..12a1af2 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -34,6 +34,7 @@ import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.DeclineTrade +import org.prolibertate.games.game.catan.DismissCounters import org.prolibertate.games.game.catan.EndFreeRoads import org.prolibertate.games.game.catan.EndTurn import org.prolibertate.games.game.catan.MoveRobber @@ -363,13 +364,24 @@ private fun CatanDialogHost( onSubmit = onSubmit, ) - state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat -> + state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat -> CatanTradeResponseDialog( + state = state, + seat = localSeat, offer = offer, seats = seats, canAccept = CatanInteraction.tradeResponses(legal).first, onAccept = { onSubmit(AcceptTrade) }, onDecline = { onSubmit(DeclineTrade) }, + onCounter = onSubmit, + ) + + state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() -> + CatanCounterChoiceDialog( + counters = CatanInteraction.counterChoices(state, legal), + seats = seats, + onTake = onSubmit, + onNoDeal = { onSubmit(DismissCounters) }, ) victimChoice != null -> diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt index 2b83a41..0c523af 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -103,6 +103,10 @@ object CatanText { const val CONFIRM = "Confirm" const val STOP_ROADS = "Done with roads" const val BUILD = "Build" + const val COUNTER = "Counter…" + const val SEND_COUNTER = "Send counter" + const val EVERYONE = "Everyone" + const val NO_DEAL = "No deal" /** * The empty second line of a button that has no cost, so every button in the @@ -194,11 +198,23 @@ object CatanText { val offer = state.pendingTrade when { offer == null -> "Waiting for a reply…" - offer.to == me -> "${seats.label(offer.by)} offers you a trade." - offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…" - else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade." + offer.asking == me -> if (offer.to == null) { + "${seats.label(offer.by)} offers the table a trade. Accept, decline or counter." + } else { + "${seats.label(offer.by)} offers you a trade. Accept, decline or counter." + } + + offer.by == me -> "Waiting for ${seats.label(offer.asking)} to answer your offer…" + else -> "${seats.label(offer.by)}'s offer is with ${seats.label(offer.asking)}." } } + + CatanPhase.TRADE_COUNTERS -> + if (mine) { + "Nobody accepted, but there are counter-offers. Take one or turn them down." + } else { + "$turn is weighing counter-offers…" + } } } @@ -212,6 +228,10 @@ object CatanText { fun tradeSummary(by: String, to: String, give: List, get: List): String = "$by offers ${cards(give)} to $to for ${cards(get)}" + /** One counter-offer as the offerer sees it: what that seat gives and wants. */ + fun counterSummary(by: String, give: List, get: List): String = + "$by gives ${cards(give)} for your ${cards(get)}" + fun winnerHeadline(state: CatanState, seats: CatanSeats): String { val winner = state.winner ?: return "Game over" return if (winner == seats.me) "You won!" else "${seats.label(winner)} won" diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt index 96a0a25..f1bf43f 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt @@ -142,4 +142,50 @@ class CatanAiTest { assertEquals(CatanPhase.PLAYING, after.phase) assertNull(after.pendingTrade) } + + private fun tradeTable(offer: TradeOffer, vararg hands: Map): CatanState = + CatanRules.initialState(config(CatanOptions(), seed = 7L)).copy( + phase = CatanPhase.TRADE_PENDING, + turn = 0, + turnNumber = 1, + pendingTrade = offer, + resources = hands.toList(), + ) + + @Test + fun `the AI accepts an offer that gives it more than it costs, and declines the reverse`() { + val generous = tradeTable( + TradeOffer(by = 0, to = null, give = listOf(Resource.ORE, Resource.ORE), get = listOf(Resource.WOOL), asking = 1), + hand(Resource.ORE to 2), hand(Resource.WOOL to 2), hand(), + ) + assertEquals(AcceptTrade, CatanAi().chooseMove(generous, 1, CatanRules.legalMoves(generous, 1))) + + val stingy = tradeTable( + TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = 1), + hand(Resource.WOOL to 1), hand(Resource.ORE to 2), hand(), + ) + assertEquals(DeclineTrade, CatanAi().chooseMove(stingy, 1, CatanRules.legalMoves(stingy, 1))) + } + + @Test + fun `an AI offerer takes the best counter and refuses a losing one`() { + val counters = listOf( + TradeCounter(by = 1, give = listOf(Resource.ORE), get = listOf(Resource.LUMBER)), + TradeCounter(by = 2, give = listOf(Resource.ORE, Resource.GRAIN), get = listOf(Resource.WOOL)), + ) + val state = tradeTable( + TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = -1, counters = counters), + hand(Resource.LUMBER to 1, Resource.WOOL to 1), hand(Resource.ORE to 1), hand(Resource.ORE to 1, Resource.GRAIN to 1), + ).copy(phase = CatanPhase.TRADE_COUNTERS) + val legal = CatanRules.legalMoves(state, 0) + assertEquals(AcceptCounter(2), CatanAi().chooseMove(state, 0, legal)) + + val bad = state.copy( + pendingTrade = state.pendingTrade!!.copy( + counters = listOf(TradeCounter(by = 1, give = listOf(Resource.WOOL), get = listOf(Resource.LUMBER))), + ), + resources = listOf(hand(Resource.LUMBER to 1), hand(Resource.WOOL to 1), hand()), + ) + assertEquals(DismissCounters, CatanAi().chooseMove(bad, 0, CatanRules.legalMoves(bad, 0))) + } } diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt index 5cf8bc6..f31b86f 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt @@ -520,6 +520,113 @@ class CatanRulesTest { assertEquals(listOf(DeclineTrade), CatanRules.legalMoves(offered, 1)) } + private val twoLumberForBrick = + OfferTrade(to = null, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)) + + private fun openTable() = craft().copy( + resources = hands( + hand(Resource.LUMBER to 3), + hand(Resource.BRICK to 2), + hand(Resource.BRICK to 1, Resource.WOOL to 1), + ), + ) + + @Test + fun `an open offer asks each other seat in turn until one accepts`() { + val state = openTable() + assertTrue(CatanRules.isLegalMove(state, 0, twoLumberForBrick)) + val asked1 = CatanRules.applyMove(state, 0, twoLumberForBrick) + assertEquals(CatanPhase.TRADE_PENDING, asked1.phase) + assertEquals(1, CatanRules.currentSeat(asked1)) + + val asked2 = CatanRules.applyMove(asked1, 1, DeclineTrade) + assertEquals("a decline passes the offer on", 2, CatanRules.currentSeat(asked2)) + assertEquals(CatanPhase.TRADE_PENDING, asked2.phase) + + val done = CatanRules.applyMove(asked2, 2, AcceptTrade) + assertEquals(CatanPhase.PLAYING, done.phase) + assertNull(done.pendingTrade) + assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), done.resources[0]) + assertEquals(hand(Resource.LUMBER to 2, Resource.WOOL to 1), done.resources[2]) + assertEquals("seat 1 never took part", state.resources[1], done.resources[1]) + } + + @Test + fun `an open offer nobody wants lapses back to the offerer`() { + var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick) + table = CatanRules.applyMove(table, 1, DeclineTrade) + table = CatanRules.applyMove(table, 2, DeclineTrade) + assertEquals(CatanPhase.PLAYING, table.phase) + assertNull(table.pendingTrade) + assertEquals(0, CatanRules.currentSeat(table)) + } + + @Test + fun `seats with no cards are not asked`() { + val state = openTable().copy(resources = hands(hand(Resource.LUMBER to 3), hand(), hand(Resource.BRICK to 1))) + val table = CatanRules.applyMove(state, 0, twoLumberForBrick) + assertEquals(2, CatanRules.currentSeat(table)) + } + + @Test + fun `a counter is kept and the offerer chooses once everyone has answered`() { + var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick) + // Seat 1 will not give a brick for two lumber but would for three. + val counter = CounterTrade(give = listOf(Resource.BRICK), get = listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER)) + assertTrue(CatanRules.isLegalMove(table, 1, counter)) + table = CatanRules.applyMove(table, 1, counter) + assertEquals(2, CatanRules.currentSeat(table)) + table = CatanRules.applyMove(table, 2, DeclineTrade) + + assertEquals(CatanPhase.TRADE_COUNTERS, table.phase) + assertEquals("the offerer chooses", 0, CatanRules.currentSeat(table)) + assertEquals( + listOf(AcceptCounter(1), DismissCounters), + CatanRules.legalMoves(table, 0), + ) + + val taken = CatanRules.applyMove(table, 0, AcceptCounter(1)) + assertEquals(CatanPhase.PLAYING, taken.phase) + assertNull(taken.pendingTrade) + assertEquals(hand(Resource.BRICK to 1), taken.resources[0]) + assertEquals(hand(Resource.LUMBER to 3, Resource.BRICK to 1), taken.resources[1]) + } + + @Test + fun `counters can all be turned down`() { + var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick) + table = CatanRules.applyMove( + table, 1, + CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER)), + ) + table = CatanRules.applyMove(table, 2, DeclineTrade) + val after = CatanRules.applyMove(table, 0, DismissCounters) + assertEquals(CatanPhase.PLAYING, after.phase) + assertNull(after.pendingTrade) + assertEquals(openTable().resources, after.resources) + } + + @Test + fun `a counter must differ from the offer and be affordable`() { + val table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick) + assertFalse( + "the offer as made is an accept, not a counter", + CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER))), + ) + assertFalse( + "cannot promise cards not held", + CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.ORE), listOf(Resource.LUMBER))), + ) + assertFalse( + "cannot ask for cards the offerer lacks", + CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.ORE))), + ) + assertFalse( + "only the seat being asked may counter", + CatanRules.isLegalMove(table, 2, CounterTrade(listOf(Resource.WOOL), listOf(Resource.LUMBER))), + ) + } + @Test fun `the bank trades four for one and harbours do better`() { val base = craft() @@ -912,6 +1019,10 @@ class CatanRulesTest { OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)), AcceptTrade, DeclineTrade, + OfferTrade(null, listOf(Resource.LUMBER), listOf(Resource.ORE)), + CounterTrade(listOf(Resource.WOOL), listOf(Resource.GRAIN, Resource.GRAIN)), + AcceptCounter(2), + DismissCounters, MaritimeTrade(Resource.WOOL, Resource.GRAIN), EndTurn, EndFreeRoads, diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index c90c0e5..b46c415 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -122,17 +122,32 @@ is the same code path. ### Domestic -An offer names **one** addressee and two card multisets: what the offerer gives, -what they want. There are no counter-offers and no open auctions in v1. +An offer is addressed to **one** seat or, with `to = null`, to **everyone**, and +names two card multisets: what the offerer gives, what they want. + +Seats are asked **one at a time**, in seat order after the offerer (seats holding +no cards are skipped; an addressed offer asks only its addressee). The seat being +asked may: + +- **accept** — the trade happens at once and the offer closes (first accept wins); +- **decline** — the offer moves on to the next seat; +- **counter** (`CounterTrade`) — propose different terms: what they would give and + what they want from the offerer. A counter must differ from the offer as made + (that is an accept), and both sides must hold the cards. The offer moves on. + +When every seat has answered and nobody accepted: with no counters the offer +simply lapses; with counters the phase becomes `TRADE_COUNTERS` and the offerer +either **takes one** (`AcceptCounter(seat)`, still checked for affordability) or +**turns them all down** (`DismissCounters`). An offer is legal only when the offerer's turn is in PLAYING, the addressee is another seat, both legs are non-empty, the two legs are not the same multiset (`countOf(give) != countOf(get)` — like-for-like is not a trade), and the offerer actually holds the cards being offered. -The addressee may **accept** only if they hold what was asked and the offerer -still holds what was promised; otherwise only **decline** is offered. A decline -clears the table and leaves every card where it was. +A seat may **accept** only if it holds what was asked and the offerer still holds +what was promised; otherwise only decline (or counter) is offered. A refusal +leaves every card where it was. Offers are free-form intents rather than enumerated moves (the space of card multisets is combinatorial), so they are validated structurally by @@ -292,15 +307,12 @@ timing rules; Longest Road and Largest Army worth five; victory at ten. - **Year of Plenty two-of-a-kind needs the bank to hold both cards**, not one. - **The robber must move.** Some printings allow staying put; this engine always requires a different hex. -- **Offers are one-to-one, no auctions or counter-offers** in v1. +- **Offers may be open to the whole table** and can be countered; the first accept wins (see Trading). ## Known gaps -- **No domestic auction or counter-offer.** An offer is addressed to a single - seat. Open trading to the whole table, and counter-offers, are the obvious next - step and are not implemented. -- **No player-to-player trade over the wire beyond accept/decline.** The engine - supports it; whether the UI surfaces it is the screen task's concern. +- **No timer or cancel on offers.** An offer waits for each asked seat in turn; the offerer cannot withdraw it mid-round, and a player who never answers stalls the table. +- **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them. - **No trading away a development card.** Not part of the base game. - **The screen is built, not yet verified on two devices.** The Katan screen and setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a diff --git a/docs/UI-catan.md b/docs/UI-catan.md index 6582b20..809e317 100644 --- a/docs/UI-catan.md +++ b/docs/UI-catan.md @@ -89,7 +89,8 @@ screen's UI state. The legal-move list stays the gate. | Seven | **Discard** dialog (forced); then taps a hex to move the robber; a victim dialog appears only if more than one player can be robbed | | Main turn | arms **Road / Settlement / City** and taps a highlighted spot; **Buy card**; **Play card**; **Trade**; **End turn** | | Road Building | taps highlighted edges, or **Done with roads** | -| Offered a trade | **Accept / Decline** dialog (forced) | +| Offered a trade | **Accept / Decline / Counter…** dialog (forced); *Counter…* opens a picker pre-filled with the mirror of the offer | +| Counter-offers received | **Counter-offers** dialog: *Accept* beside the one to take, or *No deal* | | Game over | result dialog | Trade offers are free-form (two card multisets), so they are validated with @@ -137,6 +138,6 @@ Not done yet, in rough priority order: it does not say which hexes paid out, so that needs either a diff of two states or an engine addition. 3. Art: terrain textures, resource and harbour icons, a real robber pawn. -4. Open trade to the whole table and counter-offers (an engine limitation too). +4. A timer or cancel button on offers (the engine has neither). 5. A board that shows the *last* move (a faint highlight of what just changed). 6. Landscape polish on large tablets; the right-hand panel scrolls but is plain. -- 2.47.3