diff --git a/README.md b/README.md index 1f3a454..e45a4ec 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,8 @@ game: - [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 @@ -394,6 +396,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 new file mode 100644 index 0000000..5fb3d80 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,47 @@ +# docs/ + +Conventions for this repo's documentation. Seeded at bootstrap (WALA-118). + +## Per-game rules: `RULES-.md` + +Every game in `GameCatalog` gets one file describing how *this app* implements +it — not a generic rule dump. The rules code +(`app/src/main/java/org/prolibertate/games/game//`) is the source of +truth; the doc exists so a human or an agent can check intent against +behaviour without reading Kotlin first. + +Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`. + +| Game (catalogue id) | Rules doc | +| --- | --- | +| Backgammon | [RULES-backgammon.md](RULES-backgammon.md) | +| Checkers | [RULES-checkers.md](RULES-checkers.md) | +| Chess | [RULES-chess.md](RULES-chess.md) | +| Crazy 8s | [RULES-crazy8s.md](RULES-crazy8s.md) | +| Cribbage | [RULES-cribbage.md](RULES-cribbage.md) | +| Euchre | [RULES-euchre.md](RULES-euchre.md) | +| FreeCell | — (TODO) | +| 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) | +| Peg solitaire | — (TODO) | +| Pirates | [RULES-pirates.md](RULES-pirates.md) | +| President | [RULES-president.md](RULES-president.md) | +| Pyramid | — (TODO) | +| Sequence | [RULES-sequence.md](RULES-sequence.md) | +| Spider | — (TODO) | +| Ta Yü | [RULES-tayu.md](RULES-tayu.md) | +| Wizard | [RULES-wizard.md](RULES-wizard.md) | +| Yahtzee | — (TODO) | + +## Other docs + +- Build, CI, releases and OTA are described in the CI section of the root + [README](../README.md). +- `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 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.