diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index c8d0a69..7f6d9f0 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt @@ -20,10 +20,14 @@ import org.prolibertate.games.game.engine.GameAi */ class CatanAi : GameAi { + /** Plays the Cities & Knights tables; this class keeps the base game. */ + private val ck = CatanCkAi(this) + override fun chooseMove(state: CatanState, seat: Int, legal: List): CatanMove { require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) { "No legal move for seat $seat" } + if (state.isCk) return ck.choose(state, seat, legal) return when (state.phase) { CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> chooseSetup(state, seat, legal.filterIsInstance()) @@ -40,8 +44,8 @@ class CatanAi : GameAi { CatanPhase.PLAYING -> choosePlaying(state, seat, legal) - CatanPhase.GAME_OVER -> legal.firstOrNull() - ?: error("The game is over; nobody chooses a move") + CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull() + ?: error("Nothing to choose in phase ${state.phase}") } } @@ -49,7 +53,7 @@ class CatanAi : GameAi { // Setup // ----------------------------------------------------------------------- - private fun chooseSetup(state: CatanState, seat: Int, places: List): SetupPlace { + internal fun chooseSetup(state: CatanState, seat: Int, places: List): SetupPlace { require(places.isNotEmpty()) { "No legal settlement placement" } return places.maxByOrNull { place -> spotValue(state, place.vertex) + @@ -276,7 +280,7 @@ class CatanAi : GameAi { // ----------------------------------------------------------------------- /** What a seat would think of building on [vertex]: production and ports. */ - private fun spotValue(state: CatanState, vertex: Int): Double { + internal fun spotValue(state: CatanState, vertex: Int): Double { if (state.buildings[vertex] != null) return -1.0 var value = 0.0 val kinds = mutableSetOf() @@ -297,7 +301,7 @@ class CatanAi : GameAi { * 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 { + internal 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)) { @@ -309,7 +313,7 @@ class CatanAi : GameAi { } /** How much the AI wants to hold one card of [resource]. */ - private fun want(resource: Resource): Double = when (resource) { + internal 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 diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt new file mode 100644 index 0000000..b1630ae --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt @@ -0,0 +1,1499 @@ +package org.prolibertate.games.game.catan + +import kotlin.random.Random + +/** + * The Cities & Knights rules, as pure functions over [CatanState] with its + * [CkState] present. [CatanRules] is the single entry point and calls in here + * whenever the table plays this rule set; docs/RULES-catan-ck.md is the + * human-readable statement of what is enforced. + * + * The shape of a turn is the base game's, with three additions: + * + * - the **event die** rolls with the two number dice, and is resolved before + * production: a ship moves the barbarians one step nearer, a city gate lets + * players with that colour of improvement draw a progress card; + * - **commodities** (paper, cloth, coin) join the resources a hand holds, and + * cities on forest, pasture and mountains pay one of each; + * - **knights, walls and improvements** are bought with those cards. + * + * Whenever something needs an answer from somebody — a discard, a city to give + * up, a progress card to hand over — it is queued as a [Chore] and the game + * waits in [CatanPhase.CHORE] until the queue is empty, then resumes where + * [CkState.back] says. That one mechanism carries every multi-seat decision. + */ +object CatanCk { + + // ----------------------------------------------------------------------- + // Constants the AI and the UI plan with + // ----------------------------------------------------------------------- + + /** Hiring a knight, and promoting one, each cost this. */ + val KNIGHT_COST: Map = mapOf(Resource.ORE to 1, Resource.WOOL to 1) + + /** Activating a knight costs a grain. */ + val ACTIVATE_COST: Map = mapOf(Resource.GRAIN to 1) + + val WALL_COST: Map = mapOf(Resource.BRICK to 2) + + /** What a Medicine card makes a city upgrade cost. */ + val MEDICINE_COST: Map = mapOf(Resource.GRAIN to 1, Resource.ORE to 2) + + /** Number tokens an Inventor may move (never the 2, 12, 6 or 8). */ + val INVENTOR_NUMBERS: Set = setOf(3, 4, 5, 9, 10, 11) + + // ----------------------------------------------------------------------- + // Set-up + // ----------------------------------------------------------------------- + + fun initial(options: CatanOptions, seed: Long): CkState { + val n = options.playerCount + var nextId = 0 + val decks = Track.entries.associateWith { track -> + ProgressType.entries + .filter { it.track == track } + .flatMap { kind -> List(kind.copies) { kind } } + .map { ProgressCard(nextId++, it) } + .shuffled(Random(seed + 2 + track.ordinal)) + } + return CkState( + commodities = List(n) { Commodity.entries.associateWith { 0 } }, + improvements = List(n) { Track.entries.associateWith { 0 } }, + decks = decks, + progressHands = List(n) { emptyList() }, + progressCounts = List(n) { 0 }, + progressVp = List(n) { 0 }, + defenderVp = List(n) { 0 }, + ) + } + + // ----------------------------------------------------------------------- + // Goods: resources and commodities together + // ----------------------------------------------------------------------- + + fun goodsOf(state: CatanState, seat: Int): Map { + val commodities = state.ck!!.commodities[seat] + return Good.entries.associateWith { good -> + good.resource?.let { state.resources[seat][it] } + ?: good.commodity?.let { commodities[it] } + ?: 0 + } + } + + fun goodsCount(state: CatanState, seat: Int): Int = goodsOf(state, seat).values.sum() + + fun goodsList(state: CatanState, seat: Int): List = + goodsOf(state, seat).flatMap { (good, n) -> List(n) { good } } + + fun holds(state: CatanState, seat: Int, cards: List): Boolean { + val have = goodsOf(state, seat) + return countGoods(cards).all { (good, n) -> (have[good] ?: 0) >= n } + } + + fun countGoods(cards: List): Map = cards.groupingBy { it }.eachCount() + + /** The bank's remaining cards of [good]: what no player holds. */ + fun supply(state: CatanState, good: Good): Int { + good.resource?.let { return CatanRules.bankSupply(state, it) } + val commodity = good.commodity!! + return BANK_PER_COMMODITY - state.ck!!.commodities.sumOf { it[commodity] ?: 0 } + } + + /** [state] with [delta] cards added to (or, with a negative [sign], removed from) [seat]'s hand. */ + fun adjust(state: CatanState, seat: Int, delta: Map, sign: Int): CatanState { + val ck = state.ck!! + val resources = state.resources[seat].toMutableMap() + val commodities = ck.commodities[seat].toMutableMap() + for ((good, n) in delta) { + good.resource?.let { resources[it] = (resources[it] ?: 0) + sign * n } + good.commodity?.let { commodities[it] = (commodities[it] ?: 0) + sign * n } + } + return state.copy( + resources = state.resources.toMutableList().also { it[seat] = resources }, + ck = ck.copy(commodities = ck.commodities.toMutableList().also { it[seat] = commodities }), + ) + } + + private fun give(state: CatanState, from: Int, to: Int, cards: List): CatanState { + val counts = countGoods(cards) + return adjust(adjust(state, from, counts, -1), to, counts, 1) + } + + private fun costGoods(cost: Map): Map = + cost.mapKeys { Good.of(it.key) } + + private fun pay(state: CatanState, seat: Int, cost: Map): CatanState = + adjust(state, seat, costGoods(cost), -1) + + private fun affordable(state: CatanState, seat: Int, cost: Map): Boolean = + CatanRules.affordable(state, seat, cost) + + /** The commodity a city on [terrain] pays besides the resource, if any. */ + fun commodityOf(terrain: Terrain): Commodity? = when (terrain) { + Terrain.FOREST -> Commodity.PAPER + Terrain.PASTURE -> Commodity.CLOTH + Terrain.MOUNTAINS -> Commodity.COIN + else -> null + } + + // ----------------------------------------------------------------------- + // Pieces: knights, walls, metropolises + // ----------------------------------------------------------------------- + + fun knightAt(state: CatanState, vertex: Int): Knight? = + state.ck!!.knights.firstOrNull { it.vertex == vertex } + + fun knightsOf(state: CatanState, seat: Int): List = + state.ck!!.knights.filter { it.owner == seat } + + private fun levelCount(state: CatanState, seat: Int, level: Int): Int = + state.ck!!.knights.count { it.owner == seat && it.level == level } + + /** The strength a seat brings against the barbarians: its active knights. */ + fun strength(state: CatanState, seat: Int): Int = + knightsOf(state, seat).filter { it.active }.sumOf { it.level } + + /** A knight may act when it is active and was not activated this very turn. */ + fun canAct(state: CatanState, knight: Knight): Boolean = + knight.active && knight.activatedTurn != state.turnNumber + + fun improvement(state: CatanState, seat: Int, track: Track): Int = + state.ck!!.improvements[seat][track] ?: 0 + + fun wallsOf(state: CatanState, seat: Int): Int = + state.ck!!.walls.count { state.buildings[it]?.owner == seat } + + /** How many cards a seat may hold through a seven. */ + fun handLimit(state: CatanState, seat: Int): Int = BASE_HAND_LIMIT + 2 * wallsOf(state, seat) + + private fun hasMetropolis(state: CatanState, vertex: Int): Boolean = + state.ck!!.metropolises.values.any { it.vertex == vertex } + + /** Cities of [seat] that can carry a metropolis or fall to the barbarians' rival: all but metropolises. */ + private fun plainCities(state: CatanState, seat: Int): List = + (0 until CatanBoard.VERTEX_COUNT).filter { v -> + state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && !hasMetropolis(state, v) + } + + private fun ownsCity(state: CatanState, seat: Int): Boolean = + state.buildings.any { it == CatanBuilding(BuildingType.CITY, seat) } + + /** Empty crossings at the end of one of [seat]'s roads: where a knight can be put. */ + private fun knightSpots(state: CatanState, seat: Int): List = + (0 until CatanBoard.VERTEX_COUNT).filter { v -> + state.buildings[v] == null && knightAt(state, v) == null && + CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } + } + + /** + * Crossings a knight of [seat] standing on [from] could reach along that + * seat's own roads. Its own pieces can be walked past; an opponent's + * building or knight stops the walk (a knight can still be the end of it, + * which is how one drives another off). + */ + private fun walk(state: CatanState, seat: Int, from: Int): Set { + val seen = mutableSetOf(from) + val queue = ArrayDeque().apply { add(from) } + while (queue.isNotEmpty()) { + val v = queue.removeFirst() + for (e in CatanBoard.VERTEX_EDGES[v]) { + if (state.roads[e] != seat) continue + val w = CatanBoard.otherEnd(e, v) + if (!seen.add(w)) continue + val building = state.buildings[w] + val knight = knightAt(state, w) + if (building != null && building.owner != seat) continue + if (knight != null && knight.owner != seat) continue + queue.add(w) + } + } + seen.remove(from) + return seen + } + + /** Where the knight [k] could go: empty crossings, and crossings held by a weaker foreign knight. */ + private fun destinations(state: CatanState, k: Knight): List = + walk(state, k.owner, k.vertex).filter { w -> + val there = knightAt(state, w) + if (state.buildings[w] != null) return@filter false + there == null || (there.owner != k.owner && there.level < k.level) + }.sorted() + + /** Empty crossings a driven-off [knight] of its owner could still reach. */ + private fun refuges(state: CatanState, knight: Knight): List = + walk(state, knight.owner, knight.vertex).filter { w -> + state.buildings[w] == null && knightAt(state, w) == null + }.sorted() + + private fun promotable(state: CatanState, k: Knight): Boolean { + if (k.level >= 3 || k.promotedTurn == state.turnNumber) return false + if (levelCount(state, k.owner, k.level + 1) >= KNIGHTS_PER_LEVEL) return false + return k.level < 2 || improvement(state, k.owner, Track.POLITICS) >= IMPROVEMENT_POWER_LEVEL + } + + // ----------------------------------------------------------------------- + // Scoring + // ----------------------------------------------------------------------- + + /** + * Points everyone can count. Nothing is hidden in this rule set: progress + * cards that score are played face up the moment they are drawn. + */ + fun publicVictoryPoints(state: CatanState): List { + val ck = state.ck!! + val points = IntArray(state.playerCount) + state.buildings.forEach { b -> + if (b != null) points[b.owner] += if (b.type == BuildingType.CITY) 2 else 1 + } + ck.metropolises.values.forEach { points[it.seat] += 2 } + state.longestRoadOwner?.let { points[it] += CK_LONGEST_ROAD_VP } + for (seat in 0 until state.playerCount) { + points[seat] += ck.defenderVp[seat] + ck.progressVp[seat] + } + ck.merchant?.let { points[it.seat] += 1 } + return points.toList() + } + + // ----------------------------------------------------------------------- + // Legal moves + // ----------------------------------------------------------------------- + + fun legalMoves(state: CatanState, seat: Int): List = when (state.phase) { + CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> CatanRules.setupMoves(state) + + CatanPhase.AWAIT_ROLL -> buildList { + add(RollDice()) + addAll(actionMoves(state, seat)) + } + + CatanPhase.ROBBER -> robberMoves(state, seat) + + CatanPhase.PLAYING -> playingMoves(state, seat) + + CatanPhase.FREE_ROADS -> + CatanRules.roadMoves(state, seat) + listOf(EndFreeRoads) + + CatanPhase.TRADE_PENDING -> { + val offer = state.ck!!.goodsOffer + if (offer == null) { + CatanRules.tradeResponses(state, seat) + } else { + val responses = mutableListOf() + if (holds(state, seat, offer.get) && holds(state, offer.by, offer.give)) { + responses += AcceptTrade + } + responses += DeclineTrade + responses + } + } + + CatanPhase.CHORE -> choreMoves(state, seat) + + CatanPhase.DISCARDING -> emptyList() // replaced by chores in this rule set + + CatanPhase.GAME_OVER -> emptyList() + } + + /** + * What can be done on the player's own turn either side of the roll: + * progress cards, and the knights' own actions. + */ + private fun actionMoves(state: CatanState, seat: Int): List { + val ck = state.ck!! + val moves = mutableListOf() + + for (card in ck.progressHands[seat]) moves += progressPlays(state, seat, card) + + for (k in knightsOf(state, seat)) { + if (!k.active && affordable(state, seat, ACTIVATE_COST)) moves += ActivateKnight(k.vertex) + if (canAct(state, k)) { + destinations(state, k).forEach { moves += MoveKnight(k.vertex, it) } + if (ck.attacked && k.vertex in CatanBoard.HEX_CORNERS[state.robber]) { + moves += ChaseRobber(k.vertex) + } + } + } + return moves + } + + private fun playingMoves(state: CatanState, seat: Int): List { + val ck = state.ck!! + val moves = mutableListOf() + + if (affordable(state, seat, CatanRules.ROAD_COST)) moves += CatanRules.roadMoves(state, seat) + if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) { + moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) } + } + if (affordable(state, seat, CatanRules.CITY_COST)) { + moves += CatanRules.cityVertices(state, seat).map { BuildCity(it) } + } + + for (track in Track.entries) { + if (canImprove(state, seat, track)) moves += BuyImprovement(track) + } + + if (affordable(state, seat, KNIGHT_COST)) { + if (levelCount(state, seat, 1) < KNIGHTS_PER_LEVEL) { + knightSpots(state, seat).forEach { moves += HireKnight(it) } + } + for (k in knightsOf(state, seat)) if (promotable(state, k)) moves += PromoteKnight(k.vertex) + } + + if (affordable(state, seat, WALL_COST)) { + for (v in wallSites(state, seat)) moves += BuildWall(v) + } + + moves += actionMoves(state, seat) + + for (give in Good.entries) { + val ratio = bankRatio(state, seat, give) + if ((goodsOf(state, seat)[give] ?: 0) < ratio) continue + for (get in Good.entries) { + if (get == give || supply(state, get) < 1) continue + moves += GoodsBankTrade(give, get) + } + } + + moves += EndTurn + return moves + } + + /** Own cities with no wall, while the seat has a wall left to build. */ + private fun wallSites(state: CatanState, seat: Int): List { + if (wallsOf(state, seat) >= MAX_WALLS) return emptyList() + return (0 until CatanBoard.VERTEX_COUNT).filter { v -> + state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && v !in state.ck!!.walls + } + } + + fun improvementCost(state: CatanState, seat: Int, track: Track): Int { + val next = improvement(state, seat, track) + 1 + return if (state.ck!!.crane) maxOf(0, next - 1) else next + } + + fun canImprove(state: CatanState, seat: Int, track: Track): Boolean { + if (improvement(state, seat, track) >= IMPROVEMENT_MAX || !ownsCity(state, seat)) return false + val have = state.ck!!.commodities[seat][track.commodity] ?: 0 + return have >= improvementCost(state, seat, track) + } + + /** The ratio [seat] trades [give] to the bank at: 4, or better through harbours, a merchant, a fleet, the trade track. */ + fun bankRatio(state: CatanState, seat: Int, give: Good): Int { + val ck = state.ck!! + var best = 4 + val resource = give.resource + if (resource != null) { + best = CatanRules.bestPortRatio(state, seat, resource) + val merchant = ck.merchant + if (merchant != null && merchant.seat == seat && + state.terrains[merchant.hex].produces == resource + ) { + best = minOf(best, 2) + } + } else { + // A generic harbour takes any card; a resource harbour takes only its own. + best = minOf(best, CatanRules.genericPortRatio(state, seat)) + if (improvement(state, seat, Track.TRADE) >= IMPROVEMENT_POWER_LEVEL) best = minOf(best, 2) + } + if (ck.fleet == give) best = minOf(best, 2) + return best + } + + // --- robber --- + + private fun robberMoves(state: CatanState, seat: Int): List { + val bishop = state.ck!!.bishop + val moves = mutableListOf() + for (h in 0 until CatanBoard.HEX_COUNT) { + if (h == state.robber) continue + val victims = victimsAt(state, seat, h) + when { + bishop -> moves += MoveRobber(CatanBoard.HEXES[h]) + victims.isEmpty() -> moves += MoveRobber(CatanBoard.HEXES[h]) + else -> victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) } + } + } + return moves + } + + private fun victimsAt(state: CatanState, seat: Int, hex: Int): List = + CatanBoard.HEX_CORNERS[hex] + .mapNotNull { state.buildings[it] } + .map { it.owner } + .filter { it != seat } + .distinct() + .filter { goodsCount(state, it) > 0 } + + fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState { + val ck = state.ck!! + val hex = CatanBoard.hexIndex[move.hex] + ?: throw IllegalArgumentException("No hex at ${move.hex}") + var s = state.copy(robber = hex, rngStep = state.rngStep + 1) + val rng = Random(state.seed * 1_000_003L + state.rngStep) + val victims = if (ck.bishop) victimsAt(state, seat, hex) else listOfNotNull(move.victim) + val taken = mutableListOf() + for (victim in victims) { + val hand = goodsList(s, victim) + if (hand.isEmpty()) continue + val card = hand[rng.nextInt(hand.size)] + s = give(s, victim, seat, listOf(card)) + taken += "a ${good(card)} from seat $victim" + } + val by = if (ck.bishop) "The bishop" else "Seat $seat" + val stole = if (taken.isEmpty()) "" else " and steals ${taken.joinToString(", ")}" + s = s.copy( + phase = if (ck.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING, + ck = s.ck!!.copy(bishop = false), + log = s.log + ("$by moves the robber to the ${terrainName(state.terrains[hex])}" + + " (${move.hex.q},${move.hex.r})$stole."), + ) + return s + } + + // --- progress card plays --- + + private val DICE_PAIRS: List> = (1..6).flatMap { y -> (1..6).map { r -> listOf(y, r) } } + + /** Every play of [card] the engine accepts right now. */ + fun progressPlays(state: CatanState, seat: Int, card: ProgressCard): List { + val ck = state.ck!! + val id = card.id + val vp = publicVictoryPoints(state) + val others = (0 until state.playerCount).filter { it != seat } + val preRoll = state.phase == CatanPhase.AWAIT_ROLL + + return when (card.kind) { + ProgressType.ALCHEMIST -> + if (preRoll && ck.alchemist == null) DICE_PAIRS.map { PlayProgress(id, dice = it) } else emptyList() + + ProgressType.CRANE -> + if (!ck.crane && Track.entries.any { improvement(state, seat, it) < IMPROVEMENT_MAX && ownsCity(state, seat) }) { + listOf(PlayProgress(id)) + } else { + emptyList() + } + + ProgressType.ENGINEER -> wallSites(state, seat).map { PlayProgress(id, vertex = it) } + + ProgressType.INVENTOR -> { + val hexes = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] in INVENTOR_NUMBERS } + hexes.flatMap { a -> + hexes.filter { b -> b > a && state.numbers[b] != state.numbers[a] } + .map { b -> PlayProgress(id, hexA = a, hexB = b) } + } + } + + ProgressType.IRRIGATION -> + if (hexesNextTo(state, seat, Terrain.FIELDS).isNotEmpty()) listOf(PlayProgress(id)) else emptyList() + + ProgressType.MINING -> + if (hexesNextTo(state, seat, Terrain.MOUNTAINS).isNotEmpty()) listOf(PlayProgress(id)) else emptyList() + + ProgressType.MEDICINE -> + if (affordable(state, seat, MEDICINE_COST)) { + CatanRules.cityVertices(state, seat).map { PlayProgress(id, vertex = it) } + } else { + emptyList() + } + + ProgressType.ROAD_BUILDING -> + if (CatanRules.roadMoves(state, seat).isNotEmpty()) listOf(PlayProgress(id)) else emptyList() + + ProgressType.SMITH -> + if (knightsOf(state, seat).any { promotable(state, it) }) listOf(PlayProgress(id)) else emptyList() + + ProgressType.BISHOP -> if (ck.attacked) listOf(PlayProgress(id)) else emptyList() + + ProgressType.DESERTER -> + others.filter { knightsOf(state, it).isNotEmpty() }.map { PlayProgress(id, target = it) } + + ProgressType.DIPLOMAT -> openRoads(state).map { PlayProgress(id, edge = it) } + + ProgressType.INTRIGUE -> + ck.knights.filter { k -> + k.owner != seat && CatanBoard.VERTEX_EDGES[k.vertex].any { state.roads[it] == seat } + }.map { PlayProgress(id, vertex = it.vertex) } + + ProgressType.SABOTEUR -> listOf(PlayProgress(id)) + + ProgressType.SPY -> + others.filter { ck.progressHands[it].isNotEmpty() }.map { PlayProgress(id, target = it) } + + ProgressType.WARLORD -> + if (knightsOf(state, seat).any { !it.active }) listOf(PlayProgress(id)) else emptyList() + + ProgressType.WEDDING -> + if (others.any { vp[it] > vp[seat] && goodsCount(state, it) > 0 }) listOf(PlayProgress(id)) else emptyList() + + ProgressType.COMMERCIAL_HARBOR -> + if (others.any { commodityCount(state, it) > 0 }) { + Good.RESOURCES + .filter { (goodsOf(state, seat)[it] ?: 0) > 0 } + .map { PlayProgress(id, good = it) } + } else { + emptyList() + } + + ProgressType.MASTER_MERCHANT -> + others.filter { vp[it] > vp[seat] && goodsCount(state, it) > 0 }.flatMap { target -> + takeTwo(goodsOf(state, target)).map { PlayProgress(id, target = target, goods = it) } + } + + ProgressType.MERCHANT -> + (0 until CatanBoard.HEX_COUNT).filter { h -> + state.terrains[h] != Terrain.DESERT && + CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } + }.map { PlayProgress(id, hexA = it) } + + ProgressType.MERCHANT_FLEET -> Good.entries.map { PlayProgress(id, good = it) } + + ProgressType.RESOURCE_MONOPOLY -> Good.RESOURCES.map { PlayProgress(id, good = it) } + + ProgressType.TRADE_MONOPOLY -> Good.COMMODITIES.map { PlayProgress(id, good = it) } + + // Played the moment they are drawn; never sit in a hand. + ProgressType.PRINTER, ProgressType.CONSTITUTION -> emptyList() + } + } + + private fun commodityCount(state: CatanState, seat: Int): Int = + state.ck!!.commodities[seat].values.sum() + + /** Every way to take two cards from a hand (or all of it, if it is smaller). */ + private fun takeTwo(hand: Map): List> { + val total = hand.values.sum() + val want = minOf(2, total) + val held = Good.entries.filter { (hand[it] ?: 0) > 0 } + val plays = mutableListOf>() + if (want == 1) { + held.forEach { plays += listOf(it) } + } else { + for ((i, a) in held.withIndex()) { + if ((hand[a] ?: 0) >= 2) plays += listOf(a, a) + for (b in held.drop(i + 1)) plays += listOf(a, b) + } + } + return plays + } + + private fun hexesNextTo(state: CatanState, seat: Int, terrain: Terrain): List = + (0 until CatanBoard.HEX_COUNT).filter { h -> + state.terrains[h] == terrain && + CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } + } + + /** Roads with a loose end: one end touching nothing else of anyone's. */ + private fun openRoads(state: CatanState): List = + (0 until CatanBoard.EDGE_COUNT).filter { e -> + if (state.roads[e] == NO_PLAYER) return@filter false + val (a, b) = CatanBoard.EDGE_VERTICES[e] + listOf(a, b).any { v -> + state.buildings[v] == null && knightAt(state, v) == null && + CatanBoard.VERTEX_EDGES[v].none { it != e && state.roads[it] != NO_PLAYER } + } + } + + // --- chores --- + + private fun choreMoves(state: CatanState, seat: Int): List { + val ck = state.ck!! + val chore = ck.chores.firstOrNull() ?: return emptyList() + if (chore.seat != seat) return emptyList() + return when (chore) { + is DiscardChore -> listOf(DiscardGoods(goodsList(state, seat).take(minOf(chore.count, goodsCount(state, seat))))) + + is GiveChore -> { + val pool = giveable(state, seat, chore) + listOf(GiveGoods(pool.take(minOf(chore.count, pool.size)))) + } + + is PillageChore -> plainCities(state, seat).map { PillageCity(it) } + + is PickDeckChore -> Track.entries.filter { ck.decks[it].orEmpty().isNotEmpty() }.map { PickDeck(it) } + + is DropProgressChore -> ck.progressHands[seat].map { DropProgress(it.id) } + + is MetropolisChore -> plainCities(state, seat).map { PlaceMetropolis(it) } + + is AqueductChore -> Resource.entries.filter { supply(state, Good.of(it)) > 0 }.map { TakeAqueduct(it) } + + is RelocateChore -> refuges(state, chore.knight).map { RelocateKnight(it) } + + is RemoveKnightChore -> knightsOf(state, seat).map { RemoveKnight(it.vertex) } + + is PlaceKnightChore -> knightSpots(state, seat).map { PlaceKnight(it) } + + is SpyChore -> ck.progressHands[chore.target].map { SpyTake(it.id) } + + is SmithChore -> + knightsOf(state, seat).filter { promotable(state, it) }.map { PromoteKnight(it.vertex) } + + listOf(EndSmith) + } + } + + private fun giveable(state: CatanState, seat: Int, chore: GiveChore): List { + val all = goodsList(state, seat) + return if (chore.commoditiesOnly) all.filter { it.isCommodity } else all + } + + /** Whether a chore could be answered at all; one that could not is dropped rather than queued. */ + private fun viable(state: CatanState, chore: Chore): Boolean = when (chore) { + is DiscardChore -> chore.count > 0 && goodsCount(state, chore.seat) > 0 + is GiveChore -> chore.count > 0 && giveable(state, chore.seat, chore).isNotEmpty() + is PillageChore -> plainCities(state, chore.seat).isNotEmpty() + is PickDeckChore -> Track.entries.any { state.ck!!.decks[it].orEmpty().isNotEmpty() } + is DropProgressChore -> state.ck!!.progressHands[chore.seat].size > PROGRESS_HAND_MAX + is MetropolisChore -> plainCities(state, chore.seat).isNotEmpty() + is AqueductChore -> Resource.entries.any { supply(state, Good.of(it)) > 0 } + is RelocateChore -> refuges(state, chore.knight).isNotEmpty() + is RemoveKnightChore -> knightsOf(state, chore.seat).isNotEmpty() + is PlaceKnightChore -> knightSpots(state, chore.seat).isNotEmpty() + is SpyChore -> state.ck!!.progressHands[chore.target].isNotEmpty() + is SmithChore -> knightsOf(state, chore.seat).any { promotable(state, it) } + } + + /** The phase a flow that started in [phase] returns to. */ + private fun backFor(phase: CatanPhase): Back = + if (phase == CatanPhase.AWAIT_ROLL) Back.AWAIT_ROLL else Back.PLAYING + + /** [chores] that could be answered, put ahead of whatever is waiting. The phase is not touched. */ + private fun pushFront(state: CatanState, chores: List): CatanState { + val ck = state.ck!! + val live = chores.filter { viable(state, it) } + return if (live.isEmpty()) state else state.copy(ck = ck.copy(chores = live + ck.chores)) + } + + /** + * Queues [chores] ahead of whatever is already waiting, dropping any that + * cannot be answered, and moves the game into [CatanPhase.CHORE] if any + * were queued. [back] says where play resumes; leave it null to keep the current. + */ + private fun enqueue(state: CatanState, chores: List, back: Back? = null): CatanState { + val pushed = pushFront(state, chores) + if (pushed === state) return state + val ck = pushed.ck!! + return pushed.copy(phase = CatanPhase.CHORE, ck = ck.copy(back = back ?: ck.back)) + } + + private fun pop(state: CatanState): CatanState { + val ck = state.ck!! + return state.copy(ck = ck.copy(chores = ck.chores.drop(1))) + } + + /** The first chore is done: pop it and carry on. */ + private fun finishChore(state: CatanState): CatanState = finishAfterPop(pop(state)) + + /** Continues after a chore that has already been popped from the queue. */ + private fun finishAfterPop(state: CatanState): CatanState { + val ck = state.ck!! + // Chores behind the one just answered may have lost their point. + val live = ck.chores.filter { viable(state, it) } + val s = state.copy(ck = ck.copy(chores = live)) + return if (live.isNotEmpty()) s.copy(phase = CatanPhase.CHORE) else resume(s) + } + + private fun resume(state: CatanState): CatanState = when (state.ck!!.back) { + Back.PLAYING, Back.AFTER_PRODUCTION -> state.copy(phase = CatanPhase.PLAYING) + Back.AWAIT_ROLL -> state.copy(phase = CatanPhase.AWAIT_ROLL) + Back.AFTER_EVENT -> afterEvent(state) + Back.AFTER_DISCARDS -> afterSevenDiscards(state) + } + + // ----------------------------------------------------------------------- + // Applying moves + // ----------------------------------------------------------------------- + + /** Whether [move] is a structural intent this rule set checks itself. */ + fun structurallyLegal(state: CatanState, seat: Int, move: CatanMove): Boolean? = when (move) { + is OfferGoods -> { + state.phase == CatanPhase.PLAYING && state.turn == seat && + move.to in 0 until state.playerCount && move.to != seat && + move.give.isNotEmpty() && move.get.isNotEmpty() && + countGoods(move.give) != countGoods(move.get) && + holds(state, seat, move.give) + } + + is DiscardGoods -> { + val chore = state.ck?.chores?.firstOrNull() as? DiscardChore + state.phase == CatanPhase.CHORE && chore != null && chore.seat == seat && + move.cards.size == minOf(chore.count, goodsCount(state, seat)) && + holds(state, seat, move.cards) + } + + is GiveGoods -> { + val chore = state.ck?.chores?.firstOrNull() as? GiveChore + state.phase == CatanPhase.CHORE && chore != null && chore.seat == seat && + move.cards.size == minOf(chore.count, giveable(state, seat, chore).size) && + (!chore.commoditiesOnly || move.cards.all { it.isCommodity }) && + holds(state, seat, move.cards) + } + + else -> null + } + + /** Applies a move that exists only in this rule set. */ + fun apply(state: CatanState, seat: Int, move: CatanMove): CatanState = when (move) { + is BuyImprovement -> applyImprovement(state, seat, move) + is HireKnight -> applyHire(state, seat, move) + is ActivateKnight -> applyActivate(state, seat, move) + is PromoteKnight -> applyPromote(state, seat, move) + is MoveKnight -> applyMoveKnight(state, seat, move) + is ChaseRobber -> applyChase(state, seat, move) + is BuildWall -> applyWall(state, seat, move.vertex, free = false) + is PlayProgress -> applyProgress(state, seat, move) + is GoodsBankTrade -> applyBankTrade(state, seat, move) + is OfferGoods -> applyOfferGoods(state, seat, move) + is DiscardGoods -> applyDiscard(state, seat, move) + is GiveGoods -> applyGive(state, seat, move) + is PillageCity -> applyPillageChoice(state, seat, move) + is PickDeck -> applyPickDeck(state, seat, move) + is DropProgress -> applyDrop(state, seat, move) + is PlaceMetropolis -> applyPlaceMetropolis(state, seat, move) + is TakeAqueduct -> applyAqueduct(state, seat, move) + is RelocateKnight -> applyRelocate(state, seat, move) + is RemoveKnight -> applyRemoveKnight(state, seat, move) + is PlaceKnight -> applyPlaceKnight(state, seat, move) + is SpyTake -> applySpyTake(state, seat, move) + EndSmith -> finishChore(state) + else -> throw IllegalArgumentException("Not a Cities & Knights move: $move") + } + + // --- improvements --- + + private fun applyImprovement(state: CatanState, seat: Int, move: BuyImprovement): CatanState { + val ck = state.ck!! + val cost = improvementCost(state, seat, move.track) + var s = adjust(state, seat, mapOf(Good.of(move.track.commodity) to cost), -1) + val level = improvement(state, seat, move.track) + 1 + val improvements = ck.improvements.toMutableList().also { + it[seat] = it[seat] + (move.track to level) + } + s = s.copy( + ck = s.ck!!.copy(improvements = improvements, crane = false), + log = s.log + ("Seat $seat builds the ${improvementName(move.track, level)} " + + "(${trackName(move.track)} level $level)."), + ) + return claimMetropolises(s, seat, backFor(state.phase)) + } + + /** Names for the five levels of each track, for the log and the screen. */ + fun improvementName(track: Track, level: Int): String = when (track) { + Track.TRADE -> listOf("Trading Post", "Market", "Guildhouse", "Bank", "Grand Bazaar") + Track.POLITICS -> listOf("Town Hall", "Church", "Fortress", "Cathedral", "Palace") + Track.SCIENCE -> listOf("Abbey", "Library", "Aqueduct", "Theatre", "University") + }[level - 1] + + fun trackName(track: Track): String = when (track) { + Track.TRADE -> "trade" + Track.POLITICS -> "politics" + Track.SCIENCE -> "science" + } + + /** + * Gives [seat] any metropolis it is newly entitled to: the first to reach + * level four in a track takes that track's metropolis, and level five takes + * it from a holder who has not got there. A seat with several cities picks + * which; with one it is placed at once; with none the claim waits for a city. + */ + private fun claimMetropolises(state: CatanState, seat: Int, back: Back): CatanState { + val ck = state.ck!! + var s = state + for (track in Track.entries) { + val level = improvement(s, seat, track) + val holder = s.ck!!.metropolises[track] + val due = level >= METROPOLIS_LEVEL && + (holder == null || (holder.seat != seat && level >= IMPROVEMENT_MAX && + improvement(s, holder.seat, track) < IMPROVEMENT_MAX)) + if (!due) continue + val cities = plainCities(s, seat) + when { + cities.isEmpty() -> Unit + cities.size == 1 -> s = placeMetropolis(s, seat, track, cities.first()) + else -> s = enqueue(s, listOf(MetropolisChore(seat, track)), back) + } + } + return s + } + + private fun placeMetropolis(state: CatanState, seat: Int, track: Track, vertex: Int): CatanState { + val ck = state.ck!! + val previous = ck.metropolises[track] + val took = if (previous != null && previous.seat != seat) { + " It is taken from seat ${previous.seat}." + } else { + "" + } + return state.copy( + ck = ck.copy(metropolises = ck.metropolises + (track to MetropolisAt(seat, vertex))), + log = state.log + "Seat $seat raises the ${trackName(track)} metropolis.$took", + ) + } + + private fun applyPlaceMetropolis(state: CatanState, seat: Int, move: PlaceMetropolis): CatanState { + val chore = state.ck!!.chores.first() as MetropolisChore + return finishChore(placeMetropolis(state, seat, chore.track, move.vertex)) + } + + /** After a city is built a seat may owe itself a metropolis it could not place before. */ + fun afterCityBuilt(state: CatanState, seat: Int): CatanState = + claimMetropolises(state, seat, backFor(state.phase)) + + // --- knights --- + + private fun withKnights(state: CatanState, knights: List): CatanState = + state.copy(ck = state.ck!!.copy(knights = knights)) + + private fun applyHire(state: CatanState, seat: Int, move: HireKnight): CatanState { + val s = pay(state, seat, KNIGHT_COST) + return withKnights(s, s.ck!!.knights + Knight(move.vertex, seat, 1)).let { + it.copy(log = it.log + "Seat $seat hires a knight at ${move.vertex}.") + } + } + + private fun applyActivate(state: CatanState, seat: Int, move: ActivateKnight): CatanState { + val s = pay(state, seat, ACTIVATE_COST) + val knights = s.ck!!.knights.map { + if (it.vertex == move.vertex) it.copy(active = true, activatedTurn = state.turnNumber) else it + } + return withKnights(s, knights).let { + it.copy(log = it.log + "Seat $seat activates the knight at ${move.vertex}.") + } + } + + private fun applyPromote(state: CatanState, seat: Int, move: PromoteKnight): CatanState { + val free = state.phase == CatanPhase.CHORE + var s = if (free) state else pay(state, seat, KNIGHT_COST) + val knights = s.ck!!.knights.map { + if (it.vertex == move.vertex) it.copy(level = it.level + 1, promotedTurn = state.turnNumber) else it + } + s = withKnights(s, knights).let { + it.copy(log = it.log + "Seat $seat promotes the knight at ${move.vertex}.") + } + if (!free) return s + val chore = s.ck!!.chores.first() as SmithChore + val left = chore.left - 1 + val swapped = s.ck!!.chores.toMutableList().also { it[0] = chore.copy(left = left) } + s = s.copy(ck = s.ck!!.copy(chores = swapped)) + return if (left > 0 && viable(s, swapped[0])) s else finishChore(s) + } + + private fun applyMoveKnight(state: CatanState, seat: Int, move: MoveKnight): CatanState { + val ck = state.ck!! + val mover = knightAt(state, move.from)!! + val there = knightAt(state, move.to) + val back = backFor(state.phase) + val moved = mover.copy(vertex = move.to, active = false) + val knights = ck.knights.filter { it.vertex != move.from && it.vertex != move.to } + moved + var s = state.copy(ck = ck.copy(knights = knights)) + if (there == null) { + return s.copy(log = s.log + "Seat $seat moves a knight from ${move.from} to ${move.to}.") + } + // A weaker foreign knight stood there: its owner gets to move it, or loses it. + s = s.copy(log = s.log + "Seat $seat's knight drives seat ${there.owner}'s from ${move.to}.") + val queued = enqueue(s, listOf(RelocateChore(there.owner, there)), back) + return if (queued.ck!!.chores.isNotEmpty()) { + queued + } else { + queued.copy(log = queued.log + "Seat ${there.owner}'s knight has nowhere to go and is lost.") + } + } + + private fun applyRelocate(state: CatanState, seat: Int, move: RelocateKnight): CatanState { + val chore = state.ck!!.chores.first() as RelocateChore + val s = withKnights(state, state.ck.knights + chore.knight.copy(vertex = move.to)).let { + it.copy(log = it.log + "Seat $seat moves the driven knight to ${move.to}.") + } + return finishChore(s) + } + + private fun applyChase(state: CatanState, seat: Int, move: ChaseRobber): CatanState { + val ck = state.ck!! + val knights = ck.knights.map { if (it.vertex == move.vertex) it.copy(active = false) else it } + return state.copy( + phase = CatanPhase.ROBBER, + ck = ck.copy(knights = knights, back = backFor(state.phase)), + log = state.log + "Seat $seat's knight chases off the robber.", + ) + } + + private fun applyWall(state: CatanState, seat: Int, vertex: Int, free: Boolean): CatanState { + val s = if (free) state else pay(state, seat, WALL_COST) + return s.copy( + ck = s.ck!!.copy(walls = s.ck!!.walls + vertex), + log = s.log + "Seat $seat raises a wall around the city at $vertex.", + ) + } + + // --- bank and player trade --- + + private fun applyBankTrade(state: CatanState, seat: Int, move: GoodsBankTrade): CatanState { + val ratio = bankRatio(state, seat, move.give) + var s = adjust(state, seat, mapOf(move.give to ratio), -1) + s = adjust(s, seat, mapOf(move.get to 1), 1) + val via = if (ratio == 4) "the bank" else "a $ratio:1 rate" + return s.copy( + log = s.log + "Seat $seat trades $ratio ${good(move.give)} for a ${good(move.get)} via $via.", + ) + } + + private fun applyOfferGoods(state: CatanState, seat: Int, move: OfferGoods): CatanState = + state.copy( + phase = CatanPhase.TRADE_PENDING, + ck = state.ck!!.copy(goodsOffer = GoodsOffer(seat, move.to, move.give, move.get)), + log = state.log + "Seat $seat offers ${describe(move.give)} for ${describe(move.get)} to seat ${move.to}.", + ) + + /** Accepting or declining a [GoodsOffer], called by [CatanRules] when one is pending. */ + fun answerOffer(state: CatanState, seat: Int, accept: Boolean): CatanState { + val offer = state.ck!!.goodsOffer!! + var s = state.copy(ck = state.ck.copy(goodsOffer = null), phase = CatanPhase.PLAYING) + if (!accept) return s.copy(log = s.log + "Seat $seat declines the offer.") + s = give(s, offer.to, offer.by, offer.get) + s = give(s, offer.by, offer.to, offer.give) + return s.copy(log = s.log + "Seat $seat accepts — the cards cross the table.") + } + + // --- chores answered --- + + private fun applyDiscard(state: CatanState, seat: Int, move: DiscardGoods): CatanState { + val s = adjust(state, seat, countGoods(move.cards), -1) + return finishChore(s.copy(log = s.log + "Seat $seat discards ${describe(move.cards)}.")) + } + + private fun applyGive(state: CatanState, seat: Int, move: GiveGoods): CatanState { + val chore = state.ck!!.chores.first() as GiveChore + var s = give(state, seat, chore.to, move.cards) + var line = "Seat $seat gives ${describe(move.cards)} to seat ${chore.to}" + val reward = chore.reward + if (reward != null) { + val owed = minOf(move.cards.size, goodsOf(s, chore.to)[reward] ?: 0) + if (owed > 0) { + val returned = List(owed) { reward } + s = give(s, chore.to, seat, returned) + line += " for ${describe(returned)}" + } + } + return finishChore(s.copy(log = s.log + "$line.")) + } + + private fun applyPillageChoice(state: CatanState, seat: Int, move: PillageCity): CatanState = + finishChore(pillage(state, seat, move.vertex)) + + /** The city on [vertex] falls: it becomes a settlement if the seat has one left, else it is gone. */ + private fun pillage(state: CatanState, seat: Int, vertex: Int): CatanState { + val ck = state.ck!! + val settlements = state.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) } + val replacement = if (settlements < MAX_SETTLEMENTS) { + CatanBuilding(BuildingType.SETTLEMENT, seat) + } else { + null + } + val buildings = state.buildings.toMutableList().also { it[vertex] = replacement } + val s = state.copy( + buildings = buildings, + ck = ck.copy(walls = ck.walls - vertex), + log = state.log + "Seat $seat's city at $vertex is pillaged.", + ) + return CatanRules.refreshLongestRoad(s) + } + + private fun applyPickDeck(state: CatanState, seat: Int, move: PickDeck): CatanState = + finishAfterPop(drawProgress(pop(state), seat, move.track)) + + private fun applyDrop(state: CatanState, seat: Int, move: DropProgress): CatanState { + val ck = state.ck!! + val card = ck.progressHands[seat].first { it.id == move.card } + val hands = ck.progressHands.toMutableList().also { it[seat] = it[seat].filter { c -> c.id != move.card } } + val decks = ck.decks + (card.kind.track to (ck.decks[card.kind.track].orEmpty() + card)) + val s = state.copy( + ck = ck.copy(decks = decks, progressHands = hands, progressCounts = hands.map { it.size }), + log = state.log + "Seat $seat gives up a progress card.", + ) + return finishChore(s) + } + + private fun applyAqueduct(state: CatanState, seat: Int, move: TakeAqueduct): CatanState { + val s = adjust(state, seat, mapOf(Good.of(move.resource) to 1), 1) + return finishChore(s.copy(log = s.log + "Seat $seat's aqueduct brings a ${good(Good.of(move.resource))}.")) + } + + private fun applyRemoveKnight(state: CatanState, seat: Int, move: RemoveKnight): CatanState { + val ck = state.ck!! + val chore = ck.chores.first() as RemoveKnightChore + val gone = knightAt(state, move.vertex)!! + var s = withKnights(state, ck.knights.filter { it.vertex != move.vertex }) + s = s.copy(log = s.log + "Seat $seat removes a knight, at seat ${chore.by}'s bidding.") + // The player who played the card replaces it with one of the same strength, else a basic. + val level = when { + levelCount(s, chore.by, gone.level) < KNIGHTS_PER_LEVEL -> gone.level + levelCount(s, chore.by, 1) < KNIGHTS_PER_LEVEL -> 1 + else -> 0 + } + val placing = if (level > 0) listOf(PlaceKnightChore(chore.by, level, gone.active)) else emptyList() + return finishAfterPop(pushFront(pop(s), placing)) + } + + private fun applyPlaceKnight(state: CatanState, seat: Int, move: PlaceKnight): CatanState { + val chore = state.ck!!.chores.first() as PlaceKnightChore + val knight = Knight( + vertex = move.vertex, + owner = seat, + level = chore.level, + active = chore.active, + activatedTurn = if (chore.active) state.turnNumber else 0, + ) + val s = withKnights(state, state.ck.knights + knight).let { + it.copy(log = it.log + "Seat $seat places a free knight at ${move.vertex}.") + } + return finishChore(s) + } + + private fun applySpyTake(state: CatanState, seat: Int, move: SpyTake): CatanState { + val ck = state.ck!! + val chore = ck.chores.first() as SpyChore + val card = ck.progressHands[chore.target].first { it.id == move.card } + val hands = ck.progressHands.toMutableList().also { + it[chore.target] = it[chore.target].filter { c -> c.id != move.card } + it[seat] = it[seat] + card + } + val s = state.copy( + ck = ck.copy(progressHands = hands, progressCounts = hands.map { it.size }), + log = state.log + "Seat $seat spies on seat ${chore.target} and takes a progress card.", + ) + // A fifth card has to be given back at once. + return finishAfterPop(pushFront(pop(s), listOf(DropProgressChore(seat)))) + } + + // ----------------------------------------------------------------------- + // Progress cards + // ----------------------------------------------------------------------- + + private fun drawProgress(state: CatanState, seat: Int, track: Track): CatanState { + val ck = state.ck!! + val deck = ck.decks[track].orEmpty() + if (deck.isEmpty()) return state + val card = deck.first() + val decks = ck.decks + (track to deck.drop(1)) + if (card.kind.scoresAtOnce) { + // A victory-point card is laid face up at once and never held. + val vp = ck.progressVp.toMutableList().also { it[seat] += 1 } + return state.copy( + ck = ck.copy(decks = decks, progressVp = vp), + log = state.log + "Seat $seat draws and lays down ${progressName(card.kind)} — one point.", + ) + } + val hands = ck.progressHands.toMutableList().also { it[seat] = it[seat] + card } + var s = state.copy( + ck = ck.copy(decks = decks, progressHands = hands, progressCounts = hands.map { it.size }), + log = state.log + "Seat $seat draws a ${trackName(track)} progress card.", + ) + if (hands[seat].size > PROGRESS_HAND_MAX) { + s = enqueue(s, listOf(DropProgressChore(seat))) + } + return s + } + + private fun applyProgress(state: CatanState, seat: Int, move: PlayProgress): CatanState { + val ck = state.ck!! + val card = ck.progressHands[seat].first { it.id == move.card } + val back = backFor(state.phase) + + // The card leaves the hand for the bottom of its deck, and the play resolves. + val hands = ck.progressHands.toMutableList().also { + it[seat] = it[seat].filter { c -> c.id != card.id } + } + val track = card.kind.track + var s = state.copy( + ck = ck.copy( + progressHands = hands, + progressCounts = hands.map { it.size }, + decks = ck.decks + (track to (ck.decks[track].orEmpty() + card)), + back = back, + ), + log = state.log + "Seat $seat plays ${progressName(card.kind)}.", + ) + val vp = publicVictoryPoints(s) + val others = (0 until s.playerCount).filter { it != seat } + // Opponents in the order they would act: clockwise from the player. + val clockwise = (1 until s.playerCount).map { (seat + it) % s.playerCount } + + when (card.kind) { + ProgressType.ALCHEMIST -> s = s.copy(ck = s.ck!!.copy(alchemist = move.dice)) + + ProgressType.CRANE -> s = s.copy(ck = s.ck!!.copy(crane = true)) + + ProgressType.ENGINEER -> s = applyWall(s, seat, move.vertex!!, free = true) + + ProgressType.INVENTOR -> { + val a = move.hexA!! + val b = move.hexB!! + val numbers = s.numbers.toMutableList().also { + val tmp = it[a] + it[a] = it[b] + it[b] = tmp + } + s = s.copy(numbers = numbers, log = s.log + "The tokens on two hexes trade places.") + } + + ProgressType.IRRIGATION -> s = harvest(s, seat, Terrain.FIELDS, Good.GRAIN) + + ProgressType.MINING -> s = harvest(s, seat, Terrain.MOUNTAINS, Good.ORE) + + ProgressType.MEDICINE -> { + s = pay(s, seat, MEDICINE_COST) + s = s.copy( + buildings = s.buildings.toMutableList().also { + it[move.vertex!!] = CatanBuilding(BuildingType.CITY, seat) + }, + log = s.log + "Seat $seat raises a city at ${move.vertex} at a discount.", + ) + s = claimMetropolises(s, seat, back) + } + + ProgressType.ROAD_BUILDING -> + s = s.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2) + + ProgressType.SMITH -> s = enqueue(s, listOf(SmithChore(seat, 2)), back) + + ProgressType.BISHOP -> + s = s.copy(phase = CatanPhase.ROBBER, ck = s.ck!!.copy(bishop = true)) + + ProgressType.DESERTER -> s = enqueue(s, listOf(RemoveKnightChore(move.target!!, seat)), back) + + ProgressType.DIPLOMAT -> { + val edge = move.edge!! + val owner = s.roads[edge] + s = s.copy(roads = s.roads.toMutableList().also { it[edge] = NO_PLAYER }) + s = CatanRules.refreshLongestRoad(s) + s = s.copy(log = s.log + "A road of seat $owner is removed.") + if (owner == seat && CatanRules.roadMoves(s, seat).isNotEmpty()) { + s = s.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 1) + } + } + + ProgressType.INTRIGUE -> { + val driven = knightAt(s, move.vertex!!)!! + s = withKnights(s, s.ck!!.knights.filter { it.vertex != driven.vertex }) + s = s.copy(log = s.log + "Seat $seat's intrigue drives off a knight of seat ${driven.owner}.") + s = enqueue(s, listOf(RelocateChore(driven.owner, driven)), back) + } + + ProgressType.SABOTEUR -> { + val chores = clockwise.filter { vp[it] >= vp[seat] } + .map { DiscardChore(it, goodsCount(s, it) / 2) } + s = enqueue(s, chores, back) + } + + ProgressType.SPY -> s = enqueue(s, listOf(SpyChore(seat, move.target!!)), back) + + ProgressType.WARLORD -> { + val knights = s.ck!!.knights.map { + if (it.owner == seat && !it.active) it.copy(active = true, activatedTurn = s.turnNumber) else it + } + s = withKnights(s, knights) + } + + ProgressType.WEDDING -> { + val chores = clockwise.filter { vp[it] > vp[seat] } + .map { GiveChore(it, minOf(2, goodsCount(s, it)), to = seat) } + s = enqueue(s, chores, back) + } + + ProgressType.COMMERCIAL_HARBOR -> { + val reward = move.good!! + val takers = clockwise.filter { commodityCount(s, it) > 0 } + .take(goodsOf(s, seat)[reward] ?: 0) + s = enqueue( + s, + takers.map { GiveChore(it, 1, to = seat, commoditiesOnly = true, reward = reward) }, + back, + ) + } + + ProgressType.MASTER_MERCHANT -> { + s = give(s, move.target!!, seat, move.goods!!) + s = s.copy(log = s.log + "Seat $seat takes ${describe(move.goods)} from seat ${move.target}.") + } + + ProgressType.MERCHANT -> { + s = s.copy(ck = s.ck!!.copy(merchant = MerchantAt(seat, move.hexA!!))) + s = s.copy(log = s.log + "Seat $seat takes the merchant.") + } + + ProgressType.MERCHANT_FLEET -> s = s.copy(ck = s.ck!!.copy(fleet = move.good)) + + ProgressType.RESOURCE_MONOPOLY -> s = monopoly(s, seat, move.good!!, 2, others) + + ProgressType.TRADE_MONOPOLY -> s = monopoly(s, seat, move.good!!, 1, others) + + ProgressType.PRINTER, ProgressType.CONSTITUTION -> Unit + } + return s + } + + /** Irrigation and Mining: two cards for each matching hex beside any building, as the bank allows. */ + private fun harvest(state: CatanState, seat: Int, terrain: Terrain, good: Good): CatanState { + val want = 2 * hexesNextTo(state, seat, terrain).size + val got = minOf(want, supply(state, good)) + if (got <= 0) return state + return adjust(state, seat, mapOf(good to got), 1).let { + it.copy(log = it.log + "Seat $seat takes $got ${good(good)} from the land.") + } + } + + private fun monopoly(state: CatanState, seat: Int, good: Good, each: Int, others: List): CatanState { + var s = state + var seized = 0 + for (other in others) { + val take = minOf(each, goodsOf(s, other)[good] ?: 0) + if (take == 0) continue + s = give(s, other, seat, List(take) { good }) + seized += take + } + return s.copy(log = s.log + "Seat $seat seizes $seized ${good(good)}.") + } + + // ----------------------------------------------------------------------- + // The roll + // ----------------------------------------------------------------------- + + /** + * Rolls the two number dice and the event die, resolves the event, and goes + * on to production (or the robber) once every chore the event raised is + * answered. + */ + fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState { + val ck = state.ck!! + val rng = Random(state.seed * 1_000_003L + state.rngStep) + val yellow = rng.nextInt(6) + 1 + val red = rng.nextInt(6) + 1 + val face = when (rng.nextInt(6)) { + 0, 1, 2 -> EventFace.SHIP + 3 -> EventFace.TRADE + 4 -> EventFace.POLITICS + else -> EventFace.SCIENCE + } + val dice = ck.alchemist ?: move.forced ?: listOf(yellow, red) + val event = move.event ?: face + var s = state.copy( + lastDice = dice, + rngStep = state.rngStep + 1, + ck = ck.copy(eventDie = event, alchemist = null, back = Back.AFTER_EVENT), + ) + s = s.copy(log = s.log + "Seat $seat rolls ${dice.joinToString(" + ")} and the event die shows ${eventName(event)}.") + + s = when (event) { + EventFace.SHIP -> advanceBarbarians(s) + else -> drawForGate(s, Track.valueOf(event.name), red = dice[1]) + } + return if (s.ck!!.chores.isNotEmpty()) s else afterEvent(s) + } + + private fun drawForGate(state: CatanState, track: Track, red: Int): CatanState { + var s = state + val order = (0 until s.playerCount).map { (s.turn + it) % s.playerCount } + for (seat in order) { + val level = improvement(s, seat, track) + if (level >= 1 && red <= level) s = drawProgress(s, seat, track) + } + return s + } + + private fun advanceBarbarians(state: CatanState): CatanState { + val ck = state.ck!! + val left = ck.barbarians - 1 + if (left > 0) { + return state.copy( + ck = ck.copy(barbarians = left), + log = state.log + "The barbarians sail nearer: $left ${if (left == 1) "step" else "steps"} away.", + ) + } + return attack(state) + } + + private fun attack(state: CatanState): CatanState { + val ck = state.ck!! + val cities = state.buildings.count { it?.type == BuildingType.CITY } + val strengths = (0 until state.playerCount).map { strength(state, it) } + val defence = strengths.sum() + var s = state.copy( + ck = ck.copy( + barbarians = BARBARIAN_STEPS, + attacked = true, + knights = ck.knights.map { it.copy(active = false) }, + ), + ) + val chores = mutableListOf() + if (defence >= cities) { + val top = strengths.max() + val champions = strengths.indices.filter { strengths[it] == top && top > 0 } + s = s.copy( + ck = s.ck!!.copy(lastAttack = "Catan held: knights $defence against barbarians $cities."), + log = s.log + "The barbarians land, $cities strong, against $defence in knights — Catan holds.", + ) + if (champions.size == 1) { + val seat = champions.first() + val defenders = s.ck!!.defenderVp.toMutableList().also { it[seat] += 1 } + s = s.copy( + ck = s.ck!!.copy(defenderVp = defenders), + log = s.log + "Seat $seat is named Defender of Catan — one point.", + ) + } else { + champions.forEach { chores += PickDeckChore(it) } + } + } else { + s = s.copy( + ck = s.ck!!.copy(lastAttack = "Catan fell: knights $defence against barbarians $cities."), + log = s.log + "The barbarians land, $cities strong, against $defence in knights — Catan falls.", + ) + val eligible = (0 until s.playerCount).filter { plainCities(s, it).isNotEmpty() } + if (eligible.isNotEmpty()) { + val weakest = eligible.minOf { strengths[it] } + for (seat in eligible.filter { strengths[it] == weakest }) { + val cityList = plainCities(s, seat) + if (cityList.size == 1) { + s = pillage(s, seat, cityList.first()) + } else { + chores += PillageChore(seat) + } + } + } + } + return enqueue(s, chores, Back.AFTER_EVENT) + } + + /** The event is over (and any chores it raised answered): now the dice pay out, or the robber stirs. */ + private fun afterEvent(state: CatanState): CatanState { + val total = state.lastDice.sum() + return if (total == 7) seven(state) else production(state, total) + } + + private fun seven(state: CatanState): CatanState { + val chores = (0 until state.playerCount).mapNotNull { seat -> + val held = goodsCount(state, seat) + if (held > handLimit(state, seat)) DiscardChore(seat, held / 2) else null + } + val s = state.copy(log = state.log + "A seven — the robber rides.") + val queued = enqueue(s, chores, Back.AFTER_DISCARDS) + return if (queued.ck!!.chores.isNotEmpty()) queued else afterSevenDiscards(queued) + } + + private fun afterSevenDiscards(state: CatanState): CatanState { + val ck = state.ck!! + return if (ck.attacked) { + state.copy(phase = CatanPhase.ROBBER, ck = ck.copy(back = Back.PLAYING)) + } else { + state.copy( + phase = CatanPhase.PLAYING, + ck = ck.copy(back = Back.PLAYING), + log = state.log + "The robber has not yet been woken; nothing is stolen.", + ) + } + } + + private fun production(state: CatanState, number: Int): CatanState { + val gains = List(state.playerCount) { mutableMapOf() } + for (h in 0 until CatanBoard.HEX_COUNT) { + if (state.numbers[h] != number || h == state.robber) continue + val terrain = state.terrains[h] + val resource = terrain.produces ?: continue + val commodity = commodityOf(terrain) + for (v in CatanBoard.HEX_CORNERS[h]) { + val building = state.buildings[v] ?: continue + val mine = gains[building.owner] + fun add(good: Good, n: Int) { mine[good] = (mine[good] ?: 0) + n } + if (building.type == BuildingType.SETTLEMENT) { + add(Good.of(resource), 1) + } else if (commodity == null) { + add(Good.of(resource), 2) // hills and fields pay a city double + } else { + add(Good.of(resource), 1) + add(Good.of(commodity), 1) + } + } + } + // A bank short of a card pays it in full to nobody, unless exactly one + // seat is owed it, who takes what is left. + for (good in Good.entries) { + val owed = gains.sumOf { it[good] ?: 0 } + val bank = supply(state, good) + if (owed <= bank) continue + val due = gains.indices.filter { (gains[it][good] ?: 0) > 0 } + if (due.size == 1) gains[due.first()][good] = bank else gains.forEach { it.remove(good) } + } + + var s = state + val notes = mutableListOf() + for (seat in 0 until s.playerCount) { + val got = gains[seat].filterValues { it > 0 } + if (got.isEmpty()) continue + s = adjust(s, seat, got, 1) + notes += "seat $seat takes ${describeCounts(got)}" + } + val said = if (notes.isEmpty()) "nothing for anyone" else notes.joinToString(", ") + s = s.copy(log = s.log + "The dice pay: $said.") + + // The aqueduct: a seat that was paid nothing may take a resource of its choice. + val order = (0 until s.playerCount).map { (s.turn + it) % s.playerCount } + val dry = order.filter { seat -> + improvement(s, seat, Track.SCIENCE) >= IMPROVEMENT_POWER_LEVEL && gains[seat].values.sum() == 0 + } + val queued = enqueue(s, dry.map { AqueductChore(it) }, Back.AFTER_PRODUCTION) + return if (queued.ck!!.chores.isNotEmpty()) queued else queued.copy(phase = CatanPhase.PLAYING) + } + + // ----------------------------------------------------------------------- + // Turn bookkeeping and the shared moves + // ----------------------------------------------------------------------- + + /** What carries over when the turn passes: nothing that was only good for one turn. */ + fun endTurn(state: CatanState): CatanState = state.copy( + ck = state.ck!!.copy(crane = false, fleet = null, alchemist = null, bishop = false, goodsOffer = null), + ) + + /** Called after every move: a seat on the clock that has reached the target wins. */ + fun settle(state: CatanState): CatanState = when (state.phase) { + CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND, CatanPhase.CHORE, CatanPhase.GAME_OVER -> state + else -> CatanRules.checkWin(state) + } + + /** + * What the host may show [seat]: decks hidden, other seats' progress hands + * emptied, except the one a spy is looking at. + */ + fun viewFor(state: CatanState, seat: Int): CatanState { + val ck = state.ck ?: return state + val spied = (ck.chores.firstOrNull() as? SpyChore)?.takeIf { it.seat == seat }?.target + return state.copy( + ck = ck.copy( + decks = ck.decks.mapValues { emptyList() }, + progressHands = ck.progressHands.mapIndexed { i, hand -> + if (i == seat || i == spied) hand else emptyList() + }, + ), + ) + } + + // ----------------------------------------------------------------------- + // Wording + // ----------------------------------------------------------------------- + + fun good(good: Good): String = good.name.lowercase() + + private fun describe(cards: List): String = describeCounts(countGoods(cards)) + + private fun describeCounts(counts: Map): String = + counts.entries.joinToString(" + ") { (g, n) -> "$n ${good(g)}" }.ifEmpty { "nothing" } + + fun progressName(kind: ProgressType): String = + kind.name.lowercase().split('_').joinToString(" ") { it.replaceFirstChar(Char::uppercase) } + + private fun eventName(face: EventFace): String = when (face) { + EventFace.SHIP -> "a ship" + EventFace.TRADE -> "the yellow trade gate" + EventFace.POLITICS -> "the blue politics gate" + EventFace.SCIENCE -> "the green science gate" + } + + private fun terrainName(terrain: Terrain): String = terrain.name.lowercase() +} diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt new file mode 100644 index 0000000..141b7c4 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt @@ -0,0 +1,339 @@ +package org.prolibertate.games.game.catan + +/** + * The computer opponent for Cities & Knights, reached through [CatanAi]. + * + * It plays the same cautious game as the base AI — settle well, raise cities — + * and adds the three things this rule set asks of a player: keep enough active + * knights standing when the barbarians land, spend commodities on improvements + * as soon as it can, and answer every chore the table raises with the card it + * values least. It reads only what a player at the table could see, plus its + * own hand. + * + * It is not a strong player. It never moves knights about (they stay where + * hired, and are activated when the barbarians draw near), never plays the + * progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat, + * Deserter, Intrigue), and makes no domestic offers, though it answers them. + */ +internal class CatanCkAi(private val ai: CatanAi) { + + fun choose(state: CatanState, seat: Int, legal: List): CatanMove = when (state.phase) { + CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> + ai.chooseSetup(state, seat, legal.filterIsInstance()) + + CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice } + + CatanPhase.CHORE -> chooseChore(state, seat, legal) + + 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.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull() + ?: error("Nothing to choose in phase ${state.phase}") + } + + // ----------------------------------------------------------------------- + // Chores + // ----------------------------------------------------------------------- + + private fun chooseChore(state: CatanState, seat: Int, legal: List): CatanMove { + val ck = state.ck!! + return when (val chore = ck.chores.first()) { + is DiscardChore -> { + val n = minOf(chore.count, CatanCk.goodsCount(state, seat)) + DiscardGoods(CatanCk.goodsList(state, seat).sortedBy { worth(state, seat, it) }.take(n)) + } + + is GiveChore -> { + val pool = CatanCk.goodsList(state, seat) + .filter { !chore.commoditiesOnly || it.isCommodity } + .sortedBy { worth(state, seat, it) } + GiveGoods(pool.take(minOf(chore.count, pool.size))) + } + + is PillageChore -> + legal.filterIsInstance().minByOrNull { production(state, it.vertex) } ?: legal.first() + + is PickDeckChore -> { + val picks = legal.filterIsInstance() + picks.maxByOrNull { CatanCk.improvement(state, seat, it.track) } ?: legal.first() + } + + is DropProgressChore -> + legal.filterIsInstance().minByOrNull { drop -> + ck.progressHands[seat].first { it.id == drop.card }.kind.let(::cardWorth) + } ?: legal.first() + + is MetropolisChore -> + legal.filterIsInstance().maxByOrNull { production(state, it.vertex) } + ?: legal.first() + + is AqueductChore -> + legal.filterIsInstance().maxByOrNull { ai.want(it.resource) } ?: legal.first() + + is RelocateChore -> legal.first() + + is RemoveKnightChore -> + legal.filterIsInstance().minByOrNull { + CatanCk.knightAt(state, it.vertex)?.level ?: 0 + } ?: legal.first() + + is PlaceKnightChore -> legal.first() + + is SpyChore -> legal.first() + + is SmithChore -> + legal.filterIsInstance().firstOrNull() ?: legal.first { it is EndSmith } + } + } + + // ----------------------------------------------------------------------- + // The robber + // ----------------------------------------------------------------------- + + 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 + val worth = if (building.type == BuildingType.CITY) 2 else 1 + val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0 + score += if (building.owner == seat) -worth * odds else worth * odds + } + if (move.victim != null) { + score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim] + } + score + } ?: moves.first() + } + + // ----------------------------------------------------------------------- + // The main phase + // ----------------------------------------------------------------------- + + private fun choosePlaying(state: CatanState, seat: Int, legal: List): CatanMove { + val ck = state.ck!! + val soon = ck.barbarians <= 3 + + // Chase the robber off a hex it is squatting on. + if (ck.attacked) { + val onMine = legal.filterIsInstance().firstOrNull() + val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat } + if (onMine != null && robberHurtsMe) return onMine + } + + // The cheap upgrade first, then the full-price one. + legal.filterIsInstance() + .filter { kindOf(state, seat, it) == ProgressType.MEDICINE } + .maxByOrNull { production(state, it.vertex!!) } + ?.let { return it } + + legal.filterIsInstance().maxByOrNull { production(state, it.vertex) } + ?.let { return it } + + legal.filterIsInstance().maxByOrNull { ai.spotValue(state, it.vertex) } + ?.let { return it } + + // Improvements spend cards nothing else needs. Favour the track furthest along. + legal.filterIsInstance() + .maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) } + ?.let { return it } + + // Knights: stand ready by the time the barbarians land. + defence(state, seat, legal, soon)?.let { return it } + + progress(state, seat, legal)?.let { return it } + + legal.filterIsInstance().firstOrNull()?.let { + if (CatanCk.goodsCount(state, seat) >= 7) return it + } + + legal.filterIsInstance() + .filter { ai.roadValue(state, it) > 0.1 } + .maxByOrNull { ai.roadValue(state, it) } + ?.let { return it } + + legal.filterIsInstance() + .filter { completes(state, seat, it) } + .minByOrNull { CatanCk.bankRatio(state, seat, it.give) } + ?.let { return it } + + return legal.lastOrNull { it is EndTurn } ?: legal.first() + } + + /** Hire, promote and activate so the knights are standing when the barbarians land. */ + private fun defence(state: CatanState, seat: Int, legal: List, soon: Boolean): CatanMove? { + val mine = CatanCk.knightsOf(state, seat) + val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0 + + if (soon) { + legal.filterIsInstance().firstOrNull()?.let { return it } + } + val want = if (soon) 4 else 2 + if (mine.size < want) { + legal.filterIsInstance().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire -> + // Keep a grain back for waking it, once the ship is close. + if (!soon || grainLeft >= 1) return hire + } + } + if (soon || mine.size >= 2) { + legal.filterIsInstance().firstOrNull()?.let { return it } + } + if (soon) { + legal.filterIsInstance().firstOrNull()?.let { return it } + } + return null + } + + /** Progress cards worth playing without a plan. */ + private fun progress(state: CatanState, seat: Int, legal: List): CatanMove? { + val ck = state.ck!! + val soon = ck.barbarians <= 4 + val plays = legal.filterIsInstance() + fun kindOf(play: PlayProgress) = ck.progressHands[seat].first { it.id == play.card }.kind + fun first(kind: ProgressType) = plays.firstOrNull { kindOf(it) == kind } + + first(ProgressType.IRRIGATION)?.let { return it } + first(ProgressType.MINING)?.let { return it } + first(ProgressType.ENGINEER)?.let { return it } + first(ProgressType.SMITH)?.let { return it } + first(ProgressType.WEDDING)?.let { return it } + if (soon) first(ProgressType.WARLORD)?.let { return it } + + plays.filter { kindOf(it) == ProgressType.MASTER_MERCHANT } + .maxByOrNull { play -> play.goods.orEmpty().sumOf { worth(state, seat, it) } } + ?.let { return it } + + first(ProgressType.SPY)?.let { return it } + first(ProgressType.BISHOP)?.let { return it } + + if (legal.filterIsInstance().size >= 2) first(ProgressType.ROAD_BUILDING)?.let { return it } + + // Crane: only when it makes the difference to an improvement. + first(ProgressType.CRANE)?.let { crane -> + val helps = Track.entries.any { track -> + val level = CatanCk.improvement(state, seat, track) + val have = ck.commodities[seat][track.commodity] ?: 0 + level < IMPROVEMENT_MAX && have == level // one short of the next level's price + } + if (helps) return crane + } + + // Saboteur: when someone level or ahead is sitting on a pile. + first(ProgressType.SABOTEUR)?.let { saboteur -> + val vp = CatanRules.publicVictoryPoints(state) + val pile = (0 until state.playerCount).filter { it != seat && vp[it] >= vp[seat] } + .sumOf { CatanCk.goodsCount(state, it) / 2 } + if (pile >= 4) return saboteur + } + + // Monopolies: only when the table is holding a pile of it. + plays.filter { kindOf(it) == ProgressType.RESOURCE_MONOPOLY || kindOf(it) == ProgressType.TRADE_MONOPOLY } + .maxByOrNull { held(state, seat, it.good!!) } + ?.let { best -> + val each = if (kindOf(best) == ProgressType.RESOURCE_MONOPOLY) 2 else 1 + val take = (0 until state.playerCount).filter { it != seat } + .sumOf { minOf(each, CatanCk.goodsOf(state, it)[best.good!!] ?: 0) } + if (take >= 3) return best + } + + // The merchant, on the best hex beside a building of ours. + plays.filter { kindOf(it) == ProgressType.MERCHANT } + .maxByOrNull { play -> + val hex = play.hexA!! + CatanBoard.numberOdds(state.numbers[hex]).toDouble() + }?.let { return it } + + // A commercial harbour turns spare resources into commodities. + plays.filter { kindOf(it) == ProgressType.COMMERCIAL_HARBOR } + .maxByOrNull { held(state, seat, it.good!!) } + ?.let { if (held(state, seat, it.good!!) >= 2) return it } + + return null + } + + private fun kindOf(state: CatanState, seat: Int, play: PlayProgress): ProgressType = + state.ck!!.progressHands[seat].first { it.id == play.card }.kind + + /** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */ + private fun production(state: CatanState, vertex: Int): Double = + CatanBoard.VERTEX_HEXES[vertex].sumOf { h -> + val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0 + want * CatanBoard.numberOdds(state.numbers[h]) / 36.0 + } + + private fun held(state: CatanState, seat: Int, good: Good): Int = CatanCk.goodsOf(state, seat)[good] ?: 0 + + /** Whether the trade leaves a hand that can pay for a city, a settlement or an improvement. */ + private fun completes(state: CatanState, seat: Int, trade: GoodsBankTrade): Boolean { + val hand = CatanCk.goodsOf(state, seat).toMutableMap() + hand[trade.give] = (hand[trade.give] ?: 0) - CatanCk.bankRatio(state, seat, trade.give) + hand[trade.get] = (hand[trade.get] ?: 0) + 1 + fun covers(cost: Map) = cost.all { (r, n) -> (hand[Good.of(r)] ?: 0) >= n } + if (covers(CatanRules.CITY_COST) || covers(CatanRules.SETTLEMENT_COST)) return true + return Track.entries.any { track -> + val level = CatanCk.improvement(state, seat, track) + level < IMPROVEMENT_MAX && + (hand[Good.of(track.commodity)] ?: 0) >= level + 1 && + (state.ck!!.commodities[seat][track.commodity] ?: 0) < level + 1 + } + } + + private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List): CatanMove = + legal.filterIsInstance() + .filter { ai.roadValue(state, it) > 0.05 } + .maxByOrNull { ai.roadValue(state, it) } + ?: legal.firstOrNull { it is EndFreeRoads } + ?: legal.first() + + private fun chooseResponse(state: CatanState, seat: Int, legal: List): CatanMove { + val decline = legal.first { it is DeclineTrade } + val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline + val offer = state.ck?.goodsOffer + if (offer != null) { + val incoming = offer.give.sumOf { worth(state, seat, it) } + val outgoing = offer.get.sumOf { worth(state, seat, it) } + return if (incoming > outgoing) accept else decline + } + val plain = state.pendingTrade ?: return decline + val incoming = plain.give.sumOf { ai.want(it) } + val outgoing = plain.get.sumOf { ai.want(it) } + return if (incoming > outgoing) accept else decline + } + + // ----------------------------------------------------------------------- + // Valuations + // ----------------------------------------------------------------------- + + /** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */ + private fun worth(state: CatanState, seat: Int, good: Good): Double = + good.resource?.let { ai.want(it) } ?: when (good) { + Good.PAPER -> 0.95 + Good.COIN -> 0.9 + else -> 0.85 + } + + /** Which track to lean toward when two are level: politics for the knights, then science. */ + private fun trackLean(track: Track): Int = when (track) { + Track.POLITICS -> 2 + Track.SCIENCE -> 1 + Track.TRADE -> 0 + } + + private fun cardWorth(kind: ProgressType): Double = when (kind) { + ProgressType.INVENTOR, ProgressType.ALCHEMIST, ProgressType.DIPLOMAT, + ProgressType.DESERTER, ProgressType.INTRIGUE, ProgressType.MERCHANT_FLEET, + -> 0.0 + + ProgressType.MERCHANT, ProgressType.COMMERCIAL_HARBOR, ProgressType.CRANE -> 0.5 + else -> 1.0 + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkModel.kt new file mode 100644 index 0000000..9a7f530 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkModel.kt @@ -0,0 +1,460 @@ +package org.prolibertate.games.game.catan + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/* + * The data of the Cities & Knights rule set. The rules themselves live in + * [CatanCk]; docs/RULES-catan-ck.md describes them and the choices made. + * + * Everything here is reached through [CatanState.ck], which is null for the + * base game, so a base game never sees any of it. + */ + +/** Steps the barbarian ship must make before it lands. */ +const val BARBARIAN_STEPS = 7 + +/** Commodity cards of each type the bank starts with (resources have 19). */ +const val BANK_PER_COMMODITY = 12 + +/** Knights of each strength one player owns. */ +const val KNIGHTS_PER_LEVEL = 2 + +/** City walls one player owns. */ +const val MAX_WALLS = 3 + +/** Levels of city improvement in each track. */ +const val IMPROVEMENT_MAX = 5 + +/** Progress cards a player may keep in hand. */ +const val PROGRESS_HAND_MAX = 4 + +/** Cards a player may hold through a seven, before walls. */ +const val BASE_HAND_LIMIT = 7 + +/** Longest Road is worth two points here; there is no Largest Army. */ +const val CK_LONGEST_ROAD_VP = 2 + +/** Improvement level that unlocks the track's special power. */ +const val IMPROVEMENT_POWER_LEVEL = 3 + +/** Level at which a track can claim its metropolis, and the level that can steal one. */ +const val METROPOLIS_LEVEL = 4 + +/** + * What the three cities-of-industry give in place of a second resource: + * forest gives paper, pasture cloth and mountains coin. + */ +@Serializable +enum class Commodity { CLOTH, COIN, PAPER } + +/** + * Any card a player can hold in hand and trade: a resource or a commodity. + * Progress cards are not goods. + */ +@Serializable +enum class Good { + LUMBER, BRICK, WOOL, GRAIN, ORE, CLOTH, COIN, PAPER; + + val resource: Resource? + get() = when (this) { + LUMBER -> Resource.LUMBER + BRICK -> Resource.BRICK + WOOL -> Resource.WOOL + GRAIN -> Resource.GRAIN + ORE -> Resource.ORE + else -> null + } + + val commodity: Commodity? + get() = when (this) { + CLOTH -> Commodity.CLOTH + COIN -> Commodity.COIN + PAPER -> Commodity.PAPER + else -> null + } + + val isCommodity: Boolean get() = commodity != null + + companion object { + fun of(resource: Resource): Good = when (resource) { + Resource.LUMBER -> LUMBER + Resource.BRICK -> BRICK + Resource.WOOL -> WOOL + Resource.GRAIN -> GRAIN + Resource.ORE -> ORE + } + + fun of(commodity: Commodity): Good = when (commodity) { + Commodity.CLOTH -> CLOTH + Commodity.COIN -> COIN + Commodity.PAPER -> PAPER + } + + val RESOURCES: List = Resource.entries.map { of(it) } + val COMMODITIES: List = Commodity.entries.map { of(it) } + } +} + +/** + * The three tracks of city improvement. Each is bought with one commodity, has + * a colour of progress-card deck and a power that unlocks at level three. + */ +@Serializable +enum class Track(val commodity: Commodity) { + /** Yellow, bought with cloth: trade commodities two for one. */ + TRADE(Commodity.CLOTH), + + /** Blue, bought with coin: knights may become mighty. */ + POLITICS(Commodity.COIN), + + /** Green, bought with paper: the aqueduct. */ + SCIENCE(Commodity.PAPER), +} + +/** + * One face of the event die: a ship, or a city gate of a track's colour. + * Three faces are ships. + */ +@Serializable +enum class EventFace { SHIP, TRADE, POLITICS, SCIENCE } + +/** The progress cards: [copies] of each in the deck of its [track]. */ +@Serializable +enum class ProgressType(val track: Track, val copies: Int, val scoresAtOnce: Boolean = false) { + // Science (green) + ALCHEMIST(Track.SCIENCE, 2), + CRANE(Track.SCIENCE, 2), + ENGINEER(Track.SCIENCE, 1), + INVENTOR(Track.SCIENCE, 2), + IRRIGATION(Track.SCIENCE, 2), + MEDICINE(Track.SCIENCE, 2), + MINING(Track.SCIENCE, 2), + PRINTER(Track.SCIENCE, 1, scoresAtOnce = true), + ROAD_BUILDING(Track.SCIENCE, 2), + SMITH(Track.SCIENCE, 2), + + // Politics (blue) + BISHOP(Track.POLITICS, 2), + CONSTITUTION(Track.POLITICS, 1, scoresAtOnce = true), + DESERTER(Track.POLITICS, 2), + DIPLOMAT(Track.POLITICS, 2), + INTRIGUE(Track.POLITICS, 2), + SABOTEUR(Track.POLITICS, 2), + SPY(Track.POLITICS, 3), + WARLORD(Track.POLITICS, 2), + WEDDING(Track.POLITICS, 2), + + // Trade (yellow) + COMMERCIAL_HARBOR(Track.TRADE, 2), + MASTER_MERCHANT(Track.TRADE, 2), + MERCHANT(Track.TRADE, 6), + MERCHANT_FLEET(Track.TRADE, 2), + RESOURCE_MONOPOLY(Track.TRADE, 4), + TRADE_MONOPOLY(Track.TRADE, 2), +} + +/** One progress card; [id] is stable for the life of the game. */ +@Serializable +data class ProgressCard(val id: Int, val kind: ProgressType) + +/** + * A knight on the board. [level] is its strength: 1 basic, 2 strong, 3 mighty. + * + * [activatedTurn] and [promotedTurn] record the turn number each happened on: + * a knight cannot act on the turn it was activated, and cannot be promoted twice + * in one turn. + */ +@Serializable +data class Knight( + val vertex: Int, + val owner: Int, + val level: Int, + val active: Boolean = false, + val activatedTurn: Int = 0, + val promotedTurn: Int = 0, +) + +/** A metropolis stands on one city: [vertex] of seat [seat]. */ +@Serializable +data class MetropolisAt(val seat: Int, val vertex: Int) + +/** The merchant sits on a hex, controlled by [seat] (who holds its point). */ +@Serializable +data class MerchantAt(val seat: Int, val hex: Int) + +/** A domestic trade offer in goods, which may include commodities. */ +@Serializable +data class GoodsOffer(val by: Int, val to: Int, val give: List, val get: List) + +/** Where play goes when the queue of [Chore]s runs out. */ +@Serializable +enum class Back { PLAYING, AWAIT_ROLL, AFTER_EVENT, AFTER_DISCARDS, AFTER_PRODUCTION } + +/** + * Something one seat must decide before the game can go on. A chore is queued + * when a roll, an attack or a card needs an answer from somebody (possibly not + * the player whose turn it is); the first in [CkState.chores] is the one being + * asked. + */ +@Serializable +sealed interface Chore { + val seat: Int +} + +/** Give [count] cards to the bank, of the seat's choosing (a seven, a saboteur). */ +@Serializable +@SerialName("c_discard") +data class DiscardChore(override val seat: Int, val count: Int) : Chore + +/** + * Hand [count] cards of the seat's choosing to seat [to] (a wedding, a + * commercial harbour). [commoditiesOnly] restricts them to commodities, and + * [reward] is the resource [to] gives back for each. + */ +@Serializable +@SerialName("c_give") +data class GiveChore( + override val seat: Int, + val count: Int, + val to: Int, + val commoditiesOnly: Boolean = false, + val reward: Good? = null, +) : Chore + +/** The barbarians won and this seat lost the contest: pick a city to fall. */ +@Serializable +@SerialName("c_pillage") +data class PillageChore(override val seat: Int) : Chore + +/** Choose a colour of progress deck to draw from. */ +@Serializable +@SerialName("c_pickDeck") +data class PickDeckChore(override val seat: Int) : Chore + +/** More progress cards than the hand holds: give one back. */ +@Serializable +@SerialName("c_dropProgress") +data class DropProgressChore(override val seat: Int) : Chore + +/** Choose the city that will bear the [track] metropolis. */ +@Serializable +@SerialName("c_metropolis") +data class MetropolisChore(override val seat: Int, val track: Track) : Chore + +/** The aqueduct pays out: take any one resource. */ +@Serializable +@SerialName("c_aqueduct") +data class AqueductChore(override val seat: Int) : Chore + +/** A knight was driven from its place; move it, or lose it. [knight] stood on [Knight.vertex]. */ +@Serializable +@SerialName("c_relocate") +data class RelocateChore(override val seat: Int, val knight: Knight) : Chore + +/** A deserter was played against this seat: remove one of your knights. */ +@Serializable +@SerialName("c_removeKnight") +data class RemoveKnightChore(override val seat: Int, val by: Int) : Chore + +/** Place a knight of [level] on the board for free, active or not as given. */ +@Serializable +@SerialName("c_placeKnight") +data class PlaceKnightChore(override val seat: Int, val level: Int, val active: Boolean) : Chore + +/** Choose one of [target]'s progress cards to take. */ +@Serializable +@SerialName("c_spy") +data class SpyChore(override val seat: Int, val target: Int) : Chore + +/** Promote up to [left] more knights for free. */ +@Serializable +@SerialName("c_smith") +data class SmithChore(override val seat: Int, val left: Int) : Chore + +/** + * Everything Cities & Knights adds to the table. + * + * Counts that are public ([progressCounts], the knights, the improvement + * levels) are stored directly; the decks and the other seats' progress hands are + * here in full because the host owns the truth, and [CatanRules.viewFor] keeps + * them off the wire. + */ +@Serializable +data class CkState( + /** Commodity cards per seat. */ + val commodities: List>, + /** Improvement level (0..5) per seat and track. */ + val improvements: List>, + /** The metropolis of each track that has one. */ + val metropolises: Map = emptyMap(), + val knights: List = emptyList(), + /** Vertices whose city has a wall. */ + val walls: List = emptyList(), + /** Steps left before the barbarians land, [BARBARIAN_STEPS] at the start. */ + val barbarians: Int = BARBARIAN_STEPS, + /** Whether the barbarians have landed once: until then the robber sleeps. */ + val attacked: Boolean = false, + val eventDie: EventFace? = null, + /** Progress decks, top first. Order is hidden. */ + val decks: Map>, + val progressHands: List>, + /** Public mirror of the progress-hand sizes. */ + val progressCounts: List, + /** Victory points from Constitution and Printer, played face up at once. */ + val progressVp: List, + /** Defender of Catan cards won, one point each. */ + val defenderVp: List, + val merchant: MerchantAt? = null, + /** A crane was played this turn and its discount is unspent. */ + val crane: Boolean = false, + /** A merchant fleet was played this turn for this good. */ + val fleet: Good? = null, + /** Dice named by an alchemist, used by the next roll. */ + val alchemist: List? = null, + /** The robber is being moved by a bishop, which robs everyone nearby. */ + val bishop: Boolean = false, + val chores: List = emptyList(), + val back: Back = Back.PLAYING, + val goodsOffer: GoodsOffer? = null, + /** Seats that have already drawn a defender or winners' card this attack, for the log only. */ + val lastAttack: String? = null, +) + +// --------------------------------------------------------------------------- +// Moves +// --------------------------------------------------------------------------- + +/** Buy the next level of improvement in [track]. */ +@Serializable +@SerialName("buyImprovement") +data class BuyImprovement(val track: Track) : CatanMove + +/** Hire a basic knight onto [vertex], an empty crossing at the end of one of your roads. */ +@Serializable +@SerialName("hireKnight") +data class HireKnight(val vertex: Int) : CatanMove + +/** Pay a grain to activate the knight on [vertex]. */ +@Serializable +@SerialName("activateKnight") +data class ActivateKnight(val vertex: Int) : CatanMove + +/** Promote the knight on [vertex] one level (paid, or free under a Smith). */ +@Serializable +@SerialName("promoteKnight") +data class PromoteKnight(val vertex: Int) : CatanMove + +/** Move the active knight on [from] along your roads to [to], driving off a weaker knight there. */ +@Serializable +@SerialName("moveKnight") +data class MoveKnight(val from: Int, val to: Int) : CatanMove + +/** The active knight on [vertex] chases the robber; the robber move follows. */ +@Serializable +@SerialName("chaseRobber") +data class ChaseRobber(val vertex: Int) : CatanMove + +/** Raise a wall around the city on [vertex]. */ +@Serializable +@SerialName("buildWall") +data class BuildWall(val vertex: Int) : CatanMove + +/** + * Play a progress card. Which of the optional arguments are used depends on + * the card (see [CatanCk]); the engine's legal list names every combination it + * accepts, so a client only ever chooses among them. + */ +@Serializable +@SerialName("progress") +data class PlayProgress( + val card: Int, + val target: Int? = null, + val vertex: Int? = null, + val edge: Int? = null, + val hexA: Int? = null, + val hexB: Int? = null, + val good: Good? = null, + val dice: List? = null, + val goods: List? = null, +) : CatanMove + +/** + * Trade [give] to the bank for [get], at the best ratio this seat has for the + * card it gives. Replaces [MaritimeTrade] in Cities & Knights, where + * commodities trade too. + */ +@Serializable +@SerialName("goodsBank") +data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove + +/** + * Offer a trade in goods to seat [to]. Free-form like [OfferTrade], so it is + * checked structurally and is not in the enumerated move list. + */ +@Serializable +@SerialName("offerGoods") +data class OfferGoods(val to: Int, val give: List, val get: List) : CatanMove + +/** + * Give up exactly the cards named, to a [DiscardChore]. Structural, not + * enumerated; the legal list carries one example so a seat is never without a move. + */ +@Serializable +@SerialName("discardGoods") +data class DiscardGoods(val cards: List) : CatanMove + +/** Hand over the cards named to a [GiveChore]. Structural, like [DiscardGoods]. */ +@Serializable +@SerialName("giveGoods") +data class GiveGoods(val cards: List) : CatanMove + +/** Let the city on [vertex] fall to the barbarians. */ +@Serializable +@SerialName("pillageCity") +data class PillageCity(val vertex: Int) : CatanMove + +/** Draw from the deck of this track's colour. */ +@Serializable +@SerialName("pickDeck") +data class PickDeck(val track: Track) : CatanMove + +/** Give up one progress card from a hand that is over the limit. */ +@Serializable +@SerialName("dropProgress") +data class DropProgress(val card: Int) : CatanMove + +/** Put the metropolis on the city at [vertex]. */ +@Serializable +@SerialName("placeMetropolis") +data class PlaceMetropolis(val vertex: Int) : CatanMove + +/** Take one [resource] from the bank for an aqueduct. */ +@Serializable +@SerialName("aqueduct") +data class TakeAqueduct(val resource: Resource) : CatanMove + +/** Move the knight that was driven off to [to]. */ +@Serializable +@SerialName("relocateKnight") +data class RelocateKnight(val to: Int) : CatanMove + +/** Take the knight on [vertex] off the board, for a deserter. */ +@Serializable +@SerialName("removeKnight") +data class RemoveKnight(val vertex: Int) : CatanMove + +/** Place the free knight from a deserter on [vertex]. */ +@Serializable +@SerialName("placeKnight") +data class PlaceKnight(val vertex: Int) : CatanMove + +/** Take the spied progress card with this id. */ +@Serializable +@SerialName("spyTake") +data class SpyTake(val card: Int) : CatanMove + +/** Stop promoting knights under a Smith. */ +@Serializable +@SerialName("endSmith") +data object EndSmith : CatanMove diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt index 721fe9c..4ff834c 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt @@ -113,12 +113,30 @@ enum class CatanPhase { TRADE_PENDING, GAME_OVER, + + /** + * Cities & Knights only: the first seat in [CkState.chores] has something + * to decide (a discard, a city to give up, a card to draw…) and the game + * waits on it. Play resumes where [CkState.back] says. + */ + CHORE, +} + +/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */ +@Serializable +enum class CatanRuleSet { + /** The base game: development cards, Largest Army, a robber from the start. */ + BASE, + + /** Cities & Knights: commodities, knights, barbarians, city improvements. */ + CITIES_AND_KNIGHTS, } @Serializable data class CatanOptions( val playerCount: Int = 3, val pointsToWin: Int = 10, + val ruleSet: CatanRuleSet = CatanRuleSet.BASE, ) { init { require(playerCount in 3..4) { "The island seats three or four players" } @@ -190,9 +208,14 @@ data class CatanState( val rngStep: Int, val winner: Int?, val log: List, + /** Everything Cities & Knights adds; null in the base game. */ + val ck: CkState? = null, ) { val playerCount: Int get() = options.playerCount + /** Whether this table plays Cities & Knights. */ + val isCk: Boolean get() = ck != null + /** Total resource cards held by [seat]. */ fun resourceCount(seat: Int): Int = resources[seat].values.sum() @@ -227,7 +250,11 @@ data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove */ @Serializable @SerialName("roll") -data class RollDice(val forced: List? = null) : CatanMove +data class RollDice( + val forced: List? = null, + /** Test-only, like [forced]: the event die of a Cities & Knights roll. */ + val event: EventFace? = null, +) : CatanMove /** Discard exactly these cards. */ @Serializable diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt index 82fdb1d..6e7fd30 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt @@ -57,9 +57,16 @@ object CatanRules : GameRules { 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)) + val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS + // Cities & Knights has no development cards: knights and progress + // cards take their place. + val devDeck = if (ck) { + emptyList() + } else { + DEV_DECK + .mapIndexed { id, type -> DevCard(id, type) } + .shuffled(Random(config.seed + 1)) + } return CatanState( options = options, @@ -91,8 +98,10 @@ object CatanRules : GameRules { rngStep = 0, winner = null, log = listOf( - "Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.", + "Catan${if (ck) " — Cities & Knights" else ""} — ${options.playerCount} players, " + + "first to ${options.pointsToWin} victory points.", ), + ck = if (ck) CatanCk.initial(options, config.seed) else null, ) } @@ -101,7 +110,8 @@ object CatanRules : GameRules { CatanPhase.SETUP_FIRST -> state.setupIndex CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex CatanPhase.DISCARDING -> state.discardSeat - CatanPhase.TRADE_PENDING -> state.pendingTrade?.to + CatanPhase.TRADE_PENDING -> state.pendingTrade?.to ?: state.ck?.goodsOffer?.to + CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER, CatanPhase.PLAYING, CatanPhase.FREE_ROADS, -> state.turn @@ -130,12 +140,17 @@ object CatanRules : GameRules { is OfferTrade -> offerLegal(state, seat, move) - else -> move in legalMoves(state, seat) + else -> if (state.isCk) { + CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat)) + } else { + move in legalMoves(state, seat) + } } } override fun legalMoves(state: CatanState, seat: Int): List { if (currentSeat(state) != seat) return emptyList() + if (state.isCk) return CatanCk.legalMoves(state, seat) return when (state.phase) { CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state) @@ -152,11 +167,11 @@ object CatanRules : GameRules { CatanPhase.TRADE_PENDING -> tradeResponses(state, seat) - CatanPhase.GAME_OVER -> emptyList() + CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList() } } - private fun setupMoves(state: CatanState): List { + internal fun setupMoves(state: CatanState): List { val moves = mutableListOf() for (v in 0 until CatanBoard.VERTEX_COUNT) { if (!vertexOpenForSettlement(state, v)) continue @@ -276,7 +291,7 @@ object CatanRules : GameRules { * Road placements that connect to [seat]'s network, cost aside. A player * with all fifteen roads laid has none. */ - private fun roadMoves(state: CatanState, seat: Int): List = + internal fun roadMoves(state: CatanState, seat: Int): List = if (state.roads.count { it == seat } >= MAX_ROADS) { emptyList() } else { @@ -291,7 +306,7 @@ object CatanRules : GameRules { * connection requirement does not apply — that is why setup uses its * own move.) */ - private fun settlementVertices(state: CatanState, seat: Int): List = + internal fun settlementVertices(state: CatanState, seat: Int): List = if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) { emptyList() } else { @@ -301,7 +316,7 @@ object CatanRules : GameRules { } } - private fun cityVertices(state: CatanState, seat: Int): List = + internal fun cityVertices(state: CatanState, seat: Int): List = if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) { emptyList() } else { @@ -310,11 +325,14 @@ object CatanRules : GameRules { } } + private fun hasKnight(state: CatanState, vertex: Int): Boolean = + state.ck?.knights?.any { it.vertex == vertex } == true + /** How many settlements or cities [seat] currently has standing. */ private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int = state.buildings.count { it?.owner == seat && it.type == type } - private fun tradeResponses(state: CatanState, seat: Int): List { + internal 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)) { @@ -325,8 +343,8 @@ object CatanRules : GameRules { } /** An empty vertex with no building on it or one edge away. */ - private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = - state.buildings[v] == null && + internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = + state.buildings[v] == null && !hasKnight(state, v) && CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } /** @@ -345,6 +363,9 @@ object CatanRules : GameRules { /** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */ private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean { val building = state.buildings[vertex] + // A knight is a wall to everyone but its owner, like a building. + val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex } + if (knight != null && knight.owner != seat) return false // Your own building is always a way in; anyone else's is a wall. if (building != null) return building.owner == seat return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat } @@ -387,18 +408,22 @@ object CatanRules : GameRules { is MaritimeTrade -> applyMaritimeTrade(state, seat, move) EndTurn -> applyEndTurn(state, seat) EndFreeRoads -> applyEndFreeRoads(state, seat) - } + else -> CatanCk.apply(state, seat, move) + }.let { if (it.isCk) CatanCk.settle(it) else it } } private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState { val n = state.playerCount val second = state.phase == CatanPhase.SETUP_SECOND + // In Cities & Knights the second placement is a city. + val first = if (state.isCk && second) BuildingType.CITY else BuildingType.SETTLEMENT var s = state.copy( buildings = state.buildings.toMutableList().also { - it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat) + it[move.vertex] = CatanBuilding(first, 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}.", + log = state.log + ("Seat $seat ${if (first == BuildingType.CITY) "raises a city" else "settles"} " + + "at ${move.vertex} and lays a road on ${move.edge}."), ) if (second) { @@ -432,6 +457,7 @@ object CatanRules : GameRules { } private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState { + if (state.isCk) return CatanCk.applyRoll(state, seat, move) val dice = move.forced ?: derivedDice(state) val total = dice[0] + dice[1] val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1) @@ -506,6 +532,7 @@ object CatanRules : GameRules { ((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 } private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState { + if (state.isCk) return CatanCk.applyRobber(state, seat, move) val hex = CatanBoard.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}") var s = state.copy(robber = hex, rngStep = state.rngStep + 1) @@ -522,8 +549,8 @@ object CatanRules : GameRules { } 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.", + log = s.log + ("Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" + + " (${move.hex.q},${move.hex.r})$stolen."), ) } @@ -535,7 +562,7 @@ object CatanRules : GameRules { if (free) { val left = s.pendingFreeRoads - 1 s = s.copy(pendingFreeRoads = left) - if (left == 0) s = s.copy(phase = CatanPhase.PLAYING) + if (left == 0) s = s.copy(phase = phaseAfterRoads(s)) } return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")) } @@ -560,7 +587,10 @@ object CatanRules : GameRules { }, ) s = payCost(s, seat, CITY_COST) - return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.")) + s = s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.") + // A city can be what a waiting metropolis needed. + if (s.isCk) s = CatanCk.afterCityBuilt(s, seat) + return checkWin(s) } private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState { @@ -637,11 +667,12 @@ object CatanRules : GameRules { 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}.", + 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 { + if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true) val offer = state.pendingTrade!! var resources = withCards(state.resources, offer.to, offer.get, -1) resources = withCards(resources, offer.by, offer.give, -1) @@ -656,7 +687,7 @@ object CatanRules : GameRules { } private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState = - state.copy( + if (state.ck?.goodsOffer != null) CatanCk.answerOffer(state, seat, accept = false) else state.copy( phase = CatanPhase.PLAYING, pendingTrade = null, log = state.log + "Seat $seat declines the offer.", @@ -668,8 +699,8 @@ object CatanRules : GameRules { 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.", + log = s.log + ("Seat $seat trades $ratio ${move.give.name.lowercase()} for a " + + "${move.get.name.lowercase()} via $via."), ) } @@ -679,7 +710,7 @@ object CatanRules : GameRules { if (settled.phase == CatanPhase.GAME_OVER) return settled val next = (state.turn + 1) % state.playerCount - val s = state.copy( + val s = (if (state.isCk) CatanCk.endTurn(state) else state).copy( turn = next, turnNumber = state.turnNumber + 1, phase = CatanPhase.AWAIT_ROLL, @@ -692,9 +723,13 @@ object CatanRules : GameRules { return checkWin(s) } + /** Where play goes after free roads: a card played before the roll returns to the roll. */ + private fun phaseAfterRoads(state: CatanState): CatanPhase = + if (state.ck?.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING + private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState = state.copy( - phase = CatanPhase.PLAYING, + phase = phaseAfterRoads(state), pendingFreeRoads = 0, log = state.log + "Seat $seat forfeits the remaining free road.", ) @@ -710,6 +745,7 @@ object CatanRules : GameRules { * view or the host's copy. */ fun publicVictoryPoints(state: CatanState): List { + if (state.isCk) return CatanCk.publicVictoryPoints(state) val points = IntArray(state.playerCount) state.buildings.forEach { building -> if (building != null) { @@ -725,7 +761,7 @@ object CatanRules : GameRules { publicVictoryPoints(state)[seat] + state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT } - private fun checkWin(state: CatanState): CatanState { + internal fun checkWin(state: CatanState): CatanState { if (state.phase == CatanPhase.GAME_OVER) return state val seat = state.turn val points = victoryPoints(state, seat) @@ -746,7 +782,7 @@ object CatanRules : GameRules { * 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 { + internal fun refreshLongestRoad(state: CatanState): CatanState { val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) } val holder = state.longestRoadOwner val best = lengths @@ -782,6 +818,7 @@ object CatanRules : GameRules { val building = state.buildings[v] if (building != null && building.owner != seat) blocked[v] = true } + state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true } for (start in 0 until CatanBoard.VERTEX_COUNT) { if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue val visited = BooleanArray(CatanBoard.VERTEX_COUNT) @@ -843,7 +880,7 @@ object CatanRules : GameRules { 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 = + internal 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 = @@ -894,6 +931,16 @@ object CatanRules : GameRules { return best } + /** 3 if [seat] has a settlement on a generic harbour, else the bank's 4. */ + fun genericPortRatio(state: CatanState, seat: Int): Int { + for (port in state.ports) { + if (port.resource != null) continue + val (a, b) = CatanBoard.EDGE_VERTICES[port.edge] + if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio + } + return 4 + } + // ----------------------------------------------------------------------- // Results, redaction and wire format // ----------------------------------------------------------------------- @@ -908,12 +955,15 @@ object CatanRules : GameRules { 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 viewFor(state: CatanState, seat: Int): CatanState = CatanCk.viewFor( + 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() + }, + ), + seat, ) override fun encodeState(state: CatanState): String = json.encodeToString(state) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt index 2b83a41..a8b2984 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -190,6 +190,8 @@ object CatanText { "$turn is placing free roads…" } + CatanPhase.CHORE -> "Waiting for ${seats.label(onClock ?: state.turn)}…" + CatanPhase.TRADE_PENDING -> { val offer = state.pendingTrade when { diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt new file mode 100644 index 0000000..9e6db45 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt @@ -0,0 +1,956 @@ +package org.prolibertate.games.game.catan + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.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 + +/** The Cities & Knights rules, one at a time, on crafted positions. */ +class CatanCkRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(players: Int = 3, seed: Long = 7L, ck: Boolean = true) = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString( + CatanOptions( + playerCount = players, + pointsToWin = 13, + ruleSet = if (ck) CatanRuleSet.CITIES_AND_KNIGHTS else CatanRuleSet.BASE, + ), + ), + seed = seed, + ) + + /** A mid-game table on seat 0's turn, ready to act. */ + private fun table(players: Int = 3, seed: Long = 7L): CatanState = + CatanRules.initialState(config(players, seed)).let { + it.copy( + phase = CatanPhase.PLAYING, + turn = 0, + turnNumber = 1, + // Well away from the middle hex the tests play on. + robber = CatanBoard.hexIndex.getValue(HexCoord(2, -2)), + ) + } + + private fun CatanState.hand(seat: Int, vararg cards: Pair): CatanState { + val want = cards.toMap() + val resources = resources.toMutableList().also { list -> + list[seat] = Resource.entries.associateWith { want[Good.of(it)] ?: 0 } + } + val state = ck!! + val commodities = state.commodities.toMutableList().also { list -> + list[seat] = Commodity.entries.associateWith { want[Good.of(it)] ?: 0 } + } + return copy(resources = resources, ck = state.copy(commodities = commodities)) + } + + private fun CatanState.build(vertex: Int, type: BuildingType, owner: Int): CatanState = + copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) }) + + private fun CatanState.road(edge: Int, owner: Int): CatanState = + copy(roads = roads.toMutableList().also { it[edge] = owner }) + + private fun CatanState.knight(vertex: Int, owner: Int, level: Int = 1, active: Boolean = false): CatanState = + ck!!.let { state -> copy(ck = state.copy(knights = state.knights + Knight(vertex, owner, level, active))) } + + private fun CatanState.improve(seat: Int, track: Track, level: Int): CatanState = + ck!!.let { state -> + copy(ck = state.copy(improvements = state.improvements.toMutableList().also { + it[seat] = it[seat] + (track to level) + })) + } + + /** Moves a card of [kind] from its deck into [seat]'s hand. */ + private fun CatanState.holding(seat: Int, kind: ProgressType): CatanState { + val state = ck!! + val deck = state.decks.getValue(kind.track) + val card = deck.first { it.kind == kind } + val hands = state.progressHands.toMutableList().also { it[seat] = it[seat] + card } + return copy( + ck = state.copy( + decks = state.decks + (kind.track to deck.filter { it.id != card.id }), + progressHands = hands, + progressCounts = hands.map { it.size }, + ), + ) + } + + private fun CatanState.cardId(seat: Int, kind: ProgressType): Int = + ck!!.progressHands[seat].first { it.kind == kind }.id + + /** + * Sets one hex, and takes the tokens off its neighbours (and makes them + * hills) so only it can pay, or match a terrain, in the tests that follow. + */ + private fun CatanState.withHex(hex: Int, terrain: Terrain, number: Int): CatanState = + copy( + terrains = terrains.toMutableList().also { + CatanBoard.HEX_NEIGHBORS[hex].forEach { n -> it[n] = Terrain.HILLS } + it[hex] = terrain + }, + numbers = numbers.toMutableList().also { + CatanBoard.HEX_NEIGHBORS[hex].forEach { n -> it[n] = 0 } + it[hex] = number + }, + ) + + /** A path of roads along [vertices] in order, all for [owner]. */ + private fun CatanState.path(owner: Int, vararg vertices: Int): CatanState { + var s = this + for (i in 0 until vertices.size - 1) { + val a = vertices[i] + val b = vertices[i + 1] + val edge = CatanBoard.EDGE_VERTICES.indexOfFirst { it == minOf(a, b) to maxOf(a, b) } + check(edge >= 0) { "no edge between $a and $b" } + s = s.road(edge, owner) + } + return s + } + + private fun apply(state: CatanState, seat: Int, move: CatanMove): CatanState { + assertTrue("illegal $move in ${state.phase} for seat $seat", CatanRules.isLegalMove(state, seat, move)) + return CatanRules.applyMove(state, seat, move) + } + + private val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0)) + private val ring = CatanBoard.HEX_CORNERS[centre] // six crossings around the middle hex + + private fun roll(state: CatanState, dice: List, event: EventFace): CatanState = + apply(state.copy(phase = CatanPhase.AWAIT_ROLL), state.turn, RollDice(forced = dice, event = event)) + + // ----------------------------------------------------------------------- + // The rule set is chosen at setup + // ----------------------------------------------------------------------- + + @Test + fun `the base game carries none of the expansion`() { + val base = CatanRules.initialState(config(ck = false)) + assertNull(base.ck) + assertEquals(25, base.devDeck.size) + assertEquals(CatanRuleSet.BASE, base.options.ruleSet) + } + + @Test + fun `an expansion table has no development cards and a full progress deck`() { + val s = CatanRules.initialState(config()) + assertNotNull(s.ck) + assertTrue(s.devDeck.isEmpty()) + assertEquals(3, s.ck!!.decks.size) + s.ck!!.decks.values.forEach { assertEquals(18, it.size) } + assertEquals(BARBARIAN_STEPS, s.ck!!.barbarians) + assertFalse(s.ck!!.attacked) + assertTrue(CatanRules.legalMoves(s, 0).none { it is BuyDevCard }) + } + + @Test + fun `the options without a rule set still read as the base game`() { + val old = """{"playerCount":4,"pointsToWin":10}""" + val options = Json { ignoreUnknownKeys = true }.decodeFromString(old) + assertEquals(CatanRuleSet.BASE, options.ruleSet) + } + + @Test + fun `the second placement is a city and pays resources only`() { + var s = CatanRules.initialState(config()) + val ai = CatanAi() + while (s.phase == CatanPhase.SETUP_FIRST || s.phase == CatanPhase.SETUP_SECOND) { + val seat = CatanRules.currentSeat(s)!! + s = apply(s, seat, ai.chooseMove(s, seat, CatanRules.legalMoves(s, seat))) + } + for (seat in 0 until 3) { + assertEquals(1, s.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) }) + assertEquals(1, s.buildings.count { it == CatanBuilding(BuildingType.CITY, seat) }) + assertEquals(0, s.ck!!.commodities[seat].values.sum()) + } + assertEquals(CatanPhase.AWAIT_ROLL, s.phase) + // Three cities and two points each... a settlement and a city make three. + assertEquals(listOf(3, 3, 3), CatanRules.publicVictoryPoints(s)) + } + + // ----------------------------------------------------------------------- + // Production + // ----------------------------------------------------------------------- + + @Test + fun `a city on a forest pays lumber and paper, on hills two brick, a settlement just the resource`() { + var s = table().withHex(centre, Terrain.FOREST, 8) + .build(ring[0], BuildingType.CITY, 0) + .build(ring[3], BuildingType.SETTLEMENT, 1) + s = roll(s, listOf(4, 4), EventFace.SHIP) + assertEquals(1, s.resources[0][Resource.LUMBER]) + assertEquals(1, s.ck!!.commodities[0][Commodity.PAPER]) + assertEquals(1, s.resources[1][Resource.LUMBER]) + assertEquals(0, s.ck!!.commodities[1].values.sum()) + + val hills = table().withHex(centre, Terrain.HILLS, 8).build(ring[0], BuildingType.CITY, 0) + val rolled = roll(hills, listOf(3, 5), EventFace.SHIP) + assertEquals(2, rolled.resources[0][Resource.BRICK]) + assertEquals(0, rolled.ck!!.commodities[0].values.sum()) + } + + @Test + fun `pasture pays cloth and mountains coin to a city`() { + val pasture = roll( + table().withHex(centre, Terrain.PASTURE, 6).build(ring[0], BuildingType.CITY, 0), + listOf(3, 3), EventFace.SHIP, + ) + assertEquals(1, pasture.resources[0][Resource.WOOL]) + assertEquals(1, pasture.ck!!.commodities[0][Commodity.CLOTH]) + val mountains = roll( + table().withHex(centre, Terrain.MOUNTAINS, 6).build(ring[0], BuildingType.CITY, 0), + listOf(2, 4), EventFace.SHIP, + ) + assertEquals(1, mountains.resources[0][Resource.ORE]) + assertEquals(1, mountains.ck!!.commodities[0][Commodity.COIN]) + } + + @Test + fun `a bank short of a commodity pays a lone claimant what is left and two claimants nothing`() { + // Eleven paper already out; two cities would want two more. + val base = table().withHex(centre, Terrain.FOREST, 8).hand(2, Good.PAPER to 11) + val lone = roll( + base.build(ring[0], BuildingType.CITY, 0).build(ring[2], BuildingType.CITY, 0), + listOf(4, 4), EventFace.SHIP, + ) + assertEquals("the lone claimant takes the last paper", 1, lone.ck!!.commodities[0][Commodity.PAPER]) + + val two = roll( + base.build(ring[0], BuildingType.CITY, 0).build(ring[3], BuildingType.CITY, 1), + listOf(4, 4), EventFace.SHIP, + ) + assertEquals(0, two.ck!!.commodities[0][Commodity.PAPER]) + assertEquals(0, two.ck!!.commodities[1][Commodity.PAPER]) + } + + @Test + fun `the robber does not stand on the board until the barbarians land`() { + // A seven before any attack: nobody holding eight cards under a wall is bothered, and nothing is stolen. + var s = table().hand(0, Good.LUMBER to 8) + s = roll(s, listOf(3, 4), EventFace.SHIP) + assertEquals(CatanPhase.CHORE, s.phase) + assertEquals(DiscardChore(0, 4), s.ck!!.chores.first()) + s = apply(s, 0, DiscardGoods(List(4) { Good.LUMBER })) + assertEquals(CatanPhase.PLAYING, s.phase) + assertEquals(4, s.resources[0][Resource.LUMBER]) + } + + @Test + fun `after the first attack a seven moves the robber`() { + var s = table().copy(ck = table().ck!!.copy(attacked = true)) + s = roll(s, listOf(3, 4), EventFace.SHIP) + assertEquals(CatanPhase.ROBBER, s.phase) + } + + @Test + fun `a wall raises the hand limit by two`() { + val walled = table().hand(0, Good.LUMBER to 9) + .build(ring[0], BuildingType.CITY, 0) + .let { it.copy(ck = it.ck!!.copy(walls = listOf(ring[0]))) } + val s = roll(walled, listOf(3, 4), EventFace.SHIP) + // Nine cards is not more than seven plus a wall's two. + assertEquals(CatanPhase.PLAYING, s.phase) + assertEquals(9, CatanCk.handLimit(walled, 0)) + } + + // ----------------------------------------------------------------------- + // The barbarians + // ----------------------------------------------------------------------- + + private fun nearlyLanded(s: CatanState): CatanState = s.copy(ck = s.ck!!.copy(barbarians = 1)) + + @Test + fun `a ship moves the barbarians one step and a gate does not`() { + val s = roll(table(), listOf(2, 3), EventFace.SHIP) + assertEquals(BARBARIAN_STEPS - 1, s.ck!!.barbarians) + val gate = roll(table(), listOf(2, 3), EventFace.TRADE) + assertEquals(BARBARIAN_STEPS, gate.ck!!.barbarians) + } + + @Test + fun `barbarians that beat the knights pillage the weakest seat's city`() { + // Three cities against one knight's worth of defence: the seat that + // contributed least loses a city, and keeps a settlement in its place. + var s = nearlyLanded( + table() + .build(ring[0], BuildingType.CITY, 0) + .build(ring[2], BuildingType.CITY, 1) + .build(ring[4], BuildingType.CITY, 2) + .knight(CatanBoard.VERTEX_NEIGHBORS[ring[0]].first(), 0, level = 1, active = true), + ) + s = roll(s, listOf(4, 6), EventFace.SHIP) + // Seats 1 and 2 tie for weakest, each with a lone city: both fall. + assertEquals(CatanBuilding(BuildingType.CITY, 0), s.buildings[ring[0]]) + assertEquals(CatanBuilding(BuildingType.SETTLEMENT, 1), s.buildings[ring[2]]) + assertEquals(CatanBuilding(BuildingType.SETTLEMENT, 2), s.buildings[ring[4]]) + assertTrue(s.ck!!.attacked) + assertEquals(BARBARIAN_STEPS, s.ck!!.barbarians) + assertTrue("every knight stands down", s.ck!!.knights.none { it.active }) + } + + @Test + fun `a seat with several cities chooses which falls`() { + var s = nearlyLanded( + table() + .build(ring[0], BuildingType.CITY, 0) + .build(ring[2], BuildingType.CITY, 0) + .build(ring[4], BuildingType.CITY, 1) + .knight(CatanBoard.VERTEX_NEIGHBORS[ring[4]].first(), 1, level = 1, active = true), + ) + s = roll(s, listOf(4, 6), EventFace.SHIP) + assertEquals(CatanPhase.CHORE, s.phase) + assertEquals(0, CatanRules.currentSeat(s)) + val legal = CatanRules.legalMoves(s, 0) + assertEquals(setOf(PillageCity(ring[0]), PillageCity(ring[2])), legal.toSet()) + s = apply(s, 0, PillageCity(ring[2])) + assertEquals(CatanBuilding(BuildingType.SETTLEMENT, 0), s.buildings[ring[2]]) + assertEquals(CatanPhase.PLAYING, s.phase) + } + + @Test + fun `a metropolis cannot be pillaged`() { + var s = nearlyLanded( + table() + .build(ring[0], BuildingType.CITY, 0) + .build(ring[2], BuildingType.CITY, 1), + ) + s = s.copy(ck = s.ck!!.copy(metropolises = mapOf(Track.TRADE to MetropolisAt(0, ring[0])))) + s = roll(s, listOf(4, 6), EventFace.SHIP) + assertEquals(CatanBuilding(BuildingType.CITY, 0), s.buildings[ring[0]]) + assertEquals(CatanBuilding(BuildingType.SETTLEMENT, 1), s.buildings[ring[2]]) + } + + @Test + fun `when the knights hold the strongest defender takes a victory point`() { + var s = nearlyLanded( + table() + .build(ring[0], BuildingType.CITY, 0) + .build(ring[2], BuildingType.CITY, 1) + .knight(CatanBoard.VERTEX_NEIGHBORS[ring[0]].first(), 0, level = 3, active = true) + .knight(CatanBoard.VERTEX_NEIGHBORS[ring[2]].first(), 1, level = 1, active = true), + ) + val points = CatanRules.publicVictoryPoints(s) + s = roll(s, listOf(4, 6), EventFace.SHIP) + assertEquals(points[0] + 1, CatanRules.publicVictoryPoints(s)[0]) + assertEquals(points[1], CatanRules.publicVictoryPoints(s)[1]) + assertEquals(CatanBuilding(BuildingType.CITY, 1), s.buildings[ring[2]]) + } + + @Test + fun `a tie for strongest defender draws a progress card of the winner's choice instead`() { + var s = nearlyLanded( + table() + .build(ring[0], BuildingType.CITY, 0) + .knight(CatanBoard.VERTEX_NEIGHBORS[ring[0]].first(), 0, level = 2, active = true) + .knight(CatanBoard.VERTEX_NEIGHBORS[ring[3]].first(), 1, level = 2, active = true), + ) + s = roll(s, listOf(4, 6), EventFace.SHIP) + assertEquals(CatanPhase.CHORE, s.phase) + assertTrue(s.ck!!.chores.first() is PickDeckChore) + assertEquals(0, CatanRules.currentSeat(s)) + s = apply(s, 0, PickDeck(Track.SCIENCE)) + assertEquals(1, s.ck!!.progressHands[0].size + s.ck!!.progressVp[0]) + // The other tied seat chooses too. + assertEquals(1, CatanRules.currentSeat(s)) + s = apply(s, 1, PickDeck(Track.TRADE)) + assertEquals(1, s.ck!!.progressHands[1].size + s.ck!!.progressVp[1]) + assertEquals(CatanPhase.PLAYING, s.phase) + } + + // ----------------------------------------------------------------------- + // Knights + // ----------------------------------------------------------------------- + + private val a = ring[0] + private val b = ring[1] + private val c = ring[2] + private val d = ring[3] + + @Test + fun `a knight is hired at the end of a road for an ore and a wool, two of each strength`() { + val s = table().hand(0, Good.ORE to 4, Good.WOOL to 4).path(0, a, b) + val hires = CatanRules.legalMoves(s, 0).filterIsInstance().map { it.vertex }.toSet() + assertEquals(setOf(a, b), hires) + + var t = apply(s, 0, HireKnight(b)) + assertEquals(Knight(b, 0, 1), t.ck!!.knights.single()) + assertEquals(3, t.resources[0][Resource.ORE]) + t = apply(t, 0, HireKnight(a)) + // A third basic knight is refused: two of each strength. + val more = t.path(0, b, c) + assertTrue(CatanRules.legalMoves(more, 0).none { it is HireKnight }) + } + + @Test + fun `a knight cannot be hired where it has no road or on a building`() { + val s = table().hand(0, Good.ORE to 2, Good.WOOL to 2).path(0, a, b).build(b, BuildingType.SETTLEMENT, 0) + val hires = CatanRules.legalMoves(s, 0).filterIsInstance().map { it.vertex }.toSet() + assertEquals(setOf(a), hires) + } + + @Test + fun `activating costs a grain and a knight cannot act the turn it wakes`() { + var s = table().hand(0, Good.GRAIN to 1).path(0, a, b, c).knight(a, 0) + s = apply(s, 0, ActivateKnight(a)) + assertTrue(s.ck!!.knights.single().active) + assertEquals(0, s.resources[0][Resource.GRAIN]) + assertTrue("it woke this turn and may not move", CatanRules.legalMoves(s, 0).none { it is MoveKnight }) + // Next turn it can. + val next = s.copy(turnNumber = s.turnNumber + 1) + assertTrue(CatanRules.legalMoves(next, 0).any { it is MoveKnight }) + } + + @Test + fun `an active knight moves along its own roads and then rests`() { + var s = table().path(0, a, b, c).knight(a, 0, active = true) + val destinations = CatanRules.legalMoves(s, 0).filterIsInstance().map { it.to }.toSet() + assertEquals(setOf(b, c), destinations) + s = apply(s, 0, MoveKnight(a, c)) + assertEquals(c, s.ck!!.knights.single().vertex) + assertFalse(s.ck!!.knights.single().active) + } + + @Test + fun `a knight cannot pass an enemy knight, but can drive off a weaker one`() { + // a - b - c on seat 0's roads; seat 1's knight stands on b. + val base = table().path(0, a, b, c) + val weak = base.knight(a, 0, level = 2, active = true).knight(b, 1, level = 1) + val dests = CatanRules.legalMoves(weak, 0).filterIsInstance().map { it.to }.toSet() + assertEquals("it can take b but not run past it", setOf(b), dests) + + val strong = base.knight(a, 0, level = 1, active = true).knight(b, 1, level = 2) + assertTrue(CatanRules.legalMoves(strong, 0).none { it is MoveKnight }) + } + + @Test + fun `a driven knight moves along its owner's roads or is lost`() { + // Seat 1's knight sits on b with a road on to c. + val s = table().path(0, a, b).path(1, b, c).knight(a, 0, level = 2, active = true).knight(b, 1) + val driven = apply(s, 0, MoveKnight(a, b)) + assertEquals(CatanPhase.CHORE, driven.phase) + assertEquals(1, CatanRules.currentSeat(driven)) + assertEquals(listOf(RelocateKnight(c)), CatanRules.legalMoves(driven, 1)) + val done = apply(driven, 1, RelocateKnight(c)) + assertEquals(setOf(b to 0, c to 1), done.ck!!.knights.map { it.vertex to it.owner }.toSet()) + assertEquals(CatanPhase.PLAYING, done.phase) + + // With no road to flee along, the knight is simply lost. + val trapped = table().path(0, a, b).knight(a, 0, level = 2, active = true).knight(b, 1) + val gone = apply(trapped, 0, MoveKnight(a, b)) + assertEquals(CatanPhase.PLAYING, gone.phase) + assertEquals(1, gone.ck!!.knights.size) + } + + @Test + fun `promotion costs an ore and a wool and a mighty knight needs the fortress`() { + var s = table().hand(0, Good.ORE to 9, Good.WOOL to 9).path(0, a, b).knight(a, 0).knight(b, 0, level = 2) + val promotable = CatanRules.legalMoves(s, 0).filterIsInstance().map { it.vertex }.toSet() + assertEquals("the strong knight needs politics level 3", setOf(a), promotable) + + s = s.improve(0, Track.POLITICS, 3) + assertEquals( + setOf(a, b), + CatanRules.legalMoves(s, 0).filterIsInstance().map { it.vertex }.toSet(), + ) + s = apply(s, 0, PromoteKnight(b)) + assertEquals(3, s.ck!!.knights.first { it.vertex == b }.level) + s = apply(s, 0, PromoteKnight(a)) + assertEquals(2, s.ck!!.knights.first { it.vertex == a }.level) + // A knight is promoted once a turn, so the new strong knight stays as it is. + assertTrue(CatanRules.legalMoves(s, 0).none { it is PromoteKnight && it.vertex == a }) + } + + @Test + fun `an active knight beside the robber can chase it, once the barbarians have landed`() { + var s = table().path(0, a, b).knight(a, 0, active = true) + assertTrue(CatanRules.legalMoves(s.copy(robber = centre), 0).none { it is ChaseRobber }) + s = s.copy(robber = centre, ck = s.ck!!.copy(attacked = true)) + val chase = ChaseRobber(a) + assertTrue(chase in CatanRules.legalMoves(s, 0)) + s = apply(s, 0, chase) + assertEquals(CatanPhase.ROBBER, s.phase) + assertFalse(s.ck!!.knights.single().active) + } + + @Test + fun `an enemy knight blocks a road and a settlement`() { + val s = table().hand(0, Good.LUMBER to 5, Good.BRICK to 5, Good.WOOL to 5, Good.GRAIN to 5) + .path(0, a, b).knight(b, 1) + val roads = CatanRules.legalMoves(s, 0).filterIsInstance().map { CatanBoard.EDGE_VERTICES[it.edge] } + assertTrue("no road may run past the knight on b", roads.none { it.first == b || it.second == b }) + assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildSettlement && it.vertex == b }) + } + + @Test + fun `a knight cuts the longest road`() { + val line = intArrayOf(a, b, c, d, ring[4], ring[5]) + val s = table().path(0, *line) + assertEquals(5, CatanRules.longestRoadFor(s, 0)) + val cut = s.knight(c, 1) + assertTrue(CatanRules.longestRoadFor(cut, 0) < 5) + } + + // ----------------------------------------------------------------------- + // Walls + // ----------------------------------------------------------------------- + + @Test + fun `a wall costs two brick, goes on a city, and three are the most`() { + var s = table().hand(0, Good.BRICK to 8) + .build(a, BuildingType.CITY, 0).build(c, BuildingType.CITY, 0).build(ring[4], BuildingType.CITY, 0) + .build(CatanBoard.VERTEX_NEIGHBORS[a].first { it !in ring }, BuildingType.CITY, 0) + val sites = CatanRules.legalMoves(s, 0).filterIsInstance().map { it.vertex }.toSet() + assertEquals(setOf(a, c, ring[4], CatanBoard.VERTEX_NEIGHBORS[a].first { it !in ring }), sites) + s = apply(s, 0, BuildWall(a)) + assertEquals(6, s.resources[0][Resource.BRICK]) + s = apply(s, 0, BuildWall(c)) + s = apply(s, 0, BuildWall(ring[4])) + assertEquals("three walls are all a seat owns", 0, CatanRules.legalMoves(s, 0).count { it is BuildWall }) + } + + // ----------------------------------------------------------------------- + // City improvements and metropolises + // ----------------------------------------------------------------------- + + @Test + fun `an improvement needs a city and costs its next level in the track's commodity`() { + val noCity = table().hand(0, Good.CLOTH to 5) + assertTrue(CatanRules.legalMoves(noCity, 0).none { it is BuyImprovement }) + + var s = table().hand(0, Good.CLOTH to 3, Good.COIN to 0).build(a, BuildingType.CITY, 0) + assertEquals(listOf(BuyImprovement(Track.TRADE)), CatanRules.legalMoves(s, 0).filterIsInstance()) + s = apply(s, 0, BuyImprovement(Track.TRADE)) + assertEquals(1, CatanCk.improvement(s, 0, Track.TRADE)) + assertEquals(2, s.ck!!.commodities[0][Commodity.CLOTH]) + s = apply(s, 0, BuyImprovement(Track.TRADE)) + assertEquals(2, CatanCk.improvement(s, 0, Track.TRADE)) + assertEquals("the second level costs two", 0, s.ck!!.commodities[0][Commodity.CLOTH]) + } + + @Test + fun `a crane takes a commodity off the next improvement only`() { + var s = table().hand(0, Good.COIN to 2).build(a, BuildingType.CITY, 0) + .improve(0, Track.POLITICS, 1).holding(0, ProgressType.CRANE) + assertEquals(2, CatanCk.improvementCost(s, 0, Track.POLITICS)) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.CRANE))) + assertEquals(1, CatanCk.improvementCost(s, 0, Track.POLITICS)) + s = apply(s, 0, BuyImprovement(Track.POLITICS)) + assertEquals(1, s.ck!!.commodities[0][Commodity.COIN]) + assertEquals(3, CatanCk.improvementCost(s, 0, Track.POLITICS)) + } + + @Test + fun `the first to level four takes the metropolis, level five takes it from a rival at four`() { + var s = table().hand(0, Good.CLOTH to 4) + .build(a, BuildingType.CITY, 0).build(c, BuildingType.CITY, 1) + .improve(0, Track.TRADE, 3).improve(1, Track.TRADE, 4) + .let { it.copy(ck = it.ck!!.copy(metropolises = mapOf(Track.TRADE to MetropolisAt(1, c)))) } + // Seat 1 holds the metropolis at level four; seat 0 reaches four only: no change. + s = apply(s, 0, BuyImprovement(Track.TRADE)) + assertEquals(MetropolisAt(1, c), s.ck!!.metropolises[Track.TRADE]) + + // Reaching five takes it, and with one city the placement is automatic. + s = s.hand(0, Good.CLOTH to 5) + s = apply(s, 0, BuyImprovement(Track.TRADE)) + assertEquals(MetropolisAt(0, a), s.ck!!.metropolises[Track.TRADE]) + assertEquals(4, CatanRules.publicVictoryPoints(s)[0]) + assertEquals(2, CatanRules.publicVictoryPoints(s)[1]) + } + + @Test + fun `a seat with several cities picks where the metropolis goes`() { + var s = table().hand(0, Good.PAPER to 4) + .build(a, BuildingType.CITY, 0).build(c, BuildingType.CITY, 0) + .improve(0, Track.SCIENCE, 3) + s = apply(s, 0, BuyImprovement(Track.SCIENCE)) + assertEquals(CatanPhase.CHORE, s.phase) + assertEquals(setOf(PlaceMetropolis(a), PlaceMetropolis(c)), CatanRules.legalMoves(s, 0).toSet()) + s = apply(s, 0, PlaceMetropolis(c)) + assertEquals(MetropolisAt(0, c), s.ck!!.metropolises[Track.SCIENCE]) + assertEquals(CatanPhase.PLAYING, s.phase) + } + + @Test + fun `the trade track trades commodities two for one and the aqueduct feeds a dry seat`() { + val trader = table().hand(0, Good.CLOTH to 2).improve(0, Track.TRADE, 3) + assertEquals(2, CatanCk.bankRatio(trader, 0, Good.CLOTH)) + assertEquals(4, CatanCk.bankRatio(table(), 0, Good.CLOTH)) + val traded = apply(trader, 0, GoodsBankTrade(Good.CLOTH, Good.ORE)) + assertEquals(1, traded.resources[0][Resource.ORE]) + + // Seat 0 has the aqueduct and no building on a producing hex: a dry roll earns a free resource. + var s = table().improve(0, Track.SCIENCE, 3) + s = roll(s, listOf(1, 2), EventFace.TRADE) + assertEquals(CatanPhase.CHORE, s.phase) + assertTrue(s.ck!!.chores.first() is AqueductChore) + s = apply(s, 0, TakeAqueduct(Resource.ORE)) + assertEquals(1, s.resources[0][Resource.ORE]) + assertEquals(CatanPhase.PLAYING, s.phase) + } + + @Test + fun `a city gate draws progress cards for those whose red die and improvements match`() { + var s = table().improve(0, Track.SCIENCE, 2).improve(1, Track.SCIENCE, 1).improve(2, Track.TRADE, 5) + // Science gate, red die 2: seat 0 (level 2) draws; seat 1 (level 1) needs a 1. + s = roll(s, listOf(3, 2), EventFace.SCIENCE) + val drawn = (0 until 3).map { s.ck!!.progressHands[it].size + s.ck!!.progressVp[it] } + assertEquals(listOf(1, 0, 0), drawn) + } + + @Test + fun `a fifth progress card must be given back`() { + var s = table().improve(0, Track.SCIENCE, 5) + .holding(0, ProgressType.CRANE).holding(0, ProgressType.MINING) + .holding(0, ProgressType.SMITH).holding(0, ProgressType.ALCHEMIST) + s = roll(s, listOf(1, 1), EventFace.SCIENCE) + if (s.ck!!.progressVp[0] == 0) { + assertEquals(CatanPhase.CHORE, s.phase) + assertTrue(s.ck!!.chores.first() is DropProgressChore) + val drop = CatanRules.legalMoves(s, 0).first() + s = apply(s, 0, drop) + assertEquals(PROGRESS_HAND_MAX, s.ck!!.progressHands[0].size) + } + } + + // ----------------------------------------------------------------------- + // Progress cards + // ----------------------------------------------------------------------- + + @Test + fun `irrigation and mining pay two for each matching hex, cities or not`() { + val s0 = table().withHex(centre, Terrain.FIELDS, 5) + .build(ring[0], BuildingType.CITY, 0).build(ring[2], BuildingType.SETTLEMENT, 0) + .holding(0, ProgressType.IRRIGATION) + val s = apply(s0, 0, PlayProgress(s0.cardId(0, ProgressType.IRRIGATION))) + assertEquals("one hex touched by two buildings still pays two", 2, s.resources[0][Resource.GRAIN]) + + val m0 = table().withHex(centre, Terrain.MOUNTAINS, 5).build(ring[0], BuildingType.CITY, 0) + .holding(0, ProgressType.MINING) + val m = apply(m0, 0, PlayProgress(m0.cardId(0, ProgressType.MINING))) + assertEquals(2, m.resources[0][Resource.ORE]) + } + + @Test + fun `a played card goes to the bottom of its deck`() { + val s0 = table().withHex(centre, Terrain.FIELDS, 5).build(ring[0], BuildingType.CITY, 0) + .holding(0, ProgressType.IRRIGATION) + val id = s0.cardId(0, ProgressType.IRRIGATION) + val s = apply(s0, 0, PlayProgress(id)) + assertTrue(s.ck!!.progressHands[0].isEmpty()) + assertEquals(id, s.ck!!.decks.getValue(Track.SCIENCE).last().id) + assertEquals(18, s.ck!!.decks.getValue(Track.SCIENCE).size) + } + + @Test + fun `an alchemist names the dice before the roll`() { + var s = table().holding(0, ProgressType.ALCHEMIST).copy(phase = CatanPhase.AWAIT_ROLL) + val plays = CatanRules.legalMoves(s, 0).filterIsInstance() + assertEquals(36, plays.size) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.ALCHEMIST), dice = listOf(3, 5))) + assertEquals(CatanPhase.AWAIT_ROLL, s.phase) + s = apply(s, 0, RollDice(event = EventFace.TRADE)) + assertEquals(listOf(3, 5), s.lastDice) + } + + @Test + fun `an alchemist cannot be played after the roll`() { + val s = table().holding(0, ProgressType.ALCHEMIST) + assertTrue(CatanRules.legalMoves(s, 0).none { it is PlayProgress }) + } + + @Test + fun `printer and constitution score at once and are never held`() { + // Pull every card of the deck out until a victory-point card is on top, then draw it. + val s0 = table().improve(0, Track.SCIENCE, 5) + val deck = s0.ck!!.decks.getValue(Track.SCIENCE) + val printer = deck.first { it.kind == ProgressType.PRINTER } + val rigged = s0.copy( + ck = s0.ck!!.copy(decks = s0.ck!!.decks + (Track.SCIENCE to listOf(printer) + deck.filter { it.id != printer.id })), + ) + val s = roll(rigged, listOf(1, 1), EventFace.SCIENCE) + assertEquals(1, s.ck!!.progressVp[0]) + assertTrue(s.ck!!.progressHands[0].isEmpty()) + assertEquals(1, CatanRules.publicVictoryPoints(s)[0]) + } + + @Test + fun `the merchant is worth a point and trades its hex's resource two for one`() { + val s0 = table().withHex(centre, Terrain.PASTURE, 6).build(ring[0], BuildingType.SETTLEMENT, 0) + .hand(0, Good.WOOL to 2).holding(0, ProgressType.MERCHANT) + val s = apply(s0, 0, PlayProgress(s0.cardId(0, ProgressType.MERCHANT), hexA = centre)) + assertEquals(MerchantAt(0, centre), s.ck!!.merchant) + assertEquals("a settlement and the merchant", 2, CatanRules.publicVictoryPoints(s)[0]) + assertEquals(2, CatanCk.bankRatio(s, 0, Good.WOOL)) + assertEquals(4, CatanCk.bankRatio(s, 0, Good.LUMBER)) + + // Another player's merchant takes it over, with the point. + val t0 = s.holding(1, ProgressType.MERCHANT).build(ring[3], BuildingType.SETTLEMENT, 1).copy(turn = 1) + val t = apply(t0, 1, PlayProgress(t0.cardId(1, ProgressType.MERCHANT), hexA = centre)) + assertEquals(MerchantAt(1, centre), t.ck!!.merchant) + assertEquals(1, CatanRules.publicVictoryPoints(t)[0]) + assertEquals(2, CatanRules.publicVictoryPoints(t)[1]) + } + + @Test + fun `resource monopoly takes two of a resource from each other seat and trade monopoly one commodity`() { + var s = table().hand(1, Good.GRAIN to 3, Good.CLOTH to 2).hand(2, Good.GRAIN to 1, Good.CLOTH to 0) + .holding(0, ProgressType.RESOURCE_MONOPOLY).holding(0, ProgressType.TRADE_MONOPOLY) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.RESOURCE_MONOPOLY), good = Good.GRAIN)) + assertEquals(3, s.resources[0][Resource.GRAIN]) + assertEquals(1, s.resources[1][Resource.GRAIN]) + assertEquals(0, s.resources[2][Resource.GRAIN]) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.TRADE_MONOPOLY), good = Good.CLOTH)) + assertEquals(1, s.ck!!.commodities[0][Commodity.CLOTH]) + assertEquals(1, s.ck!!.commodities[1][Commodity.CLOTH]) + } + + @Test + fun `saboteur makes everyone level or ahead discard half`() { + // Seat 0 has one point (a settlement); seat 1 has two (a city); seat 2 has none. + var s = table().build(a, BuildingType.SETTLEMENT, 0).build(c, BuildingType.CITY, 1) + .hand(1, Good.LUMBER to 6).hand(2, Good.LUMBER to 6) + .holding(0, ProgressType.SABOTEUR) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.SABOTEUR))) + assertEquals(CatanPhase.CHORE, s.phase) + assertEquals(1, CatanRules.currentSeat(s)) + s = apply(s, 1, DiscardGoods(List(3) { Good.LUMBER })) + assertEquals("seat 2 has fewer points and keeps its hand", CatanPhase.PLAYING, s.phase) + assertEquals(6, s.resources[2][Resource.LUMBER]) + } + + @Test + fun `wedding takes two cards of their choosing from each seat that is ahead`() { + var s = table().build(a, BuildingType.CITY, 1).hand(1, Good.LUMBER to 1, Good.ORE to 1, Good.WOOL to 3) + .holding(0, ProgressType.WEDDING) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.WEDDING))) + assertEquals(CatanPhase.CHORE, s.phase) + assertEquals(1, CatanRules.currentSeat(s)) + s = apply(s, 1, GiveGoods(listOf(Good.WOOL, Good.WOOL))) + assertEquals(2, s.resources[0][Resource.WOOL]) + assertEquals(1, s.resources[1][Resource.WOOL]) + assertEquals(CatanPhase.PLAYING, s.phase) + } + + @Test + fun `master merchant takes two cards from a seat with more points, and only from one`() { + val s0 = table().build(a, BuildingType.CITY, 1).hand(1, Good.ORE to 2, Good.COIN to 1) + .holding(0, ProgressType.MASTER_MERCHANT) + val plays = CatanRules.legalMoves(s0, 0).filterIsInstance() + assertTrue(plays.all { it.target == 1 }) + val s = apply(s0, 0, PlayProgress(s0.cardId(0, ProgressType.MASTER_MERCHANT), target = 1, goods = listOf(Good.ORE, Good.COIN))) + assertEquals(1, s.resources[0][Resource.ORE]) + assertEquals(1, s.ck!!.commodities[0][Commodity.COIN]) + // A seat that is level or behind may not be picked on. + val level = table().build(a, BuildingType.CITY, 0).build(c, BuildingType.CITY, 1).hand(1, Good.ORE to 2) + .holding(0, ProgressType.MASTER_MERCHANT) + assertTrue(CatanRules.legalMoves(level, 0).none { it is PlayProgress }) + } + + @Test + fun `warlord wakes every knight without grain and they still cannot act that turn`() { + var s = table().path(0, a, b, c).knight(a, 0).knight(c, 0).holding(0, ProgressType.WARLORD) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.WARLORD))) + assertTrue(s.ck!!.knights.all { it.active }) + assertEquals(0, s.resources[0].values.sum()) + assertTrue(CatanRules.legalMoves(s, 0).none { it is MoveKnight }) + } + + @Test + fun `bishop moves the robber and robs every seat beside it once the barbarians have landed`() { + val s0 = table().build(ring[0], BuildingType.SETTLEMENT, 1).build(ring[2], BuildingType.SETTLEMENT, 2) + .hand(1, Good.LUMBER to 1).hand(2, Good.ORE to 1) + .holding(0, ProgressType.BISHOP) + assertTrue("the robber sleeps until the first attack", CatanRules.legalMoves(s0, 0).none { it is PlayProgress }) + val awake = s0.copy(ck = s0.ck!!.copy(attacked = true), robber = 0) + var s = apply(awake, 0, PlayProgress(awake.cardId(0, ProgressType.BISHOP))) + assertEquals(CatanPhase.ROBBER, s.phase) + s = apply(s, 0, MoveRobber(CatanBoard.HEXES[centre])) + assertEquals(1, s.resources[0][Resource.LUMBER]) + assertEquals(1, s.resources[0][Resource.ORE]) + assertEquals(0, CatanCk.goodsCount(s, 1)) + assertEquals(0, CatanCk.goodsCount(s, 2)) + assertEquals(CatanPhase.PLAYING, s.phase) + } + + @Test + fun `smith promotes two knights for free`() { + var s = table().path(0, a, b, c).knight(a, 0).knight(c, 0).holding(0, ProgressType.SMITH) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.SMITH))) + assertEquals(CatanPhase.CHORE, s.phase) + s = apply(s, 0, PromoteKnight(a)) + assertEquals(CatanPhase.CHORE, s.phase) + s = apply(s, 0, PromoteKnight(c)) + assertEquals(CatanPhase.PLAYING, s.phase) + assertEquals(listOf(2, 2), s.ck!!.knights.map { it.level }) + assertEquals(0, s.resources[0].values.sum()) + } + + @Test + fun `engineer raises a wall for nothing and medicine upgrades a settlement for less`() { + var s = table().build(a, BuildingType.CITY, 0).holding(0, ProgressType.ENGINEER) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.ENGINEER), vertex = a)) + assertEquals(listOf(a), s.ck!!.walls) + + var m = table().build(c, BuildingType.SETTLEMENT, 0).hand(0, Good.ORE to 2, Good.GRAIN to 1) + .holding(0, ProgressType.MEDICINE) + m = apply(m, 0, PlayProgress(m.cardId(0, ProgressType.MEDICINE), vertex = c)) + assertEquals(CatanBuilding(BuildingType.CITY, 0), m.buildings[c]) + assertEquals(0, m.resources[0].values.sum()) + } + + @Test + fun `inventor swaps two tokens but never a six, eight, two or twelve`() { + val s0 = table().holding(0, ProgressType.INVENTOR) + val plays = CatanRules.legalMoves(s0, 0).filterIsInstance() + assertTrue(plays.isNotEmpty()) + assertTrue(plays.all { s0.numbers[it.hexA!!] in CatanCk.INVENTOR_NUMBERS && s0.numbers[it.hexB!!] in CatanCk.INVENTOR_NUMBERS }) + val play = plays.first() + val s = apply(s0, 0, play) + assertEquals(s0.numbers[play.hexA!!], s.numbers[play.hexB!!]) + assertEquals(s0.numbers[play.hexB!!], s.numbers[play.hexA!!]) + } + + @Test + fun `diplomat removes an open road, and replaces your own for free`() { + // A short road a - b: both ends open. + var s = table().path(0, a, b).build(a, BuildingType.SETTLEMENT, 0).holding(0, ProgressType.DIPLOMAT) + val plays = CatanRules.legalMoves(s, 0).filterIsInstance() + val edge = plays.first().edge!! + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.DIPLOMAT), edge = edge)) + assertEquals(NO_PLAYER, s.roads[edge]) + assertEquals(CatanPhase.FREE_ROADS, s.phase) + assertEquals(1, s.pendingFreeRoads) + } + + @Test + fun `deserter makes a seat remove a knight and gives its player one free`() { + var s = table().path(0, a, b).path(1, c, d).knight(c, 1, level = 2).holding(0, ProgressType.DESERTER) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.DESERTER), target = 1)) + assertEquals(1, CatanRules.currentSeat(s)) + s = apply(s, 1, RemoveKnight(c)) + assertEquals(0, CatanRules.currentSeat(s)) + assertTrue(s.ck!!.knights.isEmpty()) + s = apply(s, 0, PlaceKnight(b)) + assertEquals(Knight(b, 0, 2, active = false), s.ck!!.knights.single()) + assertEquals(CatanPhase.PLAYING, s.phase) + } + + @Test + fun `intrigue drives an enemy knight off a crossing beside your road`() { + var s = table().path(0, a, b).path(1, b, c).knight(b, 1).holding(0, ProgressType.INTRIGUE) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.INTRIGUE), vertex = b)) + assertEquals(1, CatanRules.currentSeat(s)) + assertEquals(listOf(RelocateKnight(c)), CatanRules.legalMoves(s, 1)) + s = apply(s, 1, RelocateKnight(c)) + assertEquals(c, s.ck!!.knights.single().vertex) + } + + @Test + fun `spy lets you see a seat's progress cards and take one`() { + var s = table().holding(1, ProgressType.MINING).holding(1, ProgressType.SMITH).holding(0, ProgressType.SPY) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.SPY), target = 1)) + assertEquals(CatanPhase.CHORE, s.phase) + // Only the spy sees the hand while the choice is open. + assertEquals(2, CatanRules.viewFor(s, 0).ck!!.progressHands[1].size) + assertTrue(CatanRules.viewFor(s, 2).ck!!.progressHands[1].isEmpty()) + val take = s.cardId(1, ProgressType.SMITH) + s = apply(s, 0, SpyTake(take)) + assertEquals(take, s.ck!!.progressHands[0].single().id) + assertEquals(1, s.ck!!.progressHands[1].size) + } + + @Test + fun `commercial harbour swaps your resource for a commodity of each seat's choosing`() { + var s = table().hand(0, Good.WOOL to 2).hand(1, Good.CLOTH to 1, Good.COIN to 1).hand(2) + .holding(0, ProgressType.COMMERCIAL_HARBOR) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.COMMERCIAL_HARBOR), good = Good.WOOL)) + assertEquals(1, CatanRules.currentSeat(s)) + s = apply(s, 1, GiveGoods(listOf(Good.COIN))) + assertEquals(1, s.ck!!.commodities[0][Commodity.COIN]) + assertEquals(1, s.resources[1][Resource.WOOL]) + assertEquals(1, s.resources[0][Resource.WOOL]) + } + + @Test + fun `merchant fleet trades one card type two for one for the turn`() { + var s = table().hand(0, Good.PAPER to 4).holding(0, ProgressType.MERCHANT_FLEET) + s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.MERCHANT_FLEET), good = Good.PAPER)) + assertEquals(2, CatanCk.bankRatio(s, 0, Good.PAPER)) + s = apply(s, 0, GoodsBankTrade(Good.PAPER, Good.ORE)) + s = apply(s, 0, GoodsBankTrade(Good.PAPER, Good.ORE)) + assertEquals(2, s.resources[0][Resource.ORE]) + val next = apply(s, 0, EndTurn) + assertNull(next.ck!!.fleet) + } + + // ----------------------------------------------------------------------- + // Trading goods between players, and winning + // ----------------------------------------------------------------------- + + @Test + fun `players trade commodities`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1) + val offer = OfferGoods(1, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE)) + s = apply(s, 0, offer) + assertEquals(CatanPhase.TRADE_PENDING, s.phase) + assertEquals(1, CatanRules.currentSeat(s)) + assertTrue(AcceptTrade in CatanRules.legalMoves(s, 1)) + s = apply(s, 1, AcceptTrade) + assertEquals(2, s.ck!!.commodities[1][Commodity.CLOTH]) + assertEquals(1, s.resources[0][Resource.ORE]) + assertEquals(CatanPhase.PLAYING, s.phase) + assertNull(s.ck!!.goodsOffer) + } + + @Test + fun `thirteen points win, with the longest road worth two and no largest army`() { + val road = table().build(a, BuildingType.CITY, 0).path(0, *ring.toIntArray()) + assertEquals("a city is two", 2, CatanRules.publicVictoryPoints(road)[0]) + assertEquals(5, CatanRules.longestRoadFor(road, 0)) + assertEquals(4, CatanRules.publicVictoryPoints(road.copy(longestRoadOwner = 0))[0]) + + // Four cities, a settlement, the road and two defender cards make thirteen. + val far = CatanBoard.VERTEX_NEIGHBORS[a].first { it !in ring } + var s = table() + .build(ring[0], BuildingType.CITY, 0).build(ring[2], BuildingType.CITY, 0) + .build(ring[4], BuildingType.CITY, 0).build(far, BuildingType.CITY, 0) + .build(CatanBoard.VERTEX_NEIGHBORS[ring[3]].first { it !in ring }, BuildingType.SETTLEMENT, 0) + s = s.copy(longestRoadOwner = 0, ck = s.ck!!.copy(defenderVp = listOf(2, 0, 0))) + assertEquals(8 + 1 + 2 + 2, CatanRules.publicVictoryPoints(s)[0]) + s = s.copy(ck = s.ck!!.copy(defenderVp = listOf(2, 0, 0), progressVp = listOf(2, 0, 0))) + s = apply(s, 0, EndTurn) + assertEquals(CatanPhase.GAME_OVER, s.phase) + assertEquals(0, s.winner) + } + + @Test + fun `base game victory points are unchanged by the expansion code`() { + val base = CatanRules.initialState(config(ck = false)).copy(longestRoadOwner = 0, largestArmyOwner = 1) + assertEquals(listOf(5, 5, 0), CatanRules.publicVictoryPoints(base)) + } + + @Test + fun `the whole expansion state survives its wire format and keeps hidden cards hidden`() { + val s = table().holding(0, ProgressType.SMITH).holding(1, ProgressType.MINING).knight(a, 0, 2, true) + .improve(2, Track.TRADE, 3) + assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s))) + val view = CatanRules.viewFor(s, 0) + assertEquals(1, view.ck!!.progressHands[0].size) + assertTrue(view.ck!!.progressHands[1].isEmpty()) + assertEquals(listOf(1, 1, 0), view.ck!!.progressCounts) + assertTrue(view.ck!!.decks.values.all { it.isEmpty() }) + val encoded = CatanRules.encodeMove(PlayProgress(3, target = 1, goods = listOf(Good.ORE))) + assertEquals(PlayProgress(3, target = 1, goods = listOf(Good.ORE)), CatanRules.decodeMove(encoded)) + assertEquals(EndSmith, CatanRules.decodeMove(CatanRules.encodeMove(EndSmith))) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanCkSelfPlayTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkSelfPlayTest.kt new file mode 100644 index 0000000..b11e954 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkSelfPlayTest.kt @@ -0,0 +1,182 @@ +package org.prolibertate.games.game.catan + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * Engines playing Cities & Knights against each other. These are the + * playtests: every move is checked for legality, the state is round-tripped + * through its wire format as it goes, nothing may leave a seat without a move, + * and the game has to reach a result. + */ +class CatanCkSelfPlayTest { + + 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, + ) + + /** What one game looked like, for the summary printed at the end. */ + private data class Report( + val seed: Long, + val moves: Int, + val turns: Int, + val attacks: Int, + val progressPlayed: Int, + val metropolises: Int, + val winner: Int?, + val points: List, + ) + + private fun playOut(options: CatanOptions, seed: Long, cap: Int = 30_000): Pair { + val ai = CatanAi() + var state = CatanRules.initialState(config(options, seed)) + var moves = 0 + var attacks = 0 + var progress = 0 + while (!CatanRules.isFinished(state)) { + assertTrue("seed $seed ran past $cap moves; log tail: ${state.log.takeLast(8)}", moves++ < cap) + val seat = CatanRules.currentSeat(state) + ?: error("seed $seed: nobody on the clock in ${state.phase}; log tail: ${state.log.takeLast(8)}") + val legal = CatanRules.legalMoves(state, seat) + assertTrue("seed $seed: seat $seat has no legal move in ${state.phase}", legal.isNotEmpty()) + val move = ai.chooseMove(state, seat, legal) + assertTrue( + "seed $seed: seat $seat chose the illegal move $move in ${state.phase}; " + + "log tail: ${state.log.takeLast(5)}", + CatanRules.isLegalMove(state, seat, move), + ) + val before = state.ck!!.attacked + val wasAttacks = state.log.count { it.contains("The barbarians land") } + state = CatanRules.applyMove(state, seat, move) + attacks += state.log.count { it.contains("The barbarians land") } - wasAttacks + if (move is PlayProgress) progress++ + if (!before && state.ck!!.attacked) Unit + + if (moves % 97 == 0) { + val back = CatanRules.decodeState(CatanRules.encodeState(state)) + assertEquals("seed $seed: the state did not survive its wire format", state, back) + // A seat's view never carries another seat's progress cards or the decks. + for (viewer in 0 until options.playerCount) { + val view = CatanRules.viewFor(state, viewer) + assertTrue(view.ck!!.decks.values.all { it.isEmpty() }) + for (other in 0 until options.playerCount) { + if (other != viewer) { + val spied = (state.ck!!.chores.firstOrNull() as? SpyChore) + val allowed = spied != null && spied.seat == viewer && spied.target == other + if (!allowed) assertTrue(view.ck!!.progressHands[other].isEmpty()) + } + } + } + } + checkInvariants(state, seed) + } + val ck = state.ck!! + return state to Report( + seed = seed, + moves = moves, + turns = state.turnNumber, + attacks = attacks, + progressPlayed = progress, + metropolises = ck.metropolises.size, + winner = state.winner, + points = CatanRules.publicVictoryPoints(state), + ) + } + + /** Things that must hold in every state of every game. */ + private fun checkInvariants(state: CatanState, seed: Long) { + val ck = state.ck!! + val tag = "seed $seed turn ${state.turnNumber}" + // Cards are conserved: no good is held in more copies than the bank started with. + for (good in Good.entries) { + val held = (0 until state.playerCount).sumOf { CatanCk.goodsOf(state, it)[good] ?: 0 } + val cap = if (good.isCommodity) BANK_PER_COMMODITY else BANK_PER_RESOURCE + assertTrue("$tag: $held ${good} cards exist, the bank only had $cap", held in 0..cap) + } + for (seat in 0 until state.playerCount) { + assertTrue( + "$tag: seat $seat holds negative cards", + CatanCk.goodsOf(state, seat).values.all { it >= 0 }, + ) + for (level in 1..3) { + assertTrue( + "$tag: seat $seat has more than two level-$level knights", + ck.knights.count { it.owner == seat && it.level == level } <= KNIGHTS_PER_LEVEL, + ) + } + assertTrue("$tag: too many progress cards in seat $seat's hand", ck.progressHands[seat].size <= PROGRESS_HAND_MAX + 1) + assertTrue("$tag: seat $seat has more than three walls", CatanCk.wallsOf(state, seat) <= MAX_WALLS) + assertTrue( + "$tag: seat $seat has more than five settlements", + state.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) } <= MAX_SETTLEMENTS, + ) + assertTrue( + "$tag: seat $seat has more than four cities", + state.buildings.count { it == CatanBuilding(BuildingType.CITY, seat) } <= MAX_CITIES, + ) + } + // A knight stands on a crossing nothing else stands on, and no two share one. + assertEquals("$tag: two knights share a crossing", ck.knights.size, ck.knights.map { it.vertex }.toSet().size) + for (k in ck.knights) { + assertTrue("$tag: a knight stands on a building at ${k.vertex}", state.buildings[k.vertex] == null) + } + // Metropolises stand on cities of their owners. + for ((_, m) in ck.metropolises) { + assertEquals( + "$tag: a metropolis is not on its owner's city", + CatanBuilding(BuildingType.CITY, m.seat), + state.buildings[m.vertex], + ) + } + // Every progress card exists exactly once, in a deck or a hand, or was played face up. + val cards = ck.decks.values.sumOf { it.size } + ck.progressHands.sumOf { it.size } + ck.progressVp.sum() + assertEquals("$tag: progress cards were lost or duplicated", 54, cards) + assertTrue("$tag: barbarians out of range", ck.barbarians in 1..BARBARIAN_STEPS) + } + + @Test + fun `three engines play Cities and Knights to a finish`() { + val reports = (1L..12L).map { seed -> + val (state, report) = playOut( + CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS), + 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!!) >= 13, + ) + report + } + reports.forEach { println("CK3 $it") } + } + + @Test + fun `four engines play Cities and Knights to a finish`() { + val reports = (101L..108L).map { seed -> + val (state, report) = playOut( + CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS), + seed, + ) + assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase) + report + } + reports.forEach { println("CK4 $it") } + } +}