diff --git a/README.md b/README.md index e45a4ec..33f7750 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,9 @@ game: the game is out of print and the rulebook could not be obtained, so the document says which parts are attested and which were derived - [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the - rules engine and AI are in place, the screen lands with its own task + rules engine, AI and screen are in place +- [Katan: Cities & Knights](docs/RULES-catan-ck.md) — the expansion rule set, chosen on + the setup screen: commodities, knights, barbarians, city improvements and progress cards ## Layout @@ -396,7 +398,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested mastermind/ Mastermind codes, scoring, and a code breaker pirates/ Pirates and Bulgars — the cross board, the hunt, search tayu/ Ta Yü tile geometry, river rules, scoring, AI - catan/ Katan island geometry, production, trade, dev cards, heuristic AI + catan/ Katan island geometry, production, trade, dev cards, Cities & Knights, heuristic AI net/ wire protocol, LAN transport, lobby, match driver score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store settings/ DataStore-backed preferences diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index abb732a..fa63168 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -360,7 +360,7 @@ object GameCatalog { minPlayers = 3, maxPlayers = 4, teamBased = false, - blurb = "Trade, build, and settle the island.", + blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.", // Engine and screen exist but have not been verified on two real // devices yet. Dev builds can open it as a *Preview*; a production // build does not list it. Flip `available` on, and `devPreview` 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 5dd53b3..db56508 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()) @@ -42,8 +46,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}") } } @@ -51,7 +55,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) + @@ -293,7 +297,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() @@ -314,7 +318,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)) { @@ -326,7 +330,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..be3decf --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt @@ -0,0 +1,1603 @@ +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.TRADE_COUNTERS -> { + val offer = state.ck!!.goodsOffer + if (offer == null) { + CatanRules.counterChoices(state) + } else { + val moves = mutableListOf() + for (counter in offer.counters) { + if (holds(state, counter.by, counter.give) && holds(state, offer.by, counter.get)) { + moves += AcceptCounter(counter.by) + } + } + moves += DismissCounters + moves + } + } + + 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 == null || (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 CounterGoods -> { + val offer = state.ck?.goodsOffer + offer != null && state.phase == CatanPhase.TRADE_PENDING && offer.asking == seat && + move.give.isNotEmpty() && move.get.isNotEmpty() && + countGoods(move.give) != countGoods(move.get) && + // Taking the offer as made is Accept, not a counter. + !(countGoods(move.give) == countGoods(offer.get) && countGoods(move.get) == countGoods(offer.give)) && + holds(state, seat, move.give) && holds(state, offer.by, move.get) + } + + 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 CounterGoods -> applyCounterGoods(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 { + val audience = if (move.to == null) "the table" else "seat ${move.to}" + val offered = state.copy( + log = state.log + "Seat $seat offers ${describe(move.give)} for ${describe(move.get)} to $audience.", + ) + val offer = GoodsOffer(by = seat, to = move.to, give = move.give, get = move.get) + val first = nextAsker(offered, offer, seat) + ?: return offered.copy(log = offered.log + "Nobody has any cards to trade.") + return offered.copy( + phase = CatanPhase.TRADE_PENDING, + ck = offered.ck!!.copy(goodsOffer = offer.copy(asking = first)), + ) + } + + /** + * The next seat to ask after [after], or null when the round is over. An + * addressed offer asks only its addressee; an open one walks the table in seat + * order, skipping seats with no cards (they could neither pay nor counter). + */ + private fun nextAsker(state: CatanState, offer: GoodsOffer, after: Int): Int? { + if (offer.to != null) return if (after == offer.by) offer.to else null + var s = (after + 1) % state.playerCount + while (s != offer.by) { + if (goodsCount(state, s) > 0) return s + s = (s + 1) % state.playerCount + } + return null + } + + /** Moves the offer on to the next seat, or closes the round when everyone has answered. */ + private fun advanceOffer(state: CatanState, offer: GoodsOffer, after: Int, said: String): CatanState { + val next = nextAsker(state, offer, after) + val log = state.log + said + val ck = state.ck!! + if (next != null) { + return state.copy(ck = ck.copy(goodsOffer = offer.copy(asking = next)), log = log) + } + if (offer.counters.isNotEmpty()) { + return state.copy( + phase = CatanPhase.TRADE_COUNTERS, + ck = ck.copy(goodsOffer = offer.copy(asking = -1)), + log = log, + ) + } + return state.copy( + phase = CatanPhase.PLAYING, + ck = ck.copy(goodsOffer = null), + log = log + "Nobody takes the offer.", + ) + } + + /** Accepting or declining the [GoodsOffer] on the table, called by [CatanRules] when one is pending. */ + fun answerOffer(state: CatanState, seat: Int, accept: Boolean): CatanState { + val offer = state.ck!!.goodsOffer!! + if (!accept) return advanceOffer(state, offer, seat, "Seat $seat declines the offer.") + var s = give(state, offer.by, seat, offer.give) + s = give(s, seat, offer.by, offer.get) + return s.copy( + phase = CatanPhase.PLAYING, + ck = s.ck!!.copy(goodsOffer = null), + log = s.log + "Seat $seat accepts — the cards cross the table.", + ) + } + + private fun applyCounterGoods(state: CatanState, seat: Int, move: CounterGoods): CatanState { + val offer = state.ck!!.goodsOffer!! + val counter = GoodsCounter(by = seat, give = move.give, get = move.get) + return advanceOffer( + state, + offer.copy(counters = offer.counters + counter), + seat, + "Seat $seat counters: ${describe(move.give)} for ${describe(move.get)}.", + ) + } + + /** The offerer takes the counter made by [move]'s seat. */ + fun acceptCounter(state: CatanState, move: AcceptCounter): CatanState { + val offer = state.ck!!.goodsOffer!! + val counter = offer.counters.first { it.by == move.seat } + var s = give(state, offer.by, counter.by, counter.get) + s = give(s, counter.by, offer.by, counter.give) + return s.copy( + phase = CatanPhase.PLAYING, + ck = s.ck!!.copy(goodsOffer = null), + log = s.log + "Seat ${offer.by} takes seat ${counter.by}'s counter — the cards cross the table.", + ) + } + + fun dismissCounters(state: CatanState): CatanState = state.copy( + phase = CatanPhase.PLAYING, + ck = state.ck!!.copy(goodsOffer = null), + log = state.log + "Seat ${state.turn} turns the counter-offers down.", + ) + + // --- 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..28aac5c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt @@ -0,0 +1,345 @@ +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.TRADE_COUNTERS -> chooseCounter(state, 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 + } + + /** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */ + private fun chooseCounter(state: CatanState, legal: List): CatanMove = + legal.firstOrNull { it is DismissCounters } ?: legal.first() + + // ----------------------------------------------------------------------- + // 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..068867c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkModel.kt @@ -0,0 +1,486 @@ +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 counter-proposal to a [GoodsOffer]: seat [by] would [give] these if the offerer handed over [get]. */ +@Serializable +data class GoodsCounter(val by: Int, val give: List, val get: List) + +/** + * A domestic trade offer in goods, which may include commodities. It is the + * base game's [TradeOffer] in cards of any kind, and works the same way: [to] is + * the one seat it is addressed to or null for the whole table, [asking] is the + * seat that may answer now, and [counters] collect for the offerer to choose from. + */ +@Serializable +data class GoodsOffer( + val by: Int, + val to: Int?, + val give: List, + val get: List, + val asking: Int = to ?: -1, + val counters: List = emptyList(), +) + +/** 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], or to the whole table when [to] is null. + * 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 + +/** + * Answer the goods offer on the table with different terms. Structural, like + * [OfferGoods]; the answers to the counters are the shared [AcceptCounter] and + * [DismissCounters]. + */ +@Serializable +@SerialName("counterGoods") +data class CounterGoods(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 e361253..8e19895 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 @@ -133,12 +133,30 @@ enum class CatanPhase { TRADE_COUNTERS, 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" } @@ -210,9 +228,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() @@ -247,7 +270,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 6d98c86..904b9bb 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, ) } @@ -102,7 +111,9 @@ object CatanRules : GameRules { CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex CatanPhase.DISCARDING -> state.discardSeat CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 } + ?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 } CatanPhase.TRADE_COUNTERS -> state.turn + CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER, CatanPhase.PLAYING, CatanPhase.FREE_ROADS, -> state.turn @@ -133,12 +144,17 @@ object CatanRules : GameRules { is CounterTrade -> counterLegal(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) @@ -157,11 +173,11 @@ object CatanRules : GameRules { CatanPhase.TRADE_COUNTERS -> counterChoices(state) - 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 @@ -281,7 +297,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 { @@ -296,7 +312,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 { @@ -306,7 +322,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 { @@ -315,11 +331,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)) { @@ -330,7 +349,7 @@ object CatanRules : GameRules { } /** What the offerer may do with the counters: take one that is still affordable, or none. */ - private fun counterChoices(state: CatanState): List { + internal fun counterChoices(state: CatanState): List { val offer = state.pendingTrade ?: return emptyList() val moves = mutableListOf() for (counter in offer.counters) { @@ -353,8 +372,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 } /** @@ -373,6 +392,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 } @@ -418,18 +440,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) { @@ -463,6 +489,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) @@ -537,6 +564,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) @@ -553,8 +581,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."), ) } @@ -566,7 +594,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}.")) } @@ -591,7 +619,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 { @@ -730,6 +761,7 @@ object CatanRules : GameRules { } private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState { + if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true) val offer = state.pendingTrade!! return state.copy( phase = CatanPhase.PLAYING, @@ -740,7 +772,11 @@ object CatanRules : GameRules { } private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState = - advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.") + if (state.ck?.goodsOffer != null) { + CatanCk.answerOffer(state, seat, accept = false) + } else { + advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.") + } private fun applyCounterTrade(state: CatanState, seat: Int, move: CounterTrade): CatanState { val offer = state.pendingTrade!! @@ -754,6 +790,7 @@ object CatanRules : GameRules { } private fun applyAcceptCounter(state: CatanState, move: AcceptCounter): CatanState { + if (state.ck?.goodsOffer != null) return CatanCk.acceptCounter(state, move) val offer = state.pendingTrade!! val counter = offer.counters.first { it.by == move.seat } return state.copy( @@ -765,7 +802,7 @@ object CatanRules : GameRules { } private fun applyDismissCounters(state: CatanState): CatanState = - state.copy( + if (state.ck?.goodsOffer != null) CatanCk.dismissCounters(state) else state.copy( phase = CatanPhase.PLAYING, pendingTrade = null, log = state.log + "Seat ${state.turn} turns the counter-offers down.", @@ -777,8 +814,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."), ) } @@ -788,7 +825,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, @@ -801,9 +838,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.", ) @@ -819,6 +860,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) { @@ -834,7 +876,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) @@ -855,7 +897,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 @@ -891,6 +933,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) @@ -952,7 +995,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 = @@ -1003,6 +1046,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 // ----------------------------------------------------------------------- @@ -1017,12 +1070,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/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index eca5a11..4363df7 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -58,6 +58,7 @@ import org.prolibertate.games.game.pirates.PiratesLevel import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.catan.CatanOptions +import org.prolibertate.games.game.catan.CatanRuleSet import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuOptions @@ -334,9 +335,55 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId) else -> seat } +/** The targets the setup screen offers for a Katan rule set. */ +internal fun catanPointsChoices(ruleSet: CatanRuleSet): List = when (ruleSet) { + // Five is a short game for trying things out; ten is the published target. + CatanRuleSet.BASE -> listOf(5, 8, 10, 12) + // Cities & Knights is played to thirteen, which is also why it runs longer. + CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15) +} + +/** The published target for a rule set. */ +internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) { + CatanRuleSet.BASE -> 10 + CatanRuleSet.CITIES_AND_KNIGHTS -> 13 +} + +/** + * [options] switched to [ruleSet]. The target moves to the new rule set's own + * default unless the one already chosen is on its menu too. + */ +internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions = + options.copy( + ruleSet = ruleSet, + pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) } + ?: catanDefaultPoints(ruleSet), + ) + +private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) { + CatanRuleSet.BASE -> "Base game" + CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights" +} + +private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) { + CatanRuleSet.BASE -> + "Settle the island, trade, and play development cards. Longest Road and Largest Army score." + CatanRuleSet.CITIES_AND_KNIGHTS -> + "Cities make commodities, knights hold off the barbarians, and improvements earn progress " + + "cards and metropolises. No development cards; the robber sleeps until the first attack." +} + @Composable private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Rules", + values = CatanRuleSet.entries, + selected = options.ruleSet, + display = ::catanRuleSetName, + onSelect = { onChange(catanSwitchRules(options, it)) }, + ) + Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall) ChipRow( label = "Players", values = listOf(3, 4), @@ -346,8 +393,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) - ) ChipRow( label = "Game to", - // Five is a short game for trying things out; ten is the published target. - values = listOf(5, 8, 10, 12), + values = catanPointsChoices(options.ruleSet), selected = options.pointsToWin, display = { "$it points" }, onSelect = { onChange(options.copy(pointsToWin = it)) }, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt index 69df57b..b9f4c78 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke @@ -58,6 +59,8 @@ fun CatanBoardView( style: CatanStyle = LocalCatanStyle.current, /** Hexes to light up as having produced on the latest roll. */ rolledHexes: Set = emptySet(), + /** Cities & Knights: the knight whose menu is open or that is about to move. */ + selectedKnight: Int? = null, ) { val measurer = rememberTextMeasurer() var size by remember { mutableStateOf(IntSize.Zero) } @@ -86,8 +89,8 @@ fun CatanBoardView( val at = Pt(tap.x, tap.y) val reach = board.radius * style.tapReach when { - targets.vertices.isNotEmpty() -> - board.nearestVertex(at, reach, targets.vertices)?.let { vertexTap(it) } + targets.tappableVertices.isNotEmpty() -> + board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) } targets.edges.isNotEmpty() -> board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) } @@ -105,10 +108,15 @@ fun CatanBoardView( drawHexes(state, board, style, measurer) drawRolled(rolledHexes, board, style) drawRobber(state, board, style) + drawMerchant(state, board, style, measurer) drawRoads(state, board, style) drawEdgeTargets(targets, board, style) drawBuildings(state, board, style) - drawVertexTargets(targets, selectedVertex, board, style) + drawWalls(state, board, style) + drawMetropolises(state, board, style) + drawKnights(state, board, style) + drawKnightTargets(targets, board, style) + drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style) drawHexTargets(targets, board, style) } } @@ -185,6 +193,10 @@ private fun DrawScope.drawToken( } private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) { + // In Cities & Knights the robber sleeps, drawn faintly, until the barbarians first land. + val asleep = state.ck?.attacked == false + val tint = if (asleep) style.robber.copy(alpha = 0.3f) else style.robber + val rim = Color.White.copy(alpha = if (asleep) 0.25f else 0.7f) val hex = board.hexCenter(state.robber) // Beside the number token rather than on it, so the number stays readable. val x = hex.x + board.radius * 0.5f @@ -197,11 +209,11 @@ private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: C lineTo(x + s * 0.55f, y + s * 0.9f) close() } - drawPath(pawn, color = style.robber) - drawCircle(color = style.robber, radius = s * 0.38f, center = Offset(x, y - s * 0.42f)) - drawPath(pawn, color = Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f)) + drawPath(pawn, color = tint) + drawCircle(color = tint, radius = s * 0.38f, center = Offset(x, y - s * 0.42f)) + drawPath(pawn, color = rim, style = Stroke(width = s * 0.07f)) drawCircle( - color = Color.White.copy(alpha = 0.7f), + color = rim, radius = s * 0.38f, center = Offset(x, y - s * 0.42f), style = Stroke(width = s * 0.07f), @@ -409,6 +421,131 @@ private fun cityPath(at: Pt, s: Float): Path = Path().apply { close() } +// --------------------------------------------------------------------------- +// Cities & Knights pieces +// --------------------------------------------------------------------------- + +/** A dashed wall round each walled city, crenellated by the dashes. */ +private fun DrawScope.drawWalls(state: CatanState, board: BoardLayout, style: CatanStyle) { + val walls = state.ck?.walls ?: return + for (vertex in walls) { + val at = board.vertex(vertex).offset() + val radius = board.radius * style.citySize * 1.25f + val dash = PathEffect.dashPathEffect(floatArrayOf(radius * 0.38f, radius * 0.22f)) + drawCircle( + color = style.pieceOutline, + radius = radius, + center = at, + style = Stroke(width = board.radius * 0.13f, pathEffect = dash), + ) + drawCircle( + color = style.wall, + radius = radius, + center = at, + style = Stroke(width = board.radius * 0.09f, pathEffect = dash), + ) + } +} + +/** A pennant in the track's colour flying over a city that carries its metropolis. */ +private fun DrawScope.drawMetropolises(state: CatanState, board: BoardLayout, style: CatanStyle) { + val metropolises = state.ck?.metropolises ?: return + for ((track, at) in metropolises) { + val base = board.vertex(at.vertex) + val unit = board.radius * style.citySize + val poleTop = Pt(base.x + unit * 0.9f, base.y - unit * 2.0f) + val poleBottom = Pt(base.x + unit * 0.9f, base.y - unit * 0.4f) + drawLine( + color = style.pieceOutline, + start = poleBottom.offset(), + end = poleTop.offset(), + strokeWidth = board.radius * 0.05f, + ) + val flag = Path().apply { + moveTo(poleTop.x, poleTop.y) + lineTo(poleTop.x + unit * 1.1f, poleTop.y + unit * 0.35f) + lineTo(poleTop.x, poleTop.y + unit * 0.7f) + close() + } + drawPath(flag, color = style.track.getValue(track)) + drawPath(flag, color = style.pieceOutline, style = Stroke(width = board.radius * 0.035f)) + } +} + +/** + * Each knight is a shield in its owner's colour, marked with one, two or three + * dots for its strength. A sleeping knight is drawn faded; an awake one has a + * bright rim. + */ +private fun DrawScope.drawKnights(state: CatanState, board: BoardLayout, style: CatanStyle) { + val knights = state.ck?.knights ?: return + for (knight in knights) { + val at = board.vertex(knight.vertex) + val s = board.radius * style.knightSize + val seat = style.seats[knight.owner % style.seats.size] + val path = shieldPath(at, s) + if (knight.active) { + drawPath(path, color = style.knightReady, style = Stroke(width = s * 0.55f)) + } + drawPath(path, color = if (knight.active) seat else seat.copy(alpha = 0.55f)) + drawPath(path, color = style.pieceOutline, style = Stroke(width = s * 0.16f)) + val pip = s * 0.14f + val gap = pip * 2.8f + val startX = at.x - gap * (knight.level - 1) / 2f + for (i in 0 until knight.level) { + drawCircle( + color = style.pieceOutline, + radius = pip, + center = Offset(startX + i * gap, at.y - s * 0.05f), + ) + } + } +} + +private fun shieldPath(at: Pt, s: Float): Path = Path().apply { + moveTo(at.x - s * 0.8f, at.y - s) + lineTo(at.x + s * 0.8f, at.y - s) + lineTo(at.x + s * 0.8f, at.y + s * 0.1f) + quadraticBezierTo(at.x + s * 0.8f, at.y + s * 0.9f, at.x, at.y + s * 1.25f) + quadraticBezierTo(at.x - s * 0.8f, at.y + s * 0.9f, at.x - s * 0.8f, at.y + s * 0.1f) + close() +} + +/** A ring round each of the player's knights that can do something now. */ +private fun DrawScope.drawKnightTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) { + for (vertex in targets.knights) { + drawCircle( + color = style.target, + radius = board.radius * style.knightSize * 1.9f, + center = board.vertex(vertex).offset(), + style = Stroke(width = board.radius * 0.07f), + ) + } +} + +/** The merchant: a coin in its controller's colour, beside the hex it sits on. */ +private fun DrawScope.drawMerchant( + state: CatanState, + board: BoardLayout, + style: CatanStyle, + measurer: TextMeasurer, +) { + val merchant = state.ck?.merchant ?: return + val hex = board.hexCenter(merchant.hex) + val at = Pt(hex.x - board.radius * 0.5f, hex.y + board.radius * 0.3f) + val r = board.radius * 0.2f + drawCircle(color = style.seats[merchant.seat % style.seats.size], radius = r, center = at.offset()) + drawCircle(color = style.pieceOutline, radius = r, center = at.offset(), style = Stroke(width = r * 0.3f)) + drawCentered( + measurer = measurer, + text = "M", + at = at, + fontPx = r * 1.3f, + color = style.pieceOutline, + weight = FontWeight.ExtraBold, + ) +} + // --------------------------------------------------------------------------- // Tap targets // --------------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt new file mode 100644 index 0000000..73c8920 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt @@ -0,0 +1,813 @@ +package org.prolibertate.games.ui.game.catan + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.catan.AcceptCounter +import org.prolibertate.games.game.catan.ActivateKnight +import org.prolibertate.games.game.catan.BuyImprovement +import org.prolibertate.games.game.catan.CatanCk +import org.prolibertate.games.game.catan.CatanMove +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.ChaseRobber +import org.prolibertate.games.game.catan.CounterGoods +import org.prolibertate.games.game.catan.DropProgress +import org.prolibertate.games.game.catan.GoodsBankTrade +import org.prolibertate.games.game.catan.Good +import org.prolibertate.games.game.catan.GoodsCounter +import org.prolibertate.games.game.catan.GoodsOffer +import org.prolibertate.games.game.catan.IMPROVEMENT_MAX +import org.prolibertate.games.game.catan.MoveKnight +import org.prolibertate.games.game.catan.OfferGoods +import org.prolibertate.games.game.catan.PickDeck +import org.prolibertate.games.game.catan.PlayProgress +import org.prolibertate.games.game.catan.ProgressCard +import org.prolibertate.games.game.catan.ProgressType +import org.prolibertate.games.game.catan.PromoteKnight +import org.prolibertate.games.game.catan.SpyTake +import org.prolibertate.games.game.catan.TakeAqueduct +import org.prolibertate.games.game.catan.Track + +/* + * The Cities & Knights dialogs. Like the base game's, each is a stateless + * composable apart from the choice it is helping the player make, and what is + * allowed is always asked of the engine's legal list through CatanInteraction. + */ + +// --------------------------------------------------------------------------- +// Shared pieces +// --------------------------------------------------------------------------- + +/** A card tile with a minus and a plus, for choosing how many of a good. */ +@Composable +private fun GoodCounter(good: Good, count: Int, max: Int, onChange: (Int) -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + GoodTile(good, count, enabled = max > 0 || count > 0) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedButton( + onClick = { onChange(count - 1) }, + enabled = count > 0, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.width(34.dp).height(32.dp), + ) { Text("−") } + OutlinedButton( + onClick = { onChange(count + 1) }, + enabled = count < max, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.width(34.dp).height(32.dp), + ) { Text("+") } + } + } +} + +private fun Map.asGoods(): List = Good.entries.flatMap { g -> List(this[g] ?: 0) { g } } + +/** A row of the three track colours, used for decks and improvements. */ +@Composable +private fun TrackChip(track: Track, style: CatanStyle = LocalCatanStyle.current) { + Box( + modifier = Modifier + .size(14.dp) + .clip(RoundedCornerShape(3.dp)) + .background(style.track.getValue(track)), + ) +} + +// --------------------------------------------------------------------------- +// Choosing cards to give up +// --------------------------------------------------------------------------- + +/** + * Pick exactly [quota] cards out of [held] — to discard on a seven or a saboteur, + * or to hand over for a wedding or a commercial harbour. Not dismissable: the + * game is waiting on it. [allowed] is the engine's structural check, so the + * confirm button only lights when the move would be accepted. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanGoodsPickDialog( + title: String, + intro: String, + held: Map, + quota: Int, + confirmLabel: String, + selectable: (Good) -> Boolean, + allowed: (List) -> Boolean, + onConfirm: (List) -> Unit, +) { + var picked by remember { mutableStateOf(emptyMap()) } + val selection = picked.asGoods() + AlertDialog( + onDismissRequest = {}, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("$intro (${selection.size} of $quota chosen).") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Good.entries.forEach { good -> + val have = held[good] ?: 0 + if (have > 0 && selectable(good)) { + GoodCounter( + good = good, + count = picked[good] ?: 0, + max = if (selection.size < quota) have else (picked[good] ?: 0), + onChange = { n -> picked = picked + (good to n.coerceIn(0, have)) }, + ) + } + } + } + } + }, + confirmButton = { + Button(onClick = { onConfirm(selection) }, enabled = allowed(selection)) { Text(confirmLabel) } + }, + ) +} + +// --------------------------------------------------------------------------- +// City improvements +// --------------------------------------------------------------------------- + +/** The three improvement tracks: where the player stands, what the next level costs, and what it earns. */ +@Composable +fun CatanImproveDialog( + state: CatanState, + seat: Int, + legal: List, + onBuy: (BuyImprovement) -> Unit, + onCancel: () -> Unit, +) { + val ck = state.ck ?: return + val buyable = CatanInteraction.improvable(legal) + AlertDialog( + onDismissRequest = onCancel, + title = { Text(CatanText.IMPROVE) }, + text = { + Column( + modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Track.entries.forEach { track -> + val level = CatanCk.improvement(state, seat, track) + val have = ck.commodities[seat][track.commodity] ?: 0 + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TrackChip(track) + Text(CatanText.track(track), fontWeight = FontWeight.Bold) + Text("level $level of $IMPROVEMENT_MAX", style = MaterialTheme.typography.labelMedium) + } + Text(CatanText.trackPower(track), style = MaterialTheme.typography.bodySmall) + if (level >= IMPROVEMENT_MAX) { + Text("Fully built.", style = MaterialTheme.typography.bodySmall) + } else { + val cost = CatanCk.improvementCost(state, seat, track) + Button(onClick = { onBuy(BuyImprovement(track)) }, enabled = track in buyable) { + Text( + "${CatanCk.improvementName(track, level + 1)}: $cost " + + "${CatanText.good(Good.of(track.commodity))} (you hold $have)", + ) + } + if (level + 1 == org.prolibertate.games.game.catan.METROPOLIS_LEVEL || + level + 1 == IMPROVEMENT_MAX + ) { + Text( + if (level + 1 == IMPROVEMENT_MAX) { + "Level five takes a metropolis from a seat still on four." + } else { + "Level four earns the first metropolis in this track: two more points." + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onCancel) { Text("Close") } }, + ) +} + +// --------------------------------------------------------------------------- +// Progress cards +// --------------------------------------------------------------------------- + +/** + * The player's progress cards. A card with nothing to decide plays at once; one + * that names a seat, a card or dice asks for it in a second step; one that + * needs a tap on the board closes the dialog and arms the board (see [ArmedCard]). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanProgressDialog( + hand: List, + seats: CatanSeats, + onPlay: (CatanMove) -> Unit, + onArm: (ArmedCard) -> Unit, + onCancel: () -> Unit, +) { + var chosen by remember { mutableStateOf(null) } + var target by remember { mutableStateOf(null) } + var yellow by remember { mutableStateOf(1) } + var red by remember { mutableStateOf(1) } + val entry = chosen + + AlertDialog( + onDismissRequest = onCancel, + title = { Text(entry?.let { CatanText.progress(it.card.kind) } ?: "Progress cards") }, + text = { + Column( + modifier = Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (entry == null) { + if (hand.isEmpty()) Text("You hold no progress cards.") + hand.forEach { card -> + ProgressRow(card) { picked -> + val kind = picked.card.kind + when { + kind in CatanInteraction.BOARD_CARDS -> onArm(ArmedCard(kind, picked.plays)) + picked.plays.size == 1 && picked.plays[0].isBare() -> onPlay(picked.plays[0]) + else -> { + chosen = picked + target = null + } + } + } + } + } else { + Text(CatanText.progressHelp(entry.card.kind), style = MaterialTheme.typography.bodySmall) + val kind = entry.card.kind + val plays = entry.plays + when { + kind == ProgressType.ALCHEMIST -> { + Text("Production die (yellow)", fontWeight = FontWeight.Bold) + DiePicker(yellow) { yellow = it } + Text("Progress die (red)", fontWeight = FontWeight.Bold) + DiePicker(red) { red = it } + } + + kind == ProgressType.DESERTER || kind == ProgressType.SPY -> { + Text("Choose a player:", fontWeight = FontWeight.Bold) + plays.mapNotNull { it.target }.distinct().forEach { seat -> + OutlinedButton( + onClick = { plays.firstOrNull { it.target == seat }?.let(onPlay) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(seats.label(seat)) } + } + } + + kind == ProgressType.MASTER_MERCHANT -> { + Text("Choose a player with more points:", fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + plays.mapNotNull { it.target }.distinct().forEach { seat -> + if (target == seat) { + FilledTonalButton(onClick = { target = seat }) { Text(seats.label(seat)) } + } else { + OutlinedButton(onClick = { target = seat }) { Text(seats.label(seat)) } + } + } + } + val seat = target + if (seat != null) { + Text("Take:", fontWeight = FontWeight.Bold) + plays.filter { it.target == seat }.forEach { play -> + OutlinedButton( + onClick = { onPlay(play) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(CatanText.goods(play.goods.orEmpty())) } + } + } + } + + else -> { + val label = when (kind) { + ProgressType.COMMERCIAL_HARBOR -> "Offer which resource?" + ProgressType.MERCHANT_FLEET -> "Trade which card two for one?" + ProgressType.RESOURCE_MONOPOLY -> "Name a resource:" + else -> "Name a commodity:" + } + Text(label, fontWeight = FontWeight.Bold) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + plays.mapNotNull { it.good }.distinct().forEach { good -> + GoodTile( + good = good, + count = 0, + onClick = { plays.firstOrNull { it.good == good }?.let(onPlay) }, + ) + } + } + } + } + } + } + }, + confirmButton = { + if (entry != null && entry.card.kind == ProgressType.ALCHEMIST) { + val play = entry.plays.firstOrNull { it.dice == listOf(yellow, red) } + Button(onClick = { play?.let(onPlay) }, enabled = play != null) { Text("Play") } + } + }, + dismissButton = { + TextButton( + onClick = { + if (entry == null) { + onCancel() + } else { + chosen = null + target = null + } + }, + ) { Text(if (entry == null) CatanText.CANCEL else "Back") } + }, + ) +} + +private fun PlayProgress.isBare(): Boolean = + target == null && vertex == null && edge == null && hexA == null && hexB == null && + good == null && dice == null && goods == null + +@Composable +private fun DiePicker(selected: Int, onPick: (Int) -> Unit) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + (1..6).forEach { n -> + if (n == selected) { + FilledTonalButton( + onClick = { onPick(n) }, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(40.dp), + ) { Text("$n") } + } else { + OutlinedButton( + onClick = { onPick(n) }, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(40.dp), + ) { Text("$n") } + } + } + } +} + +@Composable +private fun ProgressRow(entry: ProgressInHand, onChoose: (ProgressInHand) -> Unit) { + val kind = entry.card.kind + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TrackChip(kind.track) + Column(modifier = Modifier.weight(1f)) { + Text(CatanText.progress(kind), fontWeight = FontWeight.Bold) + Text(CatanText.progressHelp(kind), style = MaterialTheme.typography.bodySmall) + } + if (entry.playable) { + Button(onClick = { onChoose(entry) }) { Text("Play") } + } else { + Text("Not now", style = MaterialTheme.typography.labelSmall) + } + } +} + +// --------------------------------------------------------------------------- +// Knights +// --------------------------------------------------------------------------- + +/** What the knight on one crossing can do: wake, promote, move, chase the robber. */ +@Composable +fun CatanKnightDialog( + state: CatanState, + vertex: Int, + actions: List, + onDo: (CatanMove) -> Unit, + onMove: (Int) -> Unit, + onCancel: () -> Unit, +) { + val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex } + AlertDialog( + onDismissRequest = onCancel, + title = { Text(knight?.let { CatanText.knightName(it.level) } ?: "Knight") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (knight != null) { + val mood = when { + !knight.active -> "Asleep. Waking it costs ${CatanText.ACTIVATE_COST}." + knight.activatedTurn == state.turnNumber -> "Awake, but it woke this turn and cannot act yet." + else -> "Awake and ready. It counts against the barbarians." + } + Text(mood, style = MaterialTheme.typography.bodySmall) + } + actions.forEach { move -> + when (move) { + is ActivateKnight -> + Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) { + Text("Wake (${CatanText.ACTIVATE_COST})") + } + + is PromoteKnight -> + Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) { + Text("Promote (${CatanText.KNIGHT_COST})") + } + + is MoveKnight -> + OutlinedButton(onClick = { onMove(move.from) }, modifier = Modifier.fillMaxWidth()) { + Text("Move along your roads") + } + + is ChaseRobber -> + OutlinedButton(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) { + Text("Chase away the robber") + } + + else -> Unit + } + } + if (actions.isEmpty()) Text("Nothing to do with it right now.") + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onCancel) { Text("Close") } }, + ) +} + +// --------------------------------------------------------------------------- +// Chores that are answered in a dialog +// --------------------------------------------------------------------------- + +/** Choose a colour of progress deck to draw from. */ +@Composable +fun CatanPickDeckDialog(legal: List, onPick: (PickDeck) -> Unit) { + val open = legal.filterIsInstance() + AlertDialog( + onDismissRequest = {}, + title = { Text("Draw a progress card") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Catan held. You tied for the strongest defence, so choose any deck.") + open.forEach { pick -> + Button(onClick = { onPick(pick) }, modifier = Modifier.fillMaxWidth()) { + TrackChip(pick.track) + Text(" ${CatanText.track(pick.track)}") + } + } + } + }, + confirmButton = {}, + ) +} + +/** The hand is over its limit: give one progress card back. */ +@Composable +fun CatanDropProgressDialog(hand: List, onDrop: (DropProgress) -> Unit) { + AlertDialog( + onDismissRequest = {}, + title = { Text("Too many progress cards") }, + text = { + Column( + modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("You may keep four. Choose one to give back.") + hand.forEach { card -> + OutlinedButton(onClick = { onDrop(DropProgress(card.id)) }, modifier = Modifier.fillMaxWidth()) { + TrackChip(card.kind.track) + Text(" ${CatanText.progress(card.kind)}") + } + } + } + }, + confirmButton = {}, + ) +} + +/** The aqueduct pays: take any one resource. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanAqueductDialog(legal: List, onTake: (TakeAqueduct) -> Unit) { + AlertDialog( + onDismissRequest = {}, + title = { Text("Aqueduct") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("The dice paid you nothing. Take any resource.") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + legal.filterIsInstance().forEach { take -> + ResourceTile(resource = take.resource, count = 1, onClick = { onTake(take) }) + } + } + } + }, + confirmButton = {}, + ) +} + +/** A spy: these are the progress cards the other player holds; take one. */ +@Composable +fun CatanSpyDialog(cards: List, legal: List, onTake: (SpyTake) -> Unit) { + AlertDialog( + onDismissRequest = {}, + title = { Text("Spy") }, + text = { + Column( + modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Choose a card to take.") + cards.forEach { card -> + val take = SpyTake(card.id) + OutlinedButton( + onClick = { onTake(take) }, + enabled = take in legal, + modifier = Modifier.fillMaxWidth(), + ) { + TrackChip(card.kind.track) + Text(" ${CatanText.progress(card.kind)}") + } + } + } + }, + confirmButton = {}, + ) +} + +// --------------------------------------------------------------------------- +// Trading in goods +// --------------------------------------------------------------------------- + +private enum class GoodsTab { BANK, PLAYER } + +/** + * Trade with the bank at the best ratio each card has, or offer cards — + * commodities included — to one other player. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanGoodsTradeDialog( + state: CatanState, + seat: Int, + seats: CatanSeats, + legal: List, + onBank: (GoodsBankTrade) -> Unit, + onOffer: (OfferGoods) -> Unit, + onCancel: () -> Unit, +) { + var tab by remember { mutableStateOf(GoodsTab.BANK) } + var bankGive by remember { mutableStateOf(null) } + var everyone by remember { mutableStateOf(true) } + var partner by remember { mutableStateOf(null) } + var give by remember { mutableStateOf(emptyMap()) } + var get by remember { mutableStateOf(emptyMap()) } + val held = CatanCk.goodsOf(state, seat) + + AlertDialog( + onDismissRequest = onCancel, + title = { Text(CatanText.TRADE) }, + text = { + Column( + modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GoodsTabButton(CatanText.TRADE_WITH_BANK, tab == GoodsTab.BANK) { tab = GoodsTab.BANK } + GoodsTabButton(CatanText.TRADE_WITH_PLAYER, tab == GoodsTab.PLAYER) { tab = GoodsTab.PLAYER } + } + if (tab == GoodsTab.BANK) { + val trades = CatanInteraction.goodsBankTrades(legal) + if (trades.isEmpty()) { + Text("You do not hold enough of any one card to trade with the bank.") + } else { + Text("Give:", fontWeight = FontWeight.Bold) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + trades.keys.forEach { good -> + val ratio = CatanCk.bankRatio(state, seat, good) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + GoodTile(good, ratio, selected = bankGive == good, onClick = { bankGive = good }) + Text("$ratio : 1", style = MaterialTheme.typography.labelSmall) + } + } + } + val chosen = bankGive + if (chosen != null && chosen in trades) { + Text("Get one:", fontWeight = FontWeight.Bold) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + trades.getValue(chosen).forEach { good -> + GoodTile(good, 1, onClick = { onBank(GoodsBankTrade(chosen, good)) }) + } + } + } + } + } else { + Text("Offer to:", fontWeight = FontWeight.Bold) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + GoodsTabButton(CatanText.EVERYONE, everyone) { + everyone = true + partner = null + } + (0 until state.playerCount).filter { it != seat }.forEach { other -> + GoodsTabButton(seats.label(other), !everyone && partner == other) { + everyone = false + partner = other + } + } + } + Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold) + GoodCounters(give, { held[it] ?: 0 }) { give = it } + Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold) + GoodCounters(get, { 9 }) { get = it } + } + } + }, + confirmButton = { + if (tab == GoodsTab.PLAYER) { + val to = if (everyone) null else partner + val giveCards = give.asGoods() + val getCards = get.asGoods() + val allowed = (everyone || partner != null) && + CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards) + Button( + onClick = { onOffer(OfferGoods(to, giveCards, getCards)) }, + enabled = allowed, + ) { Text(CatanText.SEND_OFFER) } + } + }, + dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GoodCounters(counts: Map, limit: (Good) -> Int, onChange: (Map) -> Unit) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Good.entries.forEach { good -> + GoodCounter( + good = good, + count = counts[good] ?: 0, + max = limit(good), + onChange = { n -> onChange(counts + (good to n.coerceIn(0, limit(good)))) }, + ) + } + } +} + +@Composable +private fun GoodsTabButton(label: String, selected: Boolean, onClick: () -> Unit) { + if (selected) { + FilledTonalButton(onClick = onClick) { Text(label) } + } else { + OutlinedButton(onClick = onClick) { Text(label) } + } +} + +/** + * A goods offer has reached the local player: accept it, decline it, or answer + * with different terms. The counter's two sides start as the offer turned around. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CatanGoodsTradeResponseDialog( + state: CatanState, + seat: Int, + offer: GoodsOffer, + seats: CatanSeats, + canAccept: Boolean, + onAccept: () -> Unit, + onDecline: () -> Unit, + onCounter: (CounterGoods) -> Unit, +) { + var countering by remember(offer) { mutableStateOf(false) } + var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) } + var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) } + + AlertDialog( + onDismissRequest = {}, + title = { Text(if (countering) "Counter-offer" else "Trade offer") }, + text = { + Column( + modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to " + + "${if (offer.to == null) "the table" else "you"} for ${CatanText.goods(offer.get)}", + ) + if (countering) { + Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold) + GoodCounters(give, { CatanCk.goodsOf(state, seat)[it] ?: 0 }) { give = it } + Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold) + GoodCounters(get, { CatanCk.goodsOf(state, offer.by)[it] ?: 0 }) { get = it } + } else if (!canAccept) { + Text( + "You cannot accept this: one side no longer holds the cards.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + if (countering) { + val move = CounterGoods(give.asGoods(), get.asGoods()) + Button( + onClick = { onCounter(move) }, + enabled = CatanInteraction.goodsCounterAllowed(state, seat, move.give, move.get), + ) { Text(CatanText.SEND_COUNTER) } + } else { + Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (countering) { + TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) } + } else { + TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) } + } + TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } + } + }, + ) +} + +/** The offerer's choice once nobody accepted a goods offer: take a counter-offer, or none. */ +@Composable +fun CatanGoodsCounterChoiceDialog( + counters: List, + seats: CatanSeats, + onTake: (AcceptCounter) -> Unit, + onNoDeal: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text("Counter-offers") }, + text = { + Column( + modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (counters.isEmpty()) Text("None of the counter-offers can be met any more.") + counters.forEach { counter -> + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "${seats.label(counter.by)} would give ${CatanText.goods(counter.give)} " + + "for ${CatanText.goods(counter.get)}", + ) + Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) } + } + } + } + }, + confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt index 153a736..5054be0 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -2,45 +2,100 @@ package org.prolibertate.games.ui.game.catan import org.prolibertate.games.game.catan.AcceptCounter import org.prolibertate.games.game.catan.AcceptTrade +import org.prolibertate.games.game.catan.ActivateKnight import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildRoad import org.prolibertate.games.game.catan.BuildSettlement +import org.prolibertate.games.game.catan.BuildWall import org.prolibertate.games.game.catan.BuyDevCard +import org.prolibertate.games.game.catan.BuyImprovement import org.prolibertate.games.game.catan.CatanBoard import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.ChaseRobber +import org.prolibertate.games.game.catan.Chore import org.prolibertate.games.game.catan.CounterTrade import org.prolibertate.games.game.catan.DeclineTrade import org.prolibertate.games.game.catan.DevCard import org.prolibertate.games.game.catan.DevCardType import org.prolibertate.games.game.catan.Discard import org.prolibertate.games.game.catan.EndFreeRoads +import org.prolibertate.games.game.catan.EndSmith import org.prolibertate.games.game.catan.EndTurn +import org.prolibertate.games.game.catan.CounterGoods +import org.prolibertate.games.game.catan.GoodsBankTrade +import org.prolibertate.games.game.catan.GoodsCounter +import org.prolibertate.games.game.catan.Good import org.prolibertate.games.game.catan.HexCoord +import org.prolibertate.games.game.catan.HireKnight import org.prolibertate.games.game.catan.MaritimeTrade +import org.prolibertate.games.game.catan.MoveKnight import org.prolibertate.games.game.catan.MoveRobber +import org.prolibertate.games.game.catan.OfferGoods import org.prolibertate.games.game.catan.OfferTrade +import org.prolibertate.games.game.catan.PillageCity +import org.prolibertate.games.game.catan.PlaceKnight +import org.prolibertate.games.game.catan.PlaceMetropolis import org.prolibertate.games.game.catan.TradeCounter import org.prolibertate.games.game.catan.PlayKnight import org.prolibertate.games.game.catan.PlayMonopoly +import org.prolibertate.games.game.catan.PlayProgress import org.prolibertate.games.game.catan.PlayRoadBuilding import org.prolibertate.games.game.catan.PlayYearOfPlenty +import org.prolibertate.games.game.catan.ProgressCard +import org.prolibertate.games.game.catan.ProgressType +import org.prolibertate.games.game.catan.PromoteKnight +import org.prolibertate.games.game.catan.RelocateKnight +import org.prolibertate.games.game.catan.RemoveKnight import org.prolibertate.games.game.catan.Resource import org.prolibertate.games.game.catan.RollDice import org.prolibertate.games.game.catan.SetupPlace +import org.prolibertate.games.game.catan.Track -/** What a player can ask to build by tapping the board. */ -enum class BuildKind { ROAD, SETTLEMENT, CITY } +/** + * What a player can ask to build by tapping the board. Wall and Knight exist + * only in Cities & Knights; the base game's build menu lists the first three. + */ +enum class BuildKind { + ROAD, SETTLEMENT, CITY, WALL, KNIGHT; -/** The parts of the board the player may tap right now. Everything else is inert. */ + companion object { + val BASE: List = listOf(ROAD, SETTLEMENT, CITY) + } +} + +/** + * The parts of the board the player may tap right now. Everything else is inert. + * [knights] are crossings holding one of the player's own knights that can do + * something this turn: tapping one opens its menu. + */ data class BoardTargets( val vertices: Set = emptySet(), val edges: Set = emptySet(), val hexes: Set = emptySet(), + val knights: Set = emptySet(), + /** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */ + val idleKnights: Set = emptySet(), ) { - val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() + val isEmpty: Boolean + get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() && idleKnights.isEmpty() + + /** Every crossing a tap can land on. */ + val tappableVertices: Set get() = vertices + knights + idleKnights +} + +/** + * A progress card that is waiting for a tap on the board: [plays] are every play + * of it the engine accepts, and the player's tap picks one. [firstHex] is the + * first of the two hexes an Inventor swaps, once tapped. + */ +data class ArmedCard(val kind: ProgressType, val plays: List, val firstHex: Int? = null) + +/** One progress card in the local player's hand, and the plays the engine allows for it. */ +data class ProgressInHand(val card: ProgressCard, val plays: List) { + val playable: Boolean get() = plays.isNotEmpty() } /** What a tap on the board amounts to. */ @@ -56,6 +111,12 @@ sealed interface TapOutcome { /** The robber's hex has more than one possible victim: ask which. */ data class PickRobberVictim(val hex: HexCoord, val victims: List) : TapOutcome + + /** One of the player's own knights was tapped: open its menu. */ + data class PickKnight(val vertex: Int) : TapOutcome + + /** An Inventor's first hex was tapped; the second completes the swap. */ + data class PickFirstHex(val hex: Int) : TapOutcome } /** @@ -88,6 +149,10 @@ object CatanInteraction { legal: List, building: BuildKind?, setupVertex: Int?, + movingKnight: Int? = null, + armed: ArmedCard? = null, + /** The local seat, so its sleeping knights can be tapped for their menu too. */ + localSeat: Int? = null, ): BoardTargets { val setup = legal.filterIsInstance() if (setup.isNotEmpty()) { @@ -103,9 +168,21 @@ object CatanInteraction { return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet()) } + // Cities & Knights: answers to a queued chore are taps on the board. + val chore = choreVertices(legal) + if (chore.isNotEmpty()) return BoardTargets(vertices = chore) + val roads = legal.filterIsInstance().map { it.edge }.toSet() if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads) + if (armed != null) return armedTargets(armed) + + if (movingKnight != null) { + return BoardTargets( + vertices = legal.filterIsInstance().filter { it.from == movingKnight }.map { it.to }.toSet(), + ) + } + return when (building) { BuildKind.ROAD -> BoardTargets(edges = roads) BuildKind.SETTLEMENT -> @@ -114,15 +191,169 @@ object CatanInteraction { BuildKind.CITY -> BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) - null -> BoardTargets() + BuildKind.WALL -> + BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) + + BuildKind.KNIGHT -> + BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) + + null -> { + val ready = knightsWithActions(legal) + val mine = if (localSeat != null && legal.isNotEmpty()) { + state.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty() + } else { + emptySet() + } + BoardTargets(knights = ready, idleKnights = mine - ready) + } } } + /** The crossings a queued chore is asking the player to tap, from the legal list. */ + private fun choreVertices(legal: List): Set = buildSet { + legal.forEach { move -> + when (move) { + is PillageCity -> add(move.vertex) + is PlaceMetropolis -> add(move.vertex) + is RelocateKnight -> add(move.to) + is RemoveKnight -> add(move.vertex) + is PlaceKnight -> add(move.vertex) + else -> Unit + } + } + // A Smith's promotions are knights to tap, while the chore is open. + if (legal.any { it is EndSmith }) { + legal.filterIsInstance().forEach { add(it.vertex) } + } + } + + private fun choreMove(legal: List, vertex: Int): CatanMove? = legal.firstOrNull { move -> + when (move) { + is PillageCity -> move.vertex == vertex + is PlaceMetropolis -> move.vertex == vertex + is RelocateKnight -> move.to == vertex + is RemoveKnight -> move.vertex == vertex + is PlaceKnight -> move.vertex == vertex + is PromoteKnight -> legal.any { it is EndSmith } && move.vertex == vertex + else -> false + } + } + + // --- Cities & Knights: knights --------------------------------------------- + + /** Crossings of the player's knights that have an action available. */ + fun knightsWithActions(legal: List): Set = buildSet { + legal.forEach { move -> + when (move) { + is ActivateKnight -> add(move.vertex) + is MoveKnight -> add(move.from) + is ChaseRobber -> add(move.vertex) + is PromoteKnight -> if (legal.none { it is EndSmith }) add(move.vertex) + else -> Unit + } + } + } + + /** What the knight on [vertex] can do right now, in the order its menu lists them. */ + fun knightActions(legal: List, vertex: Int): List = + legal.filter { move -> + when (move) { + is ActivateKnight -> move.vertex == vertex + is PromoteKnight -> move.vertex == vertex && legal.none { it is EndSmith } + is ChaseRobber -> move.vertex == vertex + else -> false + } + } + listOfNotNull(legal.firstOrNull { it is MoveKnight && it.from == vertex }) + + fun canEndSmith(legal: List): Boolean = legal.any { it is EndSmith } + + // --- Cities & Knights: progress cards -------------------------------------- + + /** [seat]'s progress cards, each with the plays the engine currently allows. */ + fun progressHand(state: CatanState, seat: Int, legal: List): List = + state.ck?.progressHands?.getOrNull(seat).orEmpty().map { card -> + ProgressInHand(card, legal.filterIsInstance().filter { it.card == card.id }) + }.sortedWith(compareBy({ it.card.kind.track.ordinal }, { it.card.kind.ordinal }, { it.card.id })) + + /** The cards that are played by tapping the board, not by choosing in a dialog. */ + val BOARD_CARDS: Set = setOf( + ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.MERCHANT, + ProgressType.DIPLOMAT, ProgressType.INTRIGUE, ProgressType.INVENTOR, + ) + + private fun armedTargets(armed: ArmedCard): BoardTargets = when (armed.kind) { + ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.INTRIGUE -> + BoardTargets(vertices = armed.plays.mapNotNull { it.vertex }.toSet()) + + ProgressType.DIPLOMAT -> BoardTargets(edges = armed.plays.mapNotNull { it.edge }.toSet()) + + ProgressType.MERCHANT -> BoardTargets(hexes = armed.plays.mapNotNull { it.hexA }.toSet()) + + ProgressType.INVENTOR -> { + val first = armed.firstHex + val all = armed.plays.flatMap { listOfNotNull(it.hexA, it.hexB) }.toSet() + if (first == null) { + BoardTargets(hexes = all) + } else { + BoardTargets( + hexes = armed.plays + .filter { it.hexA == first || it.hexB == first } + .flatMap { listOfNotNull(it.hexA, it.hexB) } + .filter { it != first } + .toSet(), + ) + } + } + + else -> BoardTargets() + } + + /** The improvement tracks the player can buy a level of right now. */ + fun improvable(legal: List): Set = + legal.filterIsInstance().map { it.track }.toSet() + + // --- Cities & Knights: goods ---------------------------------------------- + + /** Bank trades on offer in Cities & Knights: what can be given, and for what. */ + fun goodsBankTrades(legal: List): Map> = + legal.filterIsInstance() + .groupBy({ it.give }, { it.get }) + .mapValues { (_, gets) -> gets.sortedBy { it.ordinal } } + .toSortedMap(compareBy { it.ordinal }) + + fun goodsOfferAllowed( + state: CatanState, + seat: Int, + to: Int?, + give: List, + get: List, + ): Boolean = give.isNotEmpty() && get.isNotEmpty() && + CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get)) + + /** Whether [seat] may answer the goods offer on the table with [give] for [get]. */ + fun goodsCounterAllowed(state: CatanState, seat: Int, give: List, get: List): Boolean = + give.isNotEmpty() && get.isNotEmpty() && + CatanRules.isLegalMove(state, seat, CounterGoods(give, get)) + + /** The goods counter-offers the offerer may take right now, in the order they came in. */ + fun goodsCounterChoices(state: CatanState, legal: List): List { + val takeable = legal.filterIsInstance().map { it.seat }.toSet() + return state.ck?.goodsOffer?.counters.orEmpty().filter { it.by in takeable } + } + + /** The chore at the front of the queue, if the table is waiting on one. */ + fun chore(state: CatanState): Chore? = + if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null + fun onVertexTap( vertex: Int, legal: List, building: BuildKind?, setupVertex: Int?, + movingKnight: Int? = null, + armed: ArmedCard? = null, + /** The local player's knights, which can be tapped for their menu even with nothing to do. */ + ownKnights: Set = emptySet(), ): TapOutcome { val setup = legal.filterIsInstance() if (setup.isNotEmpty()) { @@ -134,10 +365,27 @@ object CatanInteraction { TapOutcome.Ignored } } + choreMove(legal, vertex)?.let { return TapOutcome.Submit(it) } + + if (armed != null) { + val play = armed.plays.firstOrNull { it.vertex == vertex } ?: return TapOutcome.Ignored + return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored + } + if (movingKnight != null) { + val move = MoveKnight(movingKnight, vertex) + return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored + } + val move: CatanMove = when (building) { BuildKind.SETTLEMENT -> BuildSettlement(vertex) BuildKind.CITY -> BuildCity(vertex) - else -> return TapOutcome.Ignored + BuildKind.WALL -> BuildWall(vertex) + BuildKind.KNIGHT -> HireKnight(vertex) + else -> return if (building == null && (vertex in knightsWithActions(legal) || vertex in ownKnights)) { + TapOutcome.PickKnight(vertex) + } else { + TapOutcome.Ignored + } } return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored } @@ -147,12 +395,17 @@ object CatanInteraction { legal: List, building: BuildKind?, setupVertex: Int?, + armed: ArmedCard? = null, ): TapOutcome { if (legal.any { it is SetupPlace }) { if (setupVertex == null) return TapOutcome.Ignored val move = SetupPlace(setupVertex, edge) return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored } + if (armed != null) { + val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored + return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored + } // Free roads need no build mode; a paid road does. val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads } if (!canBuild) return TapOutcome.Ignored @@ -160,7 +413,8 @@ object CatanInteraction { return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored } - fun onHexTap(hex: Int, legal: List): TapOutcome { + fun onHexTap(hex: Int, legal: List, armed: ArmedCard? = null): TapOutcome { + if (armed != null) return onArmedHexTap(hex, legal, armed) val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored val moves = legal.filterIsInstance().filter { it.hex == coord } return when { @@ -170,6 +424,22 @@ object CatanInteraction { } } + private fun onArmedHexTap(hex: Int, legal: List, armed: ArmedCard): TapOutcome { + if (armed.kind == ProgressType.INVENTOR) { + val first = armed.firstHex ?: return if (hex in armedTargets(armed).hexes) { + TapOutcome.PickFirstHex(hex) + } else { + TapOutcome.Ignored + } + val play = armed.plays.firstOrNull { + (it.hexA == first && it.hexB == hex) || (it.hexA == hex && it.hexB == first) + } ?: return TapOutcome.Ignored + return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored + } + val play = armed.plays.firstOrNull { it.hexA == hex } ?: return TapOutcome.Ignored + return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored + } + // --- Action bar ------------------------------------------------------------ fun canRoll(legal: List): Boolean = legal.any { it is RollDice } @@ -203,6 +473,8 @@ object CatanInteraction { if (legal.any { it is BuildRoad }) add(BuildKind.ROAD) if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT) if (legal.any { it is BuildCity }) add(BuildKind.CITY) + if (legal.any { it is BuildWall }) add(BuildKind.WALL) + if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT) } fun tradeResponses(legal: List): Pair = diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index 30317cd..e23a986 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -40,10 +40,15 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.prolibertate.games.game.catan.BuildingType +import org.prolibertate.games.game.catan.CatanCk import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.EventFace +import org.prolibertate.games.game.catan.Good import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.Track /* * Stateless building blocks for the Katan screen. @@ -102,7 +107,52 @@ fun ResourceTile( } } -/** The local player's cards: one tile per resource, in a row that wraps. */ +/** + * One card type — a resource or a commodity — as a coloured tile with its count. + * Cities & Knights hands, discards and trades use this so commodities look the + * same everywhere. + */ +@Composable +fun GoodTile( + good: Good, + count: Int, + modifier: Modifier = Modifier, + selected: Boolean = false, + enabled: Boolean = true, + style: CatanStyle = LocalCatanStyle.current, + onClick: (() -> Unit)? = null, +) { + val resource = good.resource + if (resource != null) { + ResourceTile(resource, count, modifier, selected, enabled, style, onClick) + return + } + val commodity = good.commodity!! + val shape = RoundedCornerShape(10.dp) + val ink = style.commodityInk.getValue(commodity) + Column( + modifier = modifier + .defaultMinSize(minWidth = 56.dp) + .clip(shape) + .background(style.commodity.getValue(commodity).copy(alpha = if (enabled) 1f else 0.4f)) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) Color.White else Color(0x33000000), + shape = shape, + ) + .then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier) + .padding(horizontal = 6.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = count.toString(), color = ink, fontWeight = FontWeight.Bold, fontSize = 20.sp) + Text(text = CatanText.good(good), color = ink, fontSize = 11.sp, maxLines = 1) + } +} + +/** + * The local player's cards: one tile per resource, and in Cities & Knights one + * per commodity as well, in a row that wraps. + */ @OptIn(ExperimentalLayoutApi::class) @Composable fun CatanHand( @@ -115,8 +165,13 @@ fun CatanHand( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Resource.entries.forEach { resource -> - ResourceTile(resource, state.resources[seat][resource] ?: 0) + if (state.isCk) { + val held = CatanCk.goodsOf(state, seat) + Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) } + } else { + Resource.entries.forEach { resource -> + ResourceTile(resource, state.resources[seat][resource] ?: 0) + } } } } @@ -175,6 +230,41 @@ fun CatanPlayerCard( CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold) } CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold) + val ck = state.ck + if (ck != null) { + val cards = CatanCk.goodsCount(state, seat) + val strength = CatanCk.strength(state, seat) + if (compact) { + CardLine("$cards cards · ⚔$strength", MaterialTheme.typography.bodySmall) + } else { + CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall) + CardLine( + "⚔ ${CatanCk.knightsOf(state, seat).size} · str $strength · " + + "Road ${CatanRules.longestRoadFor(state, seat)}", + MaterialTheme.typography.bodySmall, + ) + } + // Trade, politics, science: the three improvement levels. + CardLine( + Track.entries.joinToString(" ") { "${it.name.first()}${CatanCk.improvement(state, seat, it)}" }, + MaterialTheme.typography.bodySmall, + color = Color.Unspecified, + ) + val ckAwards = buildList { + if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road") + ck.metropolises.forEach { (track, at) -> + if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis") + } + if (ck.merchant?.seat == seat) add("Merchant") + } + CardLine( + text = ckAwards.joinToString(" · ").ifEmpty { BLANK }, + style = MaterialTheme.typography.labelSmall, + weight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + return@Column + } if (compact) { CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall) } else { @@ -226,21 +316,36 @@ private fun CardLine( // Dice // --------------------------------------------------------------------------- -/** The two dice as last rolled, with their total. Blank until the first roll. */ +/** + * The two dice as last rolled, with their total. Blank until the first roll. In + * Cities & Knights the dice are yellow and red (the red one decides progress + * cards) and a third, the event die, shows a ship or a coloured city gate. + */ @Composable -fun CatanDice(dice: List, modifier: Modifier = Modifier) { +fun CatanDice( + dice: List, + modifier: Modifier = Modifier, + ck: Boolean = false, + event: EventFace? = null, + style: CatanStyle = LocalCatanStyle.current, +) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { val shown = if (dice.size == 2) dice else listOf(null, null) - shown.forEach { value -> + shown.forEachIndexed { index, value -> + val face = when { + !ck -> Color.White + index == 0 -> Color(0xFFFFF176) + else -> Color(0xFFEF9A9A) + } Box( modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(8.dp)) - .background(Color.White) + .background(face) .border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center, ) { @@ -253,6 +358,31 @@ fun CatanDice(dice: List, modifier: Modifier = Modifier) { ) } } + if (ck) { + val gate = when (event) { + EventFace.TRADE -> style.track.getValue(Track.TRADE) + EventFace.POLITICS -> style.track.getValue(Track.POLITICS) + EventFace.SCIENCE -> style.track.getValue(Track.SCIENCE) + EventFace.SHIP -> style.barbarian + null -> Color(0x33000000) + } + Box( + modifier = Modifier + .size(width = 52.dp, height = 36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(gate) + .border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (event == EventFace.SHIP) "⛵ Ship" else CatanText.event(event), + color = if (event == EventFace.TRADE) Color(0xFF1C1B1F) else Color.White, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + maxLines = 1, + ) + } + } // Always drawn ("= –" before the first roll) so the row never changes width. Text( text = if (dice.size == 2) "= ${dice.sum()}" else "= –", @@ -262,6 +392,48 @@ fun CatanDice(dice: List, modifier: Modifier = Modifier) { } } +/** + * Cities & Knights: how close the barbarians are, and how the cities on the + * island compare with the knights standing ready. **Fixed height**, like every + * other strip around the board. + */ +@Composable +fun CatanBarbarians(state: CatanState, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current) { + val ck = state.ck ?: return + val cities = state.buildings.count { it?.type == BuildingType.CITY } + val defence = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) } + Row( + modifier = modifier.fillMaxWidth().height(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("⛵", fontSize = 14.sp) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) { + // The ship starts seven steps out and lands when the last box fills. + val sailed = BARBARIAN_STEPS_DISPLAY - ck.barbarians + for (i in 0 until BARBARIAN_STEPS_DISPLAY) { + Box( + modifier = Modifier + .weight(1f) + .height(10.dp) + .clip(RoundedCornerShape(3.dp)) + .background(if (i < sailed) style.barbarian else Color(0x33000000)), + ) + } + } + val verdict = if (defence >= cities) "holds" else "falls" + Text( + text = "${CatanText.barbarianSteps(ck.barbarians)} · knights $defence v. $cities cities ($verdict)", + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** Steps shown on the barbarian strip: the engine's seven. */ +private const val BARBARIAN_STEPS_DISPLAY = org.prolibertate.games.game.catan.BARBARIAN_STEPS + // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- @@ -278,6 +450,14 @@ data class CatanActions( val canEndFreeRoads: Boolean = false, /** A setup settlement is chosen and a different one may still be picked. */ val canRepickSetup: Boolean = false, + /** Which kinds the Build menu lists: three in the base game, five in Cities & Knights. */ + val buildKinds: List = BuildKind.BASE, + /** Cities & Knights: the table plays the expansion, so the bar shows its buttons instead. */ + val ck: Boolean = false, + val canImprove: Boolean = false, + val canPlayProgress: Boolean = false, + val progressCount: Int = 0, + val canEndSmith: Boolean = false, ) /** @@ -305,6 +485,9 @@ fun CatanActionBar( modifier: Modifier = Modifier, /** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */ compact: Boolean = false, + onImprove: () -> Unit = {}, + onProgress: () -> Unit = {}, + onEndSmith: () -> Unit = {}, ) { Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( @@ -315,6 +498,7 @@ fun CatanActionBar( if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) } if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) } if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) } + if (actions.canEndSmith) Button(onClick = onEndSmith) { Text(CatanText.STOP_SMITH) } if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } } @@ -323,18 +507,27 @@ fun CatanActionBar( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (compact) { + if (compact || actions.ck) { BuildMenuButton(actions, onBuild) } else { BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) } - OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { - LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) - } - OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { - LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST) + if (actions.ck) { + OutlinedButton(onClick = onImprove, enabled = actions.canImprove) { + LabelWithCost(CatanText.IMPROVE, CatanText.NO_COST) + } + OutlinedButton(onClick = onProgress, enabled = actions.canPlayProgress) { + LabelWithCost(CatanText.CARDS_BUTTON, "${actions.progressCount} held") + } + } else { + OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { + LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) + } + OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { + LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST) + } } OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { LabelWithCost(CatanText.TRADE, CatanText.NO_COST) @@ -384,7 +577,7 @@ private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() } } DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - for (kind in BuildKind.entries) { + for (kind in actions.buildKinds) { DropdownMenuItem( text = { LabelWithCost(buildLabel(kind), buildCost(kind)) }, enabled = kind in actions.canBuild, @@ -402,12 +595,16 @@ private fun buildLabel(kind: BuildKind): String = when (kind) { BuildKind.ROAD -> CatanText.ROAD BuildKind.SETTLEMENT -> CatanText.SETTLEMENT BuildKind.CITY -> CatanText.CITY + BuildKind.WALL -> CatanText.WALL + BuildKind.KNIGHT -> CatanText.KNIGHT } private fun buildCost(kind: BuildKind): String = when (kind) { BuildKind.ROAD -> CatanText.ROAD_COST BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST BuildKind.CITY -> CatanText.CITY_COST + BuildKind.WALL -> CatanText.WALL_COST + BuildKind.KNIGHT -> CatanText.KNIGHT_COST } @Composable diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 12a1af2..5eb3685 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -28,18 +28,30 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.prolibertate.games.game.catan.AcceptTrade +import org.prolibertate.games.game.catan.AqueductChore import org.prolibertate.games.game.catan.BuyDevCard +import org.prolibertate.games.game.catan.CatanCk import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.DeclineTrade +import org.prolibertate.games.game.catan.DiscardChore +import org.prolibertate.games.game.catan.DiscardGoods +import org.prolibertate.games.game.catan.DropProgress +import org.prolibertate.games.game.catan.DropProgressChore import org.prolibertate.games.game.catan.DismissCounters import org.prolibertate.games.game.catan.EndFreeRoads +import org.prolibertate.games.game.catan.EndSmith import org.prolibertate.games.game.catan.EndTurn +import org.prolibertate.games.game.catan.GiveChore +import org.prolibertate.games.game.catan.GiveGoods +import org.prolibertate.games.game.catan.MoveKnight import org.prolibertate.games.game.catan.MoveRobber +import org.prolibertate.games.game.catan.PickDeckChore import org.prolibertate.games.game.catan.RollDice import org.prolibertate.games.game.catan.SetupPlace +import org.prolibertate.games.game.catan.SpyChore import org.prolibertate.games.net.MatchController import org.prolibertate.games.ui.ScreenScaffold @@ -85,6 +97,13 @@ fun CatanScreen( var showTrade by remember { mutableStateOf(false) } var confirmingEnd by remember { mutableStateOf(false) } var resultDismissed by remember { mutableStateOf(false) } + // Cities & Knights: the knight whose menu is open, the one about to move, + // a progress card waiting for a tap on the board, and the two list dialogs. + var knightMenu by remember { mutableStateOf(null) } + var movingKnight by remember { mutableStateOf(null) } + var armed by remember { mutableStateOf(null) } + var showImprove by remember { mutableStateOf(false) } + var showProgress by remember { mutableStateOf(false) } val dark = isSystemInDarkTheme() val style = remember(dark) { catanStyle(dark) } @@ -98,10 +117,17 @@ fun CatanScreen( if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) { setupVertex = null } + if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null + armed?.let { card -> if (card.plays.none { it in legal }) armed = null } if (legal.isEmpty()) { showCards = false showTrade = false victimChoice = null + showImprove = false + showProgress = false + knightMenu = null + movingKnight = null + armed = null } } @@ -112,6 +138,11 @@ fun CatanScreen( victimChoice = null showCards = false showTrade = false + showImprove = false + showProgress = false + knightMenu = null + movingKnight = null + armed = null } val endGameAction: @Composable () -> Unit = { @@ -128,10 +159,18 @@ fun CatanScreen( return } - val targets = CatanInteraction.targets(current, legal, building, setupVertex) + val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat) + val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty() val devHand = CatanInteraction.devHand(current, localSeat, legal) + val progressHand = CatanInteraction.progressHand(current, localSeat, legal) val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val actions = CatanActions( + buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE, + ck = current.isCk, + canImprove = CatanInteraction.improvable(legal).isNotEmpty(), + canPlayProgress = progressHand.any { it.playable }, + progressCount = progressHand.size, + canEndSmith = CatanInteraction.canEndSmith(legal), canRoll = CatanInteraction.canRoll(legal), canBuild = CatanInteraction.canBuild(legal), building = building, @@ -143,7 +182,14 @@ fun CatanScreen( canRepickSetup = setupVertex != null, ) val rolledHexes = CatanInteraction.rolledHexes(current) - val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building) + val prompt = CatanText.prompt( + current, + seats, + setupVertexChosen = setupVertex != null, + building = building, + movingKnight = movingKnight != null, + armed = armed?.kind, + ) val handle: (TapOutcome) -> Unit = { outcome -> when (outcome) { @@ -151,6 +197,8 @@ fun CatanScreen( is TapOutcome.Submit -> submit(outcome.move) is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex is TapOutcome.PickRobberVictim -> victimChoice = outcome + is TapOutcome.PickKnight -> knightMenu = outcome.vertex + is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex) } } @@ -171,12 +219,20 @@ fun CatanScreen( state = current, targets = targets, selectedVertex = setupVertex, - onVertexTap = { handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex)) }, - onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex)) }, - onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) }, + onVertexTap = { + handle( + CatanInteraction.onVertexTap( + it, legal, building, setupVertex, movingKnight, armed, + ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(), + ), + ) + }, + onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) }, + onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) }, modifier = boardModifier, style = style, rolledHexes = rolledHexes, + selectedKnight = movingKnight ?: knightMenu, ) } val players: @Composable (Boolean) -> Unit = { grid -> @@ -201,6 +257,9 @@ fun CatanScreen( }, onRepickSetup = { setupVertex = null }, compact = compactBuild, + onImprove = { showImprove = true }, + onProgress = { showProgress = true }, + onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) }, ) } } @@ -215,6 +274,7 @@ fun CatanScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { players(true) + if (current.isCk) CatanBarbarians(current, style = style) promptRow() handAndActions() CatanLog(current, Modifier.fillMaxWidth().height(120.dp)) @@ -223,6 +283,7 @@ fun CatanScreen( } else { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { players(false) + if (current.isCk) CatanBarbarians(current, style = style) promptRow() Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { board(Modifier) @@ -244,6 +305,21 @@ fun CatanScreen( showCards = showCards, showTrade = showTrade, devHand = devHand, + knightMenu = knightMenu, + showImprove = showImprove, + showProgress = showProgress, + progressHand = progressHand, + onMoveKnight = { from -> + knightMenu = null + movingKnight = from + }, + onArmCard = { card -> + showProgress = false + armed = card + }, + onCancelKnight = { knightMenu = null }, + onCancelImprove = { showImprove = false }, + onCancelProgress = { showProgress = false }, onExit = onExit, onSubmit = submit, onStay = controller::dismissAbandoned, @@ -316,7 +392,7 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - CatanDice(state.lastDice) + CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie) } } @@ -338,6 +414,15 @@ private fun CatanDialogHost( showCards: Boolean, showTrade: Boolean, devHand: List, + knightMenu: Int?, + showImprove: Boolean, + showProgress: Boolean, + progressHand: List, + onMoveKnight: (Int) -> Unit, + onArmCard: (ArmedCard) -> Unit, + onCancelKnight: () -> Unit, + onCancelImprove: () -> Unit, + onCancelProgress: () -> Unit, onExit: () -> Unit, onSubmit: (CatanMove) -> Unit, onStay: () -> Unit, @@ -348,6 +433,7 @@ private fun CatanDialogHost( onCancelTrade: () -> Unit, ) { val offer = state.pendingTrade + val goodsOffer = state.ck?.goodsOffer when { abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay) @@ -364,6 +450,29 @@ private fun CatanDialogHost( onSubmit = onSubmit, ) + state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat -> + ChoreDialog(state, localSeat, legal, onSubmit) + + state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.asking == localSeat -> + CatanGoodsTradeResponseDialog( + state = state, + seat = localSeat, + offer = goodsOffer, + seats = seats, + canAccept = CatanInteraction.tradeResponses(legal).first, + onAccept = { onSubmit(AcceptTrade) }, + onDecline = { onSubmit(DeclineTrade) }, + onCounter = onSubmit, + ) + + state.phase == CatanPhase.TRADE_COUNTERS && goodsOffer != null && state.turn == localSeat && legal.isNotEmpty() -> + CatanGoodsCounterChoiceDialog( + counters = CatanInteraction.goodsCounterChoices(state, legal), + seats = seats, + onTake = onSubmit, + onNoDeal = { onSubmit(DismissCounters) }, + ) + state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat -> CatanTradeResponseDialog( state = state, @@ -392,8 +501,47 @@ private fun CatanDialogHost( onCancel = onCancelVictim, ) + knightMenu != null -> + CatanKnightDialog( + state = state, + vertex = knightMenu, + actions = CatanInteraction.knightActions(legal, knightMenu), + onDo = onSubmit, + onMove = onMoveKnight, + onCancel = onCancelKnight, + ) + + showImprove -> + CatanImproveDialog( + state = state, + seat = localSeat, + legal = legal, + onBuy = onSubmit, + onCancel = onCancelImprove, + ) + + showProgress -> + CatanProgressDialog( + hand = progressHand, + seats = seats, + onPlay = onSubmit, + onArm = onArmCard, + onCancel = onCancelProgress, + ) + showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards) + showTrade && state.isCk -> + CatanGoodsTradeDialog( + state = state, + seat = localSeat, + seats = seats, + legal = legal, + onBank = onSubmit, + onOffer = onSubmit, + onCancel = onCancelTrade, + ) + showTrade -> CatanTradeDialog( state = state, @@ -406,3 +554,61 @@ private fun CatanDialogHost( ) } } + +/** + * The dialog for the chore at the front of the queue, when it is the local + * player's. Chores that are answered by tapping the board (a city to lose, a + * metropolis site, a knight to place) have no dialog: the prompt line tells the + * player what to tap. + */ +@Composable +private fun ChoreDialog( + state: CatanState, + localSeat: Int, + legal: List, + onSubmit: (CatanMove) -> Unit, +) { + val ck = state.ck ?: return + val check: (List, (List) -> CatanMove) -> Boolean = + { cards, make -> CatanRules.isLegalMove(state, localSeat, make(cards)) } + when (val chore = ck.chores.firstOrNull()) { + is DiscardChore -> { + val quota = minOf(chore.count, CatanCk.goodsCount(state, localSeat)) + CatanGoodsPickDialog( + title = "Discard $quota cards", + intro = "Choose $quota cards to give up", + held = CatanCk.goodsOf(state, localSeat), + quota = quota, + confirmLabel = "Discard", + selectable = { true }, + allowed = { check(it, ::DiscardGoods) }, + onConfirm = { onSubmit(DiscardGoods(it)) }, + ) + } + + is GiveChore -> { + val pool = CatanCk.goodsOf(state, localSeat).filterKeys { !chore.commoditiesOnly || it.isCommodity } + val quota = minOf(chore.count, pool.values.sum()) + CatanGoodsPickDialog( + title = "Hand over $quota ${if (quota == 1) "card" else "cards"}", + intro = if (chore.commoditiesOnly) "Choose a commodity to give" else "Choose which cards to give", + held = pool, + quota = quota, + confirmLabel = "Give", + selectable = { !chore.commoditiesOnly || it.isCommodity }, + allowed = { check(it, ::GiveGoods) }, + onConfirm = { onSubmit(GiveGoods(it)) }, + ) + } + + is PickDeckChore -> CatanPickDeckDialog(legal) { onSubmit(it) } + + is DropProgressChore -> CatanDropProgressDialog(ck.progressHands[localSeat]) { onSubmit(it) } + + is AqueductChore -> CatanAqueductDialog(legal) { onSubmit(it) } + + is SpyChore -> CatanSpyDialog(ck.progressHands[chore.target], legal) { onSubmit(it) } + + else -> Unit + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt index 256065e..7cc53c6 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt @@ -3,8 +3,10 @@ package org.prolibertate.games.ui.game.catan import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import org.prolibertate.games.game.catan.Commodity import org.prolibertate.games.game.catan.Resource import org.prolibertate.games.game.catan.Terrain +import org.prolibertate.games.game.catan.Track /** * Every visual decision for the Katan screen, in one place. @@ -46,6 +48,19 @@ data class CatanStyle( val resource: Map, val resourceInk: Color, + // --- Cities & Knights --------------------------------------------------- + /** Commodity cards: cloth, coin and paper. */ + val commodity: Map = LightCommodities, + /** Ink on a commodity tile (paper is pale, so it is not white). */ + val commodityInk: Map = LightCommodityInk, + /** The colour of each improvement track, its city gate, its deck and its metropolis. */ + val track: Map = LightTracks, + /** Wall round a city, and the barbarian strip. */ + val wall: Color = Color(0xFF5D4037), + val barbarian: Color = Color(0xFFB71C1C), + /** The halo round a knight that is awake. White, so it is not mistaken for the yellow tap ring. */ + val knightReady: Color = Color(0xFFFFFFFF), + // --- Players ------------------------------------------------------------ /** One colour per seat, in seat order. Roads, settlements, cities, player strip. */ val seats: List, @@ -72,6 +87,25 @@ data class CatanStyle( val tapReach: Float = 0.55f, val tokenText: Float = 0.34f, val portText: Float = 0.27f, + val knightSize: Float = 0.30f, +) + +private val LightCommodities = mapOf( + Commodity.CLOTH to Color(0xFF7E57C2), + Commodity.COIN to Color(0xFFF9A825), + Commodity.PAPER to Color(0xFFECEFF1), +) + +private val LightCommodityInk = mapOf( + Commodity.CLOTH to Color(0xFFFFFFFF), + Commodity.COIN to Color(0xFF3E2723), + Commodity.PAPER to Color(0xFF263238), +) + +private val LightTracks = mapOf( + Track.TRADE to Color(0xFFF2C300), + Track.POLITICS to Color(0xFF1E6FD9), + Track.SCIENCE to Color(0xFF2E9E4F), ) /** The light palette, which is also the reference for the dark one. */ 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 0c523af..8694f2f 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 @@ -1,12 +1,29 @@ package org.prolibertate.games.ui.game.catan +import org.prolibertate.games.game.catan.AqueductChore +import org.prolibertate.games.game.catan.CatanCk import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.DevCardType +import org.prolibertate.games.game.catan.DiscardChore +import org.prolibertate.games.game.catan.DropProgressChore +import org.prolibertate.games.game.catan.EventFace +import org.prolibertate.games.game.catan.GiveChore +import org.prolibertate.games.game.catan.Good +import org.prolibertate.games.game.catan.MetropolisChore +import org.prolibertate.games.game.catan.PickDeckChore +import org.prolibertate.games.game.catan.PillageChore +import org.prolibertate.games.game.catan.PlaceKnightChore import org.prolibertate.games.game.catan.Port +import org.prolibertate.games.game.catan.ProgressType +import org.prolibertate.games.game.catan.RelocateChore +import org.prolibertate.games.game.catan.RemoveKnightChore import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.SmithChore +import org.prolibertate.games.game.catan.SpyChore import org.prolibertate.games.game.catan.Terrain +import org.prolibertate.games.game.catan.Track /** * Who is who at the table, for wording. [names] may be empty or short — a @@ -140,7 +157,12 @@ object CatanText { seats: CatanSeats, setupVertexChosen: Boolean, building: BuildKind?, + /** Cities & Knights: a knight has been picked to move, and its destination is awaited. */ + movingKnight: Boolean = false, + /** Cities & Knights: a progress card is waiting for a tap on the board. */ + armed: ProgressType? = null, ): String { + if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed) val me = seats.me val onClock = CatanRules.currentSeat(state) val mine = onClock == me @@ -194,6 +216,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 { @@ -236,4 +260,216 @@ object CatanText { val winner = state.winner ?: return "Game over" return if (winner == seats.me) "You won!" else "${seats.label(winner)} won" } + + // --- Cities & Knights ---------------------------------------------------- + + fun good(good: Good): String = when (good) { + Good.LUMBER -> "Lumber" + Good.BRICK -> "Brick" + Good.WOOL -> "Wool" + Good.GRAIN -> "Grain" + Good.ORE -> "Ore" + Good.CLOTH -> "Cloth" + Good.COIN -> "Coin" + Good.PAPER -> "Paper" + } + + /** "2 Lumber + 1 Cloth" for a flat list of goods. */ + fun goods(cards: List): String = + cards.groupingBy { it }.eachCount().entries + .sortedBy { it.key.ordinal } + .joinToString(" + ") { (g, n) -> "$n ${good(g)}" } + .ifEmpty { "nothing" } + + fun track(track: Track): String = when (track) { + Track.TRADE -> "Trade" + Track.POLITICS -> "Politics" + Track.SCIENCE -> "Science" + } + + /** What the third level of a track gives, for the improvement dialog. */ + fun trackPower(track: Track): String = when (track) { + Track.TRADE -> "From level 3, trade commodities with the bank two for one." + Track.POLITICS -> "From level 3, knights can be promoted to mighty." + Track.SCIENCE -> "From level 3, when the dice pay you nothing, take any resource." + } + + fun knightName(level: Int): String = when (level) { + 1 -> "Basic knight" + 2 -> "Strong knight" + else -> "Mighty knight" + } + + fun event(face: EventFace?): String = when (face) { + null -> "–" + EventFace.SHIP -> "Ship" + EventFace.TRADE -> "Trade" + EventFace.POLITICS -> "Politics" + EventFace.SCIENCE -> "Science" + } + + fun progress(kind: ProgressType): String = CatanCk.progressName(kind) + + fun progressHelp(kind: ProgressType): String = when (kind) { + ProgressType.ALCHEMIST -> "Before rolling, choose what the two number dice show." + ProgressType.CRANE -> "Your next city improvement costs one commodity less." + ProgressType.ENGINEER -> "Raise a wall round one of your cities for free." + ProgressType.INVENTOR -> "Swap two number tokens (not 2, 6, 8 or 12)." + ProgressType.IRRIGATION -> "Take 2 grain for each fields hex beside your settlements and cities." + ProgressType.MEDICINE -> "Upgrade a settlement to a city for 2 ore and 1 grain." + ProgressType.MINING -> "Take 2 ore for each mountains hex beside your settlements and cities." + ProgressType.PRINTER -> "One victory point." + ProgressType.ROAD_BUILDING -> "Build two roads for free." + ProgressType.SMITH -> "Promote up to two of your knights one level for free." + ProgressType.BISHOP -> "Move the robber and take a card from every player beside its new hex." + ProgressType.CONSTITUTION -> "One victory point." + ProgressType.DESERTER -> "Another player removes a knight; you place a free one of the same strength." + ProgressType.DIPLOMAT -> "Remove an open road. If it was yours, build it again anywhere for free." + ProgressType.INTRIGUE -> "Drive an opponent's knight off a crossing beside one of your roads." + ProgressType.SABOTEUR -> "Every player with as many points as you or more discards half their cards." + ProgressType.SPY -> "Look at another player's progress cards and take one." + ProgressType.WARLORD -> "Wake all your knights without paying grain." + ProgressType.WEDDING -> "Every player with more points gives you 2 cards." + ProgressType.COMMERCIAL_HARBOR -> "Offer a resource to each player; each swaps it for a commodity." + ProgressType.MASTER_MERCHANT -> "Take 2 cards from a player with more points." + ProgressType.MERCHANT -> "Put the merchant beside your settlement: trade that resource 2:1 and score a point." + ProgressType.MERCHANT_FLEET -> "This turn, trade one chosen card type with the bank two for one." + ProgressType.RESOURCE_MONOPOLY -> "Name a resource. Every other player gives you 2 of it." + ProgressType.TRADE_MONOPOLY -> "Name a commodity. Every other player gives you 1 of it." + } + + const val IMPROVE = "Improve" + const val KNIGHTS = "Knights" + const val CARDS_BUTTON = "Progress" + const val WALL = "Wall" + const val KNIGHT = "Knight" + const val STOP_SMITH = "Done promoting" + + val KNIGHT_COST: String get() = cost(CatanCk.KNIGHT_COST) + val ACTIVATE_COST: String get() = cost(CatanCk.ACTIVATE_COST) + val WALL_COST: String get() = cost(CatanCk.WALL_COST) + + private fun ckPrompt( + state: CatanState, + seats: CatanSeats, + setupVertexChosen: Boolean, + building: BuildKind?, + movingKnight: Boolean, + armed: ProgressType?, + ): String { + val me = seats.me + val onClock = CatanRules.currentSeat(state) + val mine = onClock == me + val turn = seats.label(state.turn) + val ck = state.ck!! + + return when (state.phase) { + CatanPhase.GAME_OVER -> { + val winner = state.winner + if (winner == null) "The game is over." else "${seats.label(winner)} won the game." + } + + CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when { + !mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…" + setupVertexChosen -> "Now tap the road to place beside that settlement." + state.phase == CatanPhase.SETUP_SECOND -> + "Place your second piece: it is a city, and earns resources from the hexes around it." + + else -> "Place a settlement, then a road." + } + + CatanPhase.AWAIT_ROLL -> when { + !mine -> "$turn is rolling…" + armed != null -> armedPrompt(armed) + movingKnight -> "Tap where the knight should go." + else -> "Your turn. Roll the dice, or wake knights and play progress cards first." + } + + CatanPhase.ROBBER -> when { + !mine -> "$turn is moving the robber…" + ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card." + else -> "Move the robber. Tap a hex." + } + + CatanPhase.PLAYING -> when { + !mine -> "$turn is playing…" + armed != null -> armedPrompt(armed) + movingKnight -> "Tap where the knight should go." + building == BuildKind.ROAD -> "Tap an edge to build a road." + building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." + building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." + building == BuildKind.WALL -> "Tap one of your cities to raise a wall." + building == BuildKind.KNIGHT -> "Tap an empty crossing at the end of your road to hire a knight." + else -> "Your turn. Build, improve, trade, or end the turn. Tap a knight to use it." + } + + CatanPhase.FREE_ROADS -> + if (mine) { + "Place ${state.pendingFreeRoads} free " + + if (state.pendingFreeRoads == 1) "road." else "roads." + } else { + "$turn is placing free roads…" + } + + CatanPhase.TRADE_PENDING -> { + val offer = ck.goodsOffer + val asking = offer?.asking?.takeIf { it >= 0 } + when { + offer == null || asking == null -> "Waiting for a reply…" + asking == me -> "${seats.label(offer.by)} offers you a trade." + offer.by == me -> "Waiting for ${seats.label(asking)} to answer your offer…" + else -> "${seats.label(offer.by)} has offered ${seats.label(asking)} a trade." + } + } + + CatanPhase.TRADE_COUNTERS -> + if (mine) { + "Nobody accepted, but there are counter-offers. Take one or turn them down." + } else { + "$turn is weighing counter-offers…" + } + + CatanPhase.DISCARDING -> "Discarding…" + + CatanPhase.CHORE -> { + val chore = ck.chores.firstOrNull() + val who = seats.label(chore?.seat ?: state.turn) + if (chore == null || chore.seat != me) { + when (chore) { + is PillageChore -> "The barbarians have landed. $who must give up a city." + is DiscardChore -> "$who must discard ${chore.count} cards." + else -> "Waiting for $who…" + } + } else { + when (chore) { + is DiscardChore -> "Discard ${chore.count} cards." + is GiveChore -> "Give ${chore.count} card${if (chore.count == 1) "" else "s"} to ${seats.lower(chore.to)}." + is PillageChore -> "The barbarians won. Tap a city of yours to give up." + is PickDeckChore -> "Choose a progress deck to draw from." + is DropProgressChore -> "You hold too many progress cards. Give one back." + is MetropolisChore -> "Tap the city that will bear the ${track(chore.track).lowercase()} metropolis." + is AqueductChore -> "Your aqueduct pays. Take any resource." + is RelocateChore -> "Your knight was driven off. Tap where it goes." + is RemoveKnightChore -> "Tap one of your knights to remove." + is PlaceKnightChore -> "Tap where your free ${knightName(chore.level).lowercase()} stands." + is SpyChore -> "Choose a progress card to take." + is SmithChore -> "Tap a knight to promote (${chore.left} left), or stop." + } + } + } + } + } + + private fun armedPrompt(kind: ProgressType): String = when (kind) { + ProgressType.ENGINEER -> "Engineer: tap a city to wall." + ProgressType.MEDICINE -> "Medicine: tap a settlement to upgrade." + ProgressType.MERCHANT -> "Merchant: tap a hex beside one of your buildings." + ProgressType.DIPLOMAT -> "Diplomat: tap an open road to remove." + ProgressType.INTRIGUE -> "Intrigue: tap an enemy knight beside your road." + ProgressType.INVENTOR -> "Inventor: tap two hexes to swap their tokens." + else -> "Tap the board." + } + + /** "6 steps away", "1 step away" for the barbarian ship. */ + fun barbarianSteps(steps: Int): String = "$steps ${if (steps == 1) "step" else "steps"} away" } 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..9fca90a --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt @@ -0,0 +1,1018 @@ +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 `an open goods offer is asked of each seat in turn and the first to accept takes it`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1) + s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))) + assertEquals(CatanPhase.TRADE_PENDING, s.phase) + assertEquals("the seat after the offerer is asked first", 1, CatanRules.currentSeat(s)) + s = apply(s, 1, DeclineTrade) + assertEquals(2, CatanRules.currentSeat(s)) + s = apply(s, 2, AcceptTrade) + assertEquals(CatanPhase.PLAYING, s.phase) + assertEquals(2, s.ck!!.commodities[2][Commodity.CLOTH]) + assertEquals(1, s.resources[0][Resource.ORE]) + assertEquals("seat 1 was never asked a second time and keeps its ore", 1, s.resources[1][Resource.ORE]) + assertNull(s.ck!!.goodsOffer) + } + + @Test + fun `an open goods offer nobody takes ends with a word and no change`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1) + s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))) + s = apply(s, 1, DeclineTrade) + s = apply(s, 2, DeclineTrade) + assertEquals(CatanPhase.PLAYING, s.phase) + assertTrue(s.log.last().contains("Nobody takes the offer")) + assertEquals(2, s.ck!!.commodities[0][Commodity.CLOTH]) + } + + @Test + fun `a seat may counter a goods offer and the offerer takes the counter or none`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1) + s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))) + // Seat 1 would swap one for one; taking the offer as made is Accept, not a counter. + assertFalse( + CatanRules.isLegalMove(s, 1, CounterGoods(listOf(Good.ORE), listOf(Good.CLOTH, Good.CLOTH))), + ) + s = apply(s, 1, CounterGoods(listOf(Good.ORE), listOf(Good.CLOTH))) + s = apply(s, 2, DeclineTrade) + assertEquals(CatanPhase.TRADE_COUNTERS, s.phase) + assertEquals(0, CatanRules.currentSeat(s)) + assertEquals( + setOf(AcceptCounter(1), DismissCounters), + CatanRules.legalMoves(s, 0).toSet(), + ) + val taken = apply(s, 0, AcceptCounter(1)) + assertEquals(CatanPhase.PLAYING, taken.phase) + assertEquals(1, taken.ck!!.commodities[0][Commodity.CLOTH]) + assertEquals(1, taken.resources[0][Resource.ORE]) + assertEquals(1, taken.ck!!.commodities[1][Commodity.CLOTH]) + assertEquals(0, taken.resources[1][Resource.ORE]) + + val refused = apply(s, 0, DismissCounters) + assertEquals(CatanPhase.PLAYING, refused.phase) + assertEquals(2, refused.ck!!.commodities[0][Commodity.CLOTH]) + } + + @Test + fun `an open offer skips seats with no cards at all`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1).hand(2, Good.ORE to 1) + s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))) + assertEquals(2, CatanRules.currentSeat(s)) + } + + @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") } + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt new file mode 100644 index 0000000..085736f --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt @@ -0,0 +1,291 @@ +package org.prolibertate.games.ui.game.catan + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.catan.AcceptCounter +import org.prolibertate.games.game.catan.AcceptTrade +import org.prolibertate.games.game.catan.CounterGoods +import org.prolibertate.games.game.catan.DismissCounters +import org.prolibertate.games.game.catan.ActivateKnight +import org.prolibertate.games.game.catan.BuildCity +import org.prolibertate.games.game.catan.BuildRoad +import org.prolibertate.games.game.catan.BuildSettlement +import org.prolibertate.games.game.catan.BuildWall +import org.prolibertate.games.game.catan.BuyImprovement +import org.prolibertate.games.game.catan.CatanAi +import org.prolibertate.games.game.catan.CatanBoard +import org.prolibertate.games.game.catan.CatanMove +import org.prolibertate.games.game.catan.CatanOptions +import org.prolibertate.games.game.catan.CatanPhase +import org.prolibertate.games.game.catan.CatanRuleSet +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState +import org.prolibertate.games.game.catan.ChaseRobber +import org.prolibertate.games.game.catan.DeclineTrade +import org.prolibertate.games.game.catan.DiscardGoods +import org.prolibertate.games.game.catan.DropProgress +import org.prolibertate.games.game.catan.EndFreeRoads +import org.prolibertate.games.game.catan.EndSmith +import org.prolibertate.games.game.catan.EndTurn +import org.prolibertate.games.game.catan.GiveGoods +import org.prolibertate.games.game.catan.GoodsBankTrade +import org.prolibertate.games.game.catan.HireKnight +import org.prolibertate.games.game.catan.MoveKnight +import org.prolibertate.games.game.catan.MoveRobber +import org.prolibertate.games.game.catan.OfferGoods +import org.prolibertate.games.game.catan.PickDeck +import org.prolibertate.games.game.catan.PillageCity +import org.prolibertate.games.game.catan.PlaceKnight +import org.prolibertate.games.game.catan.PlaceMetropolis +import org.prolibertate.games.game.catan.PlayProgress +import org.prolibertate.games.game.catan.PromoteKnight +import org.prolibertate.games.game.catan.RelocateKnight +import org.prolibertate.games.game.catan.RemoveKnight +import org.prolibertate.games.game.catan.RollDice +import org.prolibertate.games.game.catan.SetupPlace +import org.prolibertate.games.game.catan.SpyTake +import org.prolibertate.games.game.catan.TakeAqueduct +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * Cities & Knights has no device in this test suite, so the screen's logic is + * checked another way: engines play whole games, and at every position the + * player on the clock is treated as the local one. For every move the engine says + * is legal, [CatanInteraction] (and the dialogs' own filters) must offer a way to + * make it — a tap, a button or a dialog choice. A legal move that no control can + * produce would be a stuck game on a real phone. + */ +class CatanCkInteractionTest { + + private val json = Json { encodeDefaults = true } + + private fun config(players: Int, seed: Long) = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString( + CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS), + ), + seed = seed, + ) + + /** How a move reaches the engine from the screen, or null if nothing can produce it. */ + private fun reachable(state: CatanState, legal: List, move: CatanMove): String? { + fun tapVertex(vertex: Int, building: BuildKind? = null, moving: Int? = null, armed: ArmedCard? = null): TapOutcome = + CatanInteraction.onVertexTap(vertex, legal, building, null, moving, armed) + + return when (move) { + is SetupPlace -> { + val vertex = CatanInteraction.targets(state, legal, null, null).vertices + val edges = CatanInteraction.targets(state, legal, null, move.vertex).edges + "setup".takeIf { + move.vertex in vertex && move.edge in edges && + CatanInteraction.onVertexTap(move.vertex, legal, null, null) is TapOutcome.PickSetupVertex && + CatanInteraction.onEdgeTap(move.edge, legal, null, move.vertex) == TapOutcome.Submit(move) + } + } + + is BuildRoad -> { + val targets = CatanInteraction.targets(state, legal, BuildKind.ROAD, null) + "road".takeIf { + move.edge in targets.edges && + CatanInteraction.onEdgeTap(move.edge, legal, BuildKind.ROAD, null) == TapOutcome.Submit(move) + } + } + + is BuildSettlement -> viaBuild(state, legal, BuildKind.SETTLEMENT, move.vertex, move) + is BuildCity -> viaBuild(state, legal, BuildKind.CITY, move.vertex, move) + is BuildWall -> viaBuild(state, legal, BuildKind.WALL, move.vertex, move) + is HireKnight -> viaBuild(state, legal, BuildKind.KNIGHT, move.vertex, move) + + is MoveRobber -> { + val hex = CatanBoard.hexIndex.getValue(move.hex) + val targets = CatanInteraction.targets(state, legal, null, null) + val tap = CatanInteraction.onHexTap(hex, legal) + "robber".takeIf { + hex in targets.hexes && + (tap == TapOutcome.Submit(move) || (tap is TapOutcome.PickRobberVictim && move.victim in tap.victims)) + } + } + + is MoveKnight -> { + val targets = CatanInteraction.targets(state, legal, null, null, movingKnight = move.from) + "moveKnight".takeIf { + move.to in targets.vertices && tapVertex(move.to, moving = move.from) == TapOutcome.Submit(move) && + move.from in CatanInteraction.targets(state, legal, null, null).knights && + CatanInteraction.knightActions(legal, move.from).any { it is MoveKnight } + } + } + + is ActivateKnight, is ChaseRobber, is PromoteKnight -> { + val vertex = when (move) { + is ActivateKnight -> move.vertex + is ChaseRobber -> move.vertex + else -> (move as PromoteKnight).vertex + } + val smith = legal.any { it is EndSmith } + if (move is PromoteKnight && smith) { + val targets = CatanInteraction.targets(state, legal, null, null) + "smith".takeIf { + vertex in targets.vertices && tapVertex(vertex) == TapOutcome.Submit(move) + } + } else { + val targets = CatanInteraction.targets(state, legal, null, null) + "knightMenu".takeIf { + vertex in targets.knights && tapVertex(vertex) == TapOutcome.PickKnight(vertex) && + move in CatanInteraction.knightActions(legal, vertex) + } + } + } + + is PillageCity -> viaChore(state, legal, move.vertex, move) + is PlaceMetropolis -> viaChore(state, legal, move.vertex, move) + is RelocateKnight -> viaChore(state, legal, move.to, move) + is RemoveKnight -> viaChore(state, legal, move.vertex, move) + is PlaceKnight -> viaChore(state, legal, move.vertex, move) + + is PlayProgress -> viaCard(state, legal, move) + + is BuyImprovement -> "improve".takeIf { move.track in CatanInteraction.improvable(legal) } + + is GoodsBankTrade -> + "bankTrade".takeIf { move.get in CatanInteraction.goodsBankTrades(legal)[move.give].orEmpty() } + + RollDice(), is RollDice -> "roll".takeIf { CatanInteraction.canRoll(legal) } + EndTurn -> "endTurn".takeIf { CatanInteraction.canEndTurn(legal) } + EndFreeRoads -> "endRoads".takeIf { CatanInteraction.canEndFreeRoads(legal) } + EndSmith -> "endSmith".takeIf { CatanInteraction.canEndSmith(legal) } + AcceptTrade, DeclineTrade -> "tradeReply".takeIf { CatanInteraction.tradeResponses(legal) != (false to false) } + + // Answered in a dialog that offers exactly the moves in the legal list. + is PickDeck, is DropProgress, is TakeAqueduct, is SpyTake -> "choreDialog" + + // Structural: the discard and give dialogs build these and ask the engine. + is DiscardGoods, is GiveGoods -> "goodsDialog" + is OfferGoods, is CounterGoods -> "tradeDialog" + is AcceptCounter, DismissCounters -> "counterDialog" + + else -> null + } + } + + private fun viaBuild(state: CatanState, legal: List, kind: BuildKind, vertex: Int, move: CatanMove): String? { + val targets = CatanInteraction.targets(state, legal, kind, null) + return "build $kind".takeIf { + vertex in targets.vertices && + CatanInteraction.onVertexTap(vertex, legal, kind, null) == TapOutcome.Submit(move) && + kind in CatanInteraction.canBuild(legal) + } + } + + private fun viaChore(state: CatanState, legal: List, vertex: Int, move: CatanMove): String? { + val targets = CatanInteraction.targets(state, legal, null, null) + return "chore".takeIf { + vertex in targets.vertices && + CatanInteraction.onVertexTap(vertex, legal, null, null) == TapOutcome.Submit(move) + } + } + + private fun viaCard(state: CatanState, legal: List, move: PlayProgress): String? { + val seat = CatanRules.currentSeat(state) ?: return null + val entry = CatanInteraction.progressHand(state, seat, legal).firstOrNull { it.card.id == move.card } + ?: return null + val kind = entry.card.kind + if (move !in entry.plays) return null + if (kind !in CatanInteraction.BOARD_CARDS) return "cardDialog" + + val armed = ArmedCard(kind, entry.plays) + val targets = CatanInteraction.targets(state, legal, null, null, armed = armed) + return when (kind) { + org.prolibertate.games.game.catan.ProgressType.DIPLOMAT -> + "armed".takeIf { + move.edge in targets.edges && + CatanInteraction.onEdgeTap(move.edge!!, legal, null, null, armed) == TapOutcome.Submit(move) + } + + org.prolibertate.games.game.catan.ProgressType.MERCHANT -> + "armed".takeIf { + move.hexA in targets.hexes && + CatanInteraction.onHexTap(move.hexA!!, legal, armed) == TapOutcome.Submit(move) + } + + org.prolibertate.games.game.catan.ProgressType.INVENTOR -> { + val first = CatanInteraction.onHexTap(move.hexA!!, legal, armed) + if (first != TapOutcome.PickFirstHex(move.hexA!!)) return null + val second = armed.copy(firstHex = move.hexA) + val partners = CatanInteraction.targets(state, legal, null, null, armed = second).hexes + "armed".takeIf { + move.hexB in partners && + CatanInteraction.onHexTap(move.hexB!!, legal, second) == TapOutcome.Submit(move) + } + } + + else -> + "armed".takeIf { + move.vertex in targets.vertices && + CatanInteraction.onVertexTap(move.vertex!!, legal, null, null, null, armed) == TapOutcome.Submit(move) + } + } + } + + private fun playAndCheck(players: Int, seed: Long): Map { + val ai = CatanAi() + var state = CatanRules.initialState(config(players, seed)) + val seen = mutableMapOf() + var moves = 0 + while (!CatanRules.isFinished(state) && moves++ < 20_000) { + val seat = CatanRules.currentSeat(state) ?: break + val legal = CatanRules.legalMoves(state, seat) + for (move in legal) { + val how = reachable(state, legal, move) + assertTrue( + "seed $seed: no control produces $move in ${state.phase} " + + "(chore ${state.ck?.chores?.firstOrNull()}); log tail: ${state.log.takeLast(3)}", + how != null, + ) + seen.merge(how!!, 1, Int::plus) + } + state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal)) + } + return seen + } + + @Test + fun `every legal move in a full game has a control that produces it`() { + val total = mutableMapOf() + listOf(3 to 21L, 4 to 22L, 3 to 23L, 4 to 24L).forEach { (players, seed) -> + playAndCheck(players, seed).forEach { (k, v) -> total.merge(k, v, Int::plus) } + } + println("CK UI coverage: ${total.toSortedMap()}") + // The ways the screen builds, moves and answers all got exercised. + listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn") + .forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) } + } + + @Test + fun `a sleeping knight with nothing to do can still be tapped for its menu`() { + val base = CatanRules.initialState(config(3, 5L)) + .copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1) + val state = base.copy( + ck = base.ck!!.copy(knights = listOf(org.prolibertate.games.game.catan.Knight(10, 0, 1))), + ) + val legal = CatanRules.legalMoves(state, 0) + assertTrue("no grain, so nothing to do", CatanInteraction.knightActions(legal, 10).isEmpty()) + + val targets = CatanInteraction.targets(state, legal, null, null, localSeat = 0) + assertTrue(10 in targets.idleKnights) + assertTrue("no tap ring for a knight with nothing to do", 10 !in targets.knights) + assertTrue(10 in targets.tappableVertices) + assertEquals( + TapOutcome.PickKnight(10), + CatanInteraction.onVertexTap(10, legal, null, null, ownKnights = setOf(10)), + ) + // On somebody else's turn the legal list is empty and nothing is offered. + assertTrue(CatanInteraction.targets(state, emptyList(), null, null, localSeat = 0).idleKnights.isEmpty()) + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt new file mode 100644 index 0000000..8a8d6f7 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt @@ -0,0 +1,75 @@ +package org.prolibertate.games.ui.game.catan + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.catan.CatanAi +import org.prolibertate.games.game.catan.CatanOptions +import org.prolibertate.games.game.catan.CatanRuleSet +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.ProgressType +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.ui.catanDefaultPoints +import org.prolibertate.games.ui.catanPointsChoices +import org.prolibertate.games.ui.catanSwitchRules + +class CatanCkTextTest { + + private val json = Json { encodeDefaults = true } + + @Test + fun `every position of a Cities and Knights game has a prompt for every seat`() { + val config = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString( + CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS), + ), + seed = 31L, + ) + var state = CatanRules.initialState(config) + val ai = CatanAi() + var moves = 0 + while (!CatanRules.isFinished(state) && moves++ < 20_000) { + for (me in 0 until 3) { + val seats = CatanSeats(me, listOf("A", "B", "C")) + val text = CatanText.prompt(state, seats, setupVertexChosen = false, building = null) + assertTrue("blank prompt in ${state.phase} for seat $me", text.isNotBlank()) + } + val seat = CatanRules.currentSeat(state) ?: break + state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat))) + } + } + + @Test + fun `every progress card has a name and a description`() { + ProgressType.entries.forEach { + assertTrue(CatanText.progress(it).isNotBlank()) + assertTrue("no help for $it", CatanText.progressHelp(it).length > 10) + } + } + + @Test + fun `the setup screen offers each rule set its own targets and switches sensibly`() { + assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE)) + assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS)) + assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE)) + assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS)) + + val base = CatanOptions() + val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS) + assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet) + assertEquals("the published target for the new rules", 13, ck.pointsToWin) + assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin) + + // A short game picked on purpose is kept when both menus have it. + val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS) + assertEquals(8, short.pointsToWin) + assertEquals(3, ck.playerCount) + } +} diff --git a/docs/README.md b/docs/README.md index d6ef5a9..3a93b26 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,7 +24,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`. | Golf | [RULES-golf.md](RULES-golf.md) | | Hearts | — (TODO) | | Kaiser | [RULES-kaiser.md](RULES-kaiser.md) | -| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) | +| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) | | Klondike | — (TODO) | | Mastermind | [RULES-mastermind.md](RULES-mastermind.md) | | Morris | [RULES-morris.md](RULES-morris.md) | diff --git a/docs/RULES-catan-ck.md b/docs/RULES-catan-ck.md new file mode 100644 index 0000000..f34fc8c --- /dev/null +++ b/docs/RULES-catan-ck.md @@ -0,0 +1,222 @@ +# Katan: Cities & Knights — implemented rules + +This is the second rule set of Katan (see [RULES-catan.md](RULES-catan.md) for the +base game). It is chosen on the setup screen — **Rules: Base game / Cities & +Knights** — and stored as `CatanOptions.ruleSet` (`CatanRuleSet.CITIES_AND_KNIGHTS`). +A table plays one or the other; the choice reaches guests with the rest of the +options. + +The expansion is a whole rule set rather than a toggle, because it replaces rules +(no development cards, no Largest Army, a different seven) as well as adding them. +Everything it needs lives behind `CatanState.ck`, which is `null` in the base game, +so a base game's state and rules are untouched. The code is: + +| File | Holds | +| --- | --- | +| `CatanCkModel.kt` | the data: commodities, improvement tracks, progress cards, knights, the chore queue, every new move | +| `CatanCk.kt` | the rules: legal moves, applying them, the roll, the barbarians, scoring | +| `CatanCkAi.kt` | the computer opponent for this rule set | +| `CatanRules.kt` | the single entry point; calls into `CatanCk` whenever `state.ck != null` | + +Behaviour is pinned by `CatanCkRulesTest` (each rule on a crafted position) and +`CatanCkSelfPlayTest` (whole games, engine against engine). Three or four players, +as in the base game. + +## Verification + +The rules were checked against the in-game almanac of Catan Universe (rules text +only: costs, card wordings, the barbarian and improvement rules). Where the almanac +is silent or ambiguous the reading chosen is listed under *Choices made here* below. + +## What changes from the base game + +| | Base game | Cities & Knights | +| --- | --- | --- | +| Points to win | 10 | **13** (setup offers 8 / 10 / 13 / 15) | +| Development cards | 25-card deck | **none** — knights and progress cards replace them | +| Largest Army | 5 *(see RULES-catan.md)* | **none** | +| Longest Road | 5 *(see RULES-catan.md)* | **2** | +| Second setup piece | settlement | **city** (resources only, no commodities) | +| Dice | two | **two number dice + the event die** | +| A seven | discard half over 7; robber moves | discard half over **7 + 2 per wall**; the robber **only moves after the first barbarian attack** | +| Cities | 2 of a resource | forest: 1 lumber + **1 paper**; pasture: 1 wool + **1 cloth**; mountains: 1 ore + **1 coin**; hills / fields: 2 of the resource | + +## The dice + +The yellow die and the red die are added for production, as in the base game. The +**event die** has three ships and one gate each of yellow (trade), blue (politics) +and green (science). It is resolved **before** production: + +- **Ship** — the barbarians come one step nearer (they start 7 steps out). +- **Gate** — every player with at least one level of improvement in that colour, + **in turn order from the roller**, whose **red die is no higher than their level** + in that track, draws a progress card of that colour. + +`RollDice(forced, event)` stages both for tests, like the base game's forced roll. +An **Alchemist** played before the roll sets the two number dice (the event die is +still rolled). + +## Commodities + +Three commodities — **cloth, coin, paper** — join the five resources. The bank holds +12 of each (19 of each resource). Hands, discards, thefts and trades all treat them as +cards; progress cards are not cards for any of these purposes. + +When the bank cannot meet the demand for a card type: with **one** seat owed it, +that seat takes what is left; with **two or more** owed, nobody receives it. + +## The barbarians + +The strip shows how many steps away the ship is. On the seventh ship it **lands**: + +- Barbarian strength is the **number of cities on the board** (a metropolis is a city). +- Catan's strength is the sum of the strengths of every **active** knight (basic 1, + strong 2, mighty 3). +- If Catan's strength is **at least** the barbarians', Catan holds: the player with + the strictly highest contribution takes a **Defender of Catan** card (one point); + if several tie for highest, **each draws a progress card from a deck of their + choice** instead. +- Otherwise Catan falls: among the players who own a city that is not a metropolis, + those who contributed **least** each lose one city, **to their own choice**. A lost + city becomes a settlement (or, if the seat has no settlement piece left, is removed), + and its wall goes. **A metropolis can never be pillaged.** +- Either way every knight stands down, the ship returns to seven steps, and the + **robber wakes** — until now it has not been on the board. + +## Knights + +Each seat has **two knights of each strength** (six in all). + +| Action | Cost | Rule | +| --- | --- | --- | +| Hire | 1 ore + 1 wool | a **basic** knight on an empty crossing at the end of one of your roads; only if one of your two basic knights is free | +| Activate | 1 grain | a sleeping knight wakes; only active knights count against the barbarians | +| Promote | 1 ore + 1 wool | one level, once per knight per turn; a **mighty** knight needs **level three of politics** (the fortress); the next level's pieces must be free | +| Move | — | an active knight moves along **its own roads** to an empty crossing, or onto a **strictly weaker** foreign knight, driving it off; it then rests | +| Chase the robber | — | an active knight on a crossing of the robber's hex sends the robber off (only once the robber is awake); it then rests, and the robber move follows | + +- **A knight cannot act on the turn it woke.** It can be woken again after it acts. +- A driven-off knight is moved by **its owner** along its own roads to any empty + crossing it can reach; if there is none, it is lost. +- A knight is a wall, like an opposing building: roads cannot be laid past an enemy + knight, a settlement cannot be built on a knight, and the knight cuts **Longest Road**. +- Knights can act (wake, move, chase) **before the roll** as well as after. + +## City walls + +2 brick, on a city without a wall, **three per player**. Each wall raises the hand +limit on a seven by two (so a seven discards when you hold more than `7 + 2 × walls`). + +## City improvements + +Three tracks, five levels each, bought with the track's commodity: **trade (cloth, +yellow), politics (coin, blue), science (paper, green)**. Level *n* costs *n* of the +commodity. You must own a city to improve. + +| Track | Level 3 gives | +| --- | --- | +| Trade | commodities trade with the bank **2:1** | +| Politics | knights may be promoted to **mighty** | +| Science | the **aqueduct**: when the dice pay you nothing, take any resource | + +The level names (Trading Post … Grand Bazaar, Town Hall … Palace, Abbey … University) +are this app's own. + +### Metropolises + +The first player to reach **level four** in a track takes that track's **metropolis** +(a marker on one of their cities: +2 points; a city carries at most one). Reaching +**level five** takes it from a holder who is still on four. A player with several +cities chooses which; with one it is placed at once; a player with no free city +**waits**, and claims it when a city is built. + +## Progress cards + +54 cards: 18 in each of three decks. A card played returns to the **bottom** of its +deck. A hand holds **four**; a fifth must be given back at once. They can be played at +any point of your own turn (before or after the roll), except where noted. + +**Science (green)** — Alchemist ×2 *(before the roll only)*, Crane ×2, Engineer, Inventor ×2, +Irrigation ×2, Medicine ×2, Mining ×2, Printer, Road Building ×2, Smith ×2. + +**Politics (blue)** — Bishop ×2 *(after the first attack)*, Constitution, Deserter ×2, +Diplomat ×2, Intrigue ×2, Saboteur ×2, Spy ×3, Warlord ×2, Wedding ×2. + +**Trade (yellow)** — Commercial Harbor ×2, Master Merchant ×2, Merchant ×6, +Merchant Fleet ×2, Resource Monopoly ×4, Trade Monopoly ×2. + +**Printer** and **Constitution** are one point each and are **laid face up the moment +they are drawn**; they never sit in a hand. + +How each plays is shown on the card in the app (`CatanText.progressHelp`); the engine's +behaviour is in `CatanCk.applyProgress`. Points worth knowing: + +- **Saboteur** hits every other player with **as many points as you or more**, who each + discard half their cards (rounded down). **Wedding** takes two cards (or what they + have) from each player with **more** points. **Master Merchant** needs a victim with + more points, and takes two cards you choose. +- **Spy**: you see the target's progress cards and take one; nobody else sees them. +- **Merchant**: placed beside one of your buildings; you trade that hex's resource 2:1 + and hold a victory point until someone else plays a Merchant. +- **Deserter**: the victim removes a knight of *their* choice; you place a free one of the + same strength (or a basic if yours is not free), awake or asleep as theirs was. +- **Diplomat**: removes a road with a loose end (an end touching no other road, building + or knight); if it was yours you may rebuild it anywhere for free. +- **Inventor** swaps two tokens, never a 2, 6, 8 or 12. + +## The chore queue + +Anything that needs an answer from somebody — a discard, a city to lose, a progress card +to hand over, a metropolis site, a knight to place — is queued as a **chore** +(`CatanPhase.CHORE`, `CkState.chores`). The game waits on the seat the first chore names +(not always the player whose turn it is), then resumes where `CkState.back` says. This is +how a seven, a barbarian landing, a wedding or a spy are all one mechanism, and why +the AI and the network driver needed no new concepts. + +The two free-form chores — **discarding** and **handing over** cards — are structural +like the base game's trade offers: the engine checks them in `isLegalMove` and the legal +list carries one example, so a seat is never without a move. + +## Scoring and winning + +Settlement 1, city 2, **metropolis +2**, **Longest Road 2**, **Defender of Catan 1** each, +**Printer / Constitution 1** each, **the Merchant 1**. Nothing is hidden. Win at 13 (default), +checked after every move and when a turn arrives. + +## Choices made here, and why + +- **The red die must be no higher than the improvement level** to draw a progress card + (level 1 draws on a 1, level 5 on 1–5). The almanac says the red die must match a + number on one of your improvements and that the number rises with the level; this is + the reading that makes both true. +- **One resource is offered to every player for a Commercial Harbor.** The printed card + lets you offer a different one to each. +- **The merchant cannot go on the desert**, which produces nothing to trade. +- **Improvements need a city** to be built. +- **A 3:1 harbour trades commodities too**; a 2:1 harbour trades only its own resource. +- **A knight that cannot find a refuge is lost**, as the almanac says of an intrigue. +- **A claim on a metropolis with no city to carry it waits** and is made when a city is + built. +- **Domestic trade** works as in the base game — an offer to one seat or to the whole + table, asked in seat order, each seat accepting, declining or countering — and may + include commodities (`OfferGoods`, `CounterGoods`; accepting and dismissing counters + reuse the base game's `AcceptCounter` and `DismissCounters`). +- **No Seafarers, no 5–6 player extension**, no scenario or variant of any kind. + +## The computer opponent + +`CatanCkAi` plays a safe, unambitious game: it settles and raises cities as the base AI +does, spends commodities on improvements as soon as it can (leaning to politics), hires +and wakes knights as the barbarians draw near, chases the robber off hexes it needs, plays +the progress cards whose value is obvious, and answers every chore with the card it values +least. It never plans a knight's route, never plays an Alchemist, Inventor, Diplomat, +Deserter or Intrigue, and makes no domestic offers (though it answers them). Every engine +game in `CatanCkSelfPlayTest` ends in a win at thirteen points. + +## Known gaps + +- The screen is built and its logic is tested, but **not yet verified on two real + devices**; the catalogue entry stays a dev-channel preview. +- No animation for the event die, the barbarian landing or a knight's move. +- The Commercial Harbor offers a single resource type (see above). +- The AI is not a strong opponent and makes no claim to be. diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index b46c415..3e8df72 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -12,14 +12,27 @@ players, as published. The engine is a pure state machine (`CatanRules`) with no Android types, and its behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`. +## Rule sets + +A table plays one of two rule sets, chosen on the setup screen +(`CatanOptions.ruleSet`): + +- **Base game** — everything in this document. +- **Cities & Knights** — the expansion, documented in + [RULES-catan-ck.md](RULES-catan-ck.md). It reuses this document's board, setup, + building, robber and trade rules and changes or replaces the rest; its state lives + behind `CatanState.ck`, which is `null` in the base game. + ## Table +- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`). - **Players — *toggle*.** Three or four. `CatanOptions.playerCount`; the engine refuses anything else. - **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at least 2. A lower target makes a test game short. -The setup screen exposes both (players: 3 or 4; game to 5, 8, 10 or 12 points). +The setup screen exposes all three (players: 3 or 4; game to 5, 8, 10 or 12 points in the +base game, 8, 10, 13 or 15 in Cities & Knights, whose published target is 13). ## Board @@ -311,6 +324,12 @@ timing rules; Longest Road and Largest Army worth five; victory at ten. ## Known gaps +- **Longest Road and Largest Army are worth 5 each here.** The published base game awards + **2** each (Catan Universe's almanac says so for Largest Army, and the printed rules + for both). Cities & Knights scores Longest Road at 2, as printed. Left unchanged in + the base game so far; it is a one-constant change in `publicVictoryPoints`. +- **A bank short of a resource pays nobody that roll**, even when only one player is owed + it. The published ruling lets a lone claimant take what is left; Cities & Knights does. - **No timer or cancel on offers.** An offer waits for each asked seat in turn; the offerer cannot withdraw it mid-round, and a player who never answers stalls the table. - **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them. - **No trading away a development card.** Not part of the base game. diff --git a/docs/UI-catan.md b/docs/UI-catan.md index 809e317..2ec24fa 100644 --- a/docs/UI-catan.md +++ b/docs/UI-catan.md @@ -121,6 +121,34 @@ and play a game on a dev build. - The board has a content description but no per-hex semantics; a screen-reader mode would need one. +## Cities & Knights on the screen + +The same layers carry the expansion; the screen only adds pieces, and **none of them +decides what is legal** — each reads the engine's legal-move list. + +| Layer | What it adds | +| --- | --- | +| `CatanStyle.kt` | commodity colours, a colour per improvement track, the wall and barbarian colours, the awake-knight halo | +| `CatanText.kt` | goods, track, knight and progress-card names and help text; the prompts for every chore | +| `CatanInteraction.kt` | `BuildKind.WALL` / `KNIGHT`; chore taps; the moving-knight and armed-card modes; `knightActions`, `progressHand`, `goodsBankTrades` | +| `CatanBoardView.kt` | shields for knights (strength dots; faded when asleep, white halo when awake), dashed walls, a pennant for each metropolis, the merchant coin, a faint robber until the first attack | +| `CatanPanels.kt` | commodity tiles in the hand, the barbarian strip, the yellow / red / event dice, the extra player-card lines, the Improve and Progress buttons | +| `CatanCkDialogs.kt` | goods picker (discard, wedding, harbour), improvements, progress cards, the knight menu, deck / aqueduct / spy / drop choices, goods trade | +| `CatanScreen.kt` | the visual state: knight menu, moving knight, armed card, and which chore dialog shows | + +**Interaction model.** Tap one of your own knights (it gets a yellow ring when it can do +something) for its menu: wake, promote, move or chase the robber. *Move* arms the board +with the crossings the knight can reach. A progress card that needs a place on the board +(Engineer, Medicine, Merchant, Diplomat, Intrigue, Inventor) closes the dialog and +**arms** the board; the next tap plays it. Chores that are answered with a place — a city +to lose, a metropolis site, a driven knight's refuge, a free knight — light the crossings +on the board and the prompt line says what to tap; the others open a dialog that cannot +be dismissed, because the game is waiting on it. + +**Testing.** `CatanCkInteractionTest` plays whole games and checks that for *every* legal +move at *every* position some control (a tap, a button or a dialog choice) produces it, so +a legal move with no way to make it — a stuck phone — fails the build. + ## Status and known gaps The screen is **playable and feature-complete for the engine's rules**, but is