diff --git a/CLAUDE.md b/CLAUDE.md index bbee649..095f66e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,14 +51,14 @@ integration: ## Katan -The island game is catalogue id `catan`, shown as **Katan**. It has two rule -sets chosen at setup: the base game (`docs/RULES-catan.md`), Cities & Knights +The island game is catalogue id `catan`, shown as **Katan**. It has two expansions +(toggles, `CatanOptions.expansions`; none on is the base game): the base game (`docs/RULES-catan.md`), Cities & Knights (`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind `CatanState.ck`, which is null in the base game) and Seafarers (`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind -`CatanState.sea`). The base and Seafarers rules read the board from `state.topo`, not from +`CatanState.sea`); the two can be switched on together. The base and Seafarers rules read the board from `state.topo`, not from `CatanBoard` directly, because a Seafarers map is not the 19-hex island -(Cities & Knights is always the standard island and still uses `CatanBoard`). The +(Cities & Knights also reads `state.topo`, so it runs on a Seafarers map too). The Catan Universe reverse engineering that shaped the landscape HUD and trade board is in `docs/RE-catan-universe.md`. The screen is layered; see `docs/UI-catan.md` before changing it. diff --git a/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonAi.kt b/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonAi.kt index 67b1754..a315241 100644 --- a/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/backgammon/BackgammonAi.kt @@ -1,6 +1,9 @@ package org.prolibertate.games.game.backgammon import kotlin.random.Random +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -22,8 +25,19 @@ import org.prolibertate.games.game.engine.GameAi * out and its first step returned; the rest follows on the moves after, because * the same calculation reaches the same turn. */ -class BackgammonAi(private val level: BackgammonLevel? = null) : - GameAi { +class BackgammonAi( + private val profiles: AiProfiles = AiProfiles.DEFAULT, + private val level: BackgammonLevel? = null, +) : GameAi { + + /** + * The turn an easy seat has settled on, so that a deliberate slip is made + * once per roll rather than re-rolled on every checker (which would stitch + * halves of different turns together). + */ + private var plan: List = emptyList() + private var planned = 0 + private var planRoll = -1 override fun chooseMove( state: BackgammonState, @@ -31,48 +45,104 @@ class BackgammonAi(private val level: BackgammonLevel? = null) : legal: List, ): BackgammonMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } - val setting = level ?: state.options.level if (legal.size == 1) return legal.first() + val profile = profiles.of(seat) + val base = level ?: state.options.level + val setting = when (profile.level) { + AiLevel.MEDIUM -> base + // Easy never looks at the dice to come. + AiLevel.EASY -> if (base == BackgammonLevel.STRONG) BackgammonLevel.CLUB else base + AiLevel.HARD -> BackgammonLevel.STRONG + } + val tuning = tuningFor(profile) val random = Random(state.rollsMade * 31L + state.moveLog.size) if (setting == BackgammonLevel.CASUAL) { // Not thoughtless: it still prefers to hit, to come in off the bar // and to bear off, which is most of what a beginner knows. - val scored = legal.map { move -> move to rough(state, seat, move) } + val scored = legal.map { move -> move to rough(state, seat, move, profile) } val best = scored.maxOf { it.second } val equal = scored.filter { it.second == best } return equal[random.nextInt(equal.size)].first } + val easy = profile.level == AiLevel.EASY + if (easy && planRoll == state.rollsMade) { + val next = plan.getOrNull(planned) + if (next != null && next in legal) { + planned++ + return next + } + } + val sequences = BackgammonRules.deepestSequences(state, seat) if (sequences.isEmpty()) return legal.first() - var best = sequences.first() - var bestScore = -Double.MAX_VALUE + val scored = ArrayList, Double>>(sequences.size) for (sequence in sequences) { val after = BackgammonRules.playSequence(state, seat, sequence) - val score = if (setting == BackgammonLevel.STRONG) { - // What the position is worth once the dice have had their say. - evaluate(after, seat) - averageReply(after, other(seat)) - } else { - evaluate(after, seat) - } - if (score > bestScore) { - bestScore = score - best = sequence - } + scored += sequence to evaluate(after, seat, tuning) } - return best.first() + + if (setting == BackgammonLevel.STRONG) { + // What the position is worth once the dice have had their say. A hard + // seat only spends the twenty-one-roll average on the turns that + // already look best, which keeps a phone's thinking time bounded. + val finalists = if (profile.level == AiLevel.HARD && scored.size > FINALISTS) { + scored.sortedByDescending { it.second }.take(FINALISTS) + } else { + scored + } + val rescored = finalists.map { (sequence, _) -> + val after = BackgammonRules.playSequence(state, seat, sequence) + sequence to (evaluate(after, seat, tuning) - averageReply(after, other(seat))) + } + return chooseFrom(rescored, profile, state, legal) + } + return chooseFrom(scored, profile, state, legal) } + /** Best sequence first move; an easy seat sometimes takes a worse (but better-half) turn. */ + private fun chooseFrom( + scored: List, Double>>, + profile: AiProfile, + state: BackgammonState, + legal: List, + ): BackgammonMove { + var best = scored.first() + for (entry in scored) if (entry.second > best.second) best = entry + var chosen = best.first + if (profile.level == AiLevel.EASY) { + val ranked = scored.sortedByDescending { it.second }.map { it.first } + chosen = profile.pick(ranked) + plan = chosen + planned = 1 + planRoll = state.rollsMade + } + val first = chosen.first() + return if (first in legal) first else legal.first() + } + + private fun tuningFor(profile: AiProfile): Tuning { + val style = profile.style + var blot = 1.0 - 0.3 * style.boldness + if (profile.level == AiLevel.EASY) blot *= 0.6 // overlooks some of the danger + return Tuning(blot, style.boldness, style.spite) + } + + /** What a seat's temperament does to the evaluation; [NEUTRAL] is the standard player. */ + private class Tuning(val blot: Double, val bold: Double, val spite: Double) + /** A quick opinion of one checker, for the level that does not think. */ - private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove): Int { + private fun rough(state: BackgammonState, seat: Int, move: BackgammonMove, profile: AiProfile): Int { val landing = BackgammonRules.destinationOf(seat, move.from, move.die) var score = 0 if (move.from == BAR) score += 50 if (landing == null) score += 40 - if (landing != null && state.countOn(landing, other(seat)) == 1) score += 60 + if (landing != null && state.countOn(landing, other(seat)) == 1) { + score += 60 + ((profile.style.boldness + profile.style.spite) * 20).toInt() + } if (landing != null && state.countOn(landing, seat) >= 1) score += 15 return score } @@ -98,8 +168,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) : val rolled = state.copy(turn = opponent, dice = dice, roll = listOf(high, low)) val replies = BackgammonRules.deepestSequences(rolled, opponent) val bestReply = replies.maxOfOrNull { sequence -> - evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent) - } ?: evaluate(rolled, opponent) + evaluate(BackgammonRules.playSequence(rolled, opponent, sequence), opponent, NEUTRAL) + } ?: evaluate(rolled, opponent, NEUTRAL) total += chance * bestReply weight += chance } @@ -117,7 +187,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) : * checkers are, and a made point in your own home board is worth having * because it is one more number your opponent cannot come in on. */ - private fun evaluate(state: BackgammonState, seat: Int): Double { + private fun evaluate(state: BackgammonState, seat: Int, tuning: Tuning): Double { val enemy = other(seat) if (state.phase == BackgammonPhase.GAME_OVER) { @@ -135,6 +205,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) : score -= state.off[enemy] * BORNE_OFF score -= state.bar[seat] * ON_BAR score += state.bar[enemy] * ON_BAR + // A gambler and a spoiler both like having an opponent on the bar. + score += state.bar[enemy] * (tuning.bold * 4.0 + tuning.spite * 8.0) for (point in 0 until POINTS) { val mine = state.countOn(point, seat) @@ -142,6 +214,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) : if (mine >= 2) { score += POINT_MADE + // A cautious seat likes covered points a little more, a gambler less. + score -= tuning.bold * 1.5 // A point in your own home board is a door shut in the face of // anyone you send to the bar. if (point in homeOf(seat)) score += HOME_POINT @@ -151,7 +225,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) : // A blot, weighted by how easily it can be reached: something // six away is in real danger, something twenty away is not. val distance = shortestEnemyDistance(state, seat, point) - score -= when { + score -= tuning.blot * when { distance <= 0 -> 0.0 distance <= 6 -> BLOT_NEAR distance <= 12 -> BLOT_FAR @@ -160,7 +234,7 @@ class BackgammonAi(private val level: BackgammonLevel? = null) : } } - score += primeLength(state, seat) * PRIME + score += primeLength(state, seat) * (PRIME + tuning.spite * 3.0) return score } @@ -199,6 +273,8 @@ class BackgammonAi(private val level: BackgammonLevel? = null) : private companion object { const val WIN = 10_000.0 + const val FINALISTS = 8 + val NEUTRAL = Tuning(1.0, 0.0, 0.0) const val PIP = 1.0 const val BORNE_OFF = 12.0 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 ddaf4ef..e96472d 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 @@ -1,5 +1,8 @@ package org.prolibertate.games.game.catan +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -17,39 +20,48 @@ import org.prolibertate.games.game.engine.GameAi * dev-card counts, knights played — plus its own hand, and never looks * into the deck, another seat's development cards, or the seed beyond the * roll the rules derive from it. + * + * Each seat's [AiProfiles] entry shapes how it plays. The default profile + * (medium, balanced) is the AI described above. Easy settles for worse + * spots, trades and builds, now and then ends its turn early, and takes + * slightly bad trades. Hard weighs setup spots by balance and port synergy, + * values cards by what its next build is missing, races for Longest Road, + * keeps the robber off its own hexes and off the table's non-leaders, and + * is choosier about whom it trades with. Style nudges the tradeoffs: bold + * seats expand, cautious seats spend down before a seven can bite, spiteful + * seats go after the leader. */ -class CatanAi : GameAi { +class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi { /** Plays the Cities & Knights tables; this class keeps the base game. */ - private val ck = CatanCkAi(this) + private val ck = CatanCkAi(this, profiles) override fun chooseMove(state: CatanState, seat: Int, legal: List): CatanMove { + val profile = profiles.of(seat) 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()) + chooseSetup(state, seat, legal.filterIsInstance(), profile) CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice } // Gold: take what the seat wants most. - CatanPhase.GOLD -> legal.filterIsInstance().maxByOrNull { pick -> - pick.cards.sumOf { want(it) } - } ?: legal.first() + CatanPhase.GOLD -> chooseGold(state, seat, legal, profile) - CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance()) + CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance(), profile) - CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance()) + CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance(), profile) - CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal) + CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal, profile) - CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal) + CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal, profile) - CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal) + CatanPhase.TRADE_COUNTERS -> chooseCounter(state, seat, legal, profile) - CatanPhase.PLAYING -> choosePlaying(state, seat, legal) + CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile) CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull() ?: error("Nothing to choose in phase ${state.phase}") @@ -60,12 +72,68 @@ class CatanAi : GameAi { // Setup // ----------------------------------------------------------------------- - internal fun chooseSetup(state: CatanState, seat: Int, places: List): SetupPlace { + internal fun chooseSetup( + state: CatanState, + seat: Int, + places: List, + profile: AiProfile = AiProfile.DEFAULT, + ): SetupPlace { require(places.isNotEmpty()) { "No legal settlement placement" } - return places.maxByOrNull { place -> - spotValue(state, place.vertex) + - 0.25 * placeDirection(state, place) - } ?: places.first() + // A bold seat cares more where its road leads; a cautious one less. + val direction = 0.25 + 0.1 * profile.style.boldness + return when (profile.level) { + // Easy looks only at the spot itself, and sometimes settles for the runner-up. + AiLevel.EASY -> profile.pick(places.sortedByDescending { spotValue(state, it.vertex) }) + + AiLevel.HARD -> places.maxByOrNull { place -> + hardSpot(state, seat, place.vertex) + direction * placeDirection(state, place) + } ?: places.first() + + AiLevel.MEDIUM -> places.maxByOrNull { place -> + spotValue(state, place.vertex) + direction * placeDirection(state, place) + } ?: places.first() + } + } + + /** + * A spot as a seat that plans would see it: the plain production value, + * plus resources the seat does not yet draw, less piling onto one it + * already has plenty of, and ports worth what the seat will produce to + * trade through them. + */ + internal fun hardSpot(state: CatanState, seat: Int, vertex: Int): Double { + if (state.buildings[vertex] != null) return -1.0 + val topo = state.topo + val owned = HashMap() + for (v in state.buildings.indices) { + val b = state.buildings[v] ?: continue + if (b.owner != seat) continue + val mult = if (b.type == BuildingType.CITY) 2.0 else 1.0 + for (h in topo.VERTEX_HEXES[v]) { + val res = state.terrains[h].produces ?: continue + owned[res] = (owned[res] ?: 0.0) + mult * CatanBoard.numberOdds(state.numbers[h]) / 36.0 + } + } + val here = HashMap() + for (h in topo.VERTEX_HEXES[vertex]) { + val res = state.terrains[h].produces ?: continue + here[res] = (here[res] ?: 0.0) + CatanBoard.numberOdds(state.numbers[h]) / 36.0 + } + var value = spotValue(state, vertex) + value += 0.15 * here.keys.count { (owned[it] ?: 0.0) <= 0.0 } + for ((res, p) in here) { + if ((owned[res] ?: 0.0) > 0.15) value -= 0.3 * p + } + val total = owned.values.sum() + here.values.sum() + for (e in topo.VERTEX_EDGES[vertex]) { + val port = state.ports.firstOrNull { it.edge == e } ?: continue + value += if (port.resource == null) { + 0.05 + 0.15 * total + } else { + 0.9 * ((owned[port.resource] ?: 0.0) + (here[port.resource] ?: 0.0)) + } + } + return value } /** @@ -87,48 +155,132 @@ class CatanAi : GameAi { // Dice, discards and the robber // ----------------------------------------------------------------------- - private fun chooseDiscard(state: CatanState, seat: Int, discards: List): Discard { + private fun chooseDiscard( + state: CatanState, + seat: Int, + discards: List, + profile: AiProfile, + ): Discard { require(discards.isNotEmpty()) { "No legal discard" } // Throw away what it wants least; grain and ore are the city, wool // is mostly a dev-card tax. - return discards.minByOrNull { discard -> - discard.cards.sumOf { want(it) } - } ?: discards.first() + return when (profile.level) { + AiLevel.EASY -> profile.pick(discards.sortedBy { d -> d.cards.sumOf { want(it) } }) + + // Keep the hand that is closest to paying for something. + AiLevel.HARD -> { + val hand = state.resources[seat] + discards.maxByOrNull { d -> + val left = hand.toMutableMap() + for (c in d.cards) left[c] = (left[c] ?: 0) - 1 + progress(left) - 0.01 * d.cards.sumOf { want(it) } + } ?: discards.first() + } + + AiLevel.MEDIUM -> discards.minByOrNull { discard -> + discard.cards.sumOf { want(it) } + } ?: discards.first() + } } - private fun chooseRobber(state: CatanState, seat: Int, moves: List): MoveRobber { + /** How close a hand is to the nearest of the things worth buying, a card count over a price, plus one if it pays. */ + private fun progress(hand: Map): Double = SHOPPING.maxOf { cost -> + val have = cost.entries.sumOf { (r, n) -> minOf(maxOf(hand[r] ?: 0, 0), n) } + have.toDouble() / cost.values.sum() + if (covers(hand, cost)) 1.0 else 0.0 + } + + internal fun chooseGold(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove { + val picks = legal.filterIsInstance() + if (picks.isEmpty()) return legal.first() + return when (profile.level) { + AiLevel.EASY -> profile.pick(picks.sortedByDescending { p -> p.cards.sumOf { want(it) } }) + AiLevel.HARD -> picks.maxByOrNull { p -> p.cards.sumOf { need(state, seat, it) } } ?: picks.first() + AiLevel.MEDIUM -> picks.maxByOrNull { pick -> pick.cards.sumOf { want(it) } } ?: legal.first() + } + } + + private fun chooseRobber( + state: CatanState, + seat: Int, + moves: List, + profile: AiProfile, + ): MoveRobber { require(moves.isNotEmpty()) { "No legal robber move" } val points = CatanRules.publicVictoryPoints(state) - return moves.maxByOrNull { move -> + val hard = profile.level == AiLevel.HARD + val spite = profile.style.spite + val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] } + val victimWeight = 0.3 + 0.1 * profile.style.boldness + fun score(move: MoveRobber): Double { val hex = state.topo.hexIndex.getValue(move.hex) var score = 0.0 for (v in state.topo.HEX_CORNERS[hex]) { val building = state.buildings[v] ?: continue - if (building.owner == seat) continue val worth = if (building.type == BuildingType.CITY) 2 else 1 - score += worth * CatanBoard.numberOdds(state.numbers[hex]) / 36.0 + val raw = worth * CatanBoard.numberOdds(state.numbers[hex]) + if (building.owner == seat) { + // A hard seat will not sit the robber on its own income. + if (hard) score -= 0.8 * raw / 36.0 + continue + } + score += raw / 36.0 + if (building.owner == leader) score += spite * raw / 36.0 } if (move.victim != null) { - score += 0.15 * state.resourceCount(move.victim) + - 0.3 * points[move.victim] + val cards = state.resourceCount(move.victim) + if (!hard || cards > 0) { + score += 0.15 * cards + victimWeight * points[move.victim] + if (move.victim == leader) score += 0.4 * spite + } } - score - } ?: moves.first() + return score + } + return if (profile.level == AiLevel.EASY) { + profile.pick(moves.sortedByDescending { score(it) }) + } else { + moves.maxByOrNull { score(it) } ?: moves.first() + } } // ----------------------------------------------------------------------- // The main phase // ----------------------------------------------------------------------- - private fun choosePlaying(state: CatanState, seat: Int, legal: List): CatanMove { - // A city doubles the best production the seat already owns. - legal.filterIsInstance().maxByOrNull { - spotValue(state, it.vertex) - }?.let { return it } + private fun choosePlaying(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove { + val easy = profile.level == AiLevel.EASY + val hard = profile.level == AiLevel.HARD + val boldness = profile.style.boldness + val endTurn = legal.lastOrNull { it is EndTurn } ?: legal.first() - legal.filterIsInstance().maxByOrNull { - spotValue(state, it.vertex) - }?.let { return it } + // Easy loses the thread now and then and just passes. + if (easy && profile.slips() && profile.random.nextBoolean()) return endTurn + + val cities = legal.filterIsInstance() + val settlements = legal.filterIsInstance() + if (hard && (cities.isNotEmpty() || settlements.isNotEmpty())) { + // A city doubles what the seat has; a settlement spreads it and + // takes a spot nobody else will. Bold seats lean to the spread. + val city = cities.maxByOrNull { spotValue(state, it.vertex) } + val settlement = settlements.maxByOrNull { hardSpot(state, seat, it.vertex) } + val cityScore = if (city != null) spotValue(state, city.vertex) + 0.15 else Double.NEGATIVE_INFINITY + val settleScore = if (settlement != null) { + hardSpot(state, seat, settlement.vertex) + 0.3 + 0.1 * boldness + } else { + Double.NEGATIVE_INFINITY + } + if (city != null && cityScore >= settleScore) return city + if (settlement != null) return settlement + } else { + // A city doubles the best production the seat already owns. + pickBest(cities, profile) { spotValue(state, it.vertex) }?.let { return it } + + pickBest(settlements, profile) { spotValue(state, it.vertex) }?.let { return it } + } + + // A raid on a fortress, or the next stage of a wonder, beats a card. + legal.filterIsInstance().firstOrNull()?.let { return it } + legal.filterIsInstance().firstOrNull()?.let { return it } + legal.filterIsInstance().firstOrNull()?.let { return it } // Seafarers: a ship toward an island nobody has landed on is worth more than a card. legal.filterIsInstance() @@ -138,11 +290,15 @@ class CatanAi : GameAi { // A knight is worth playing whenever one is playable: it blocks an // opponent's best hex and marches toward Largest Army. - legal.filterIsInstance().firstOrNull()?.let { return it } + if (!(easy && profile.slips())) { + legal.filterIsInstance().firstOrNull()?.let { return it } + } - yearOfPlenty(state, legal)?.let { return it } + if (!(easy && profile.slips())) { + yearOfPlenty(state, seat, legal, profile)?.let { return it } - monopoly(state, legal)?.let { return it } + monopoly(state, legal)?.let { return it } + } // Road Building is worth two free segments whenever there is // somewhere to put them. @@ -152,6 +308,14 @@ class CatanAi : GameAi { return legal.filterIsInstance().first() } + // Hard seat in a Longest Road race puts the road down before spending on cards. + val roadThreshold = 0.1 - 0.04 * boldness + if (hard && roadRace(state, seat)) { + legal.filterIsInstance() + .maxByOrNull { roadValue(state, it) } + ?.let { return it } + } + legal.filterIsInstance().firstOrNull()?.let { return it } // Seafarers: a ship that heads for a settlement spot worth having. @@ -162,7 +326,7 @@ class CatanAi : GameAi { // A paid road only when it reaches toward a spot worth settling. legal.filterIsInstance() - .filter { roadValue(state, it) > 0.1 } + .filter { roadValue(state, it) > roadThreshold } .maxByOrNull { roadValue(state, it) } ?.let { return it } @@ -173,16 +337,94 @@ class CatanAi : GameAi { .maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 } ?.let { return it } - offer(state, seat)?.let { return it } + if (hard) { + // Pile of one card, short of another: convert before it is stuck in hand. + legal.filterIsInstance() + .filter { surplusTrade(state, seat, it) } + .minByOrNull { CatanRules.bestPortRatio(state, seat, it.give) } + ?.let { return it } + } - return legal.lastOrNull { it is EndTurn } ?: legal.first() + // Stay under the seven: a cautious (or careful) seat sells down a big hand. + if ((hard || boldness < 0) && state.resourceCount(seat) > DISCARD_LIMIT) { + legal.filterIsInstance() + .maxByOrNull { t -> + (state.resources[seat][t.give] ?: 0) * 10.0 - + CatanRules.bestPortRatio(state, seat, t.give) * 3.0 + need(state, seat, t.get) + } + ?.let { return it } + } + + if (!(easy && profile.slips())) { + offer(state, seat, profile)?.let { return it } + } + + return endTurn + } + + /** Picks the best of [items] by [score]; easy sometimes takes a near miss. */ + private fun pickBest(items: List, profile: AiProfile, score: (T) -> Double): T? { + if (items.isEmpty()) return null + if (profile.level != AiLevel.EASY) return items.maxByOrNull(score) + return profile.pick(items.sortedByDescending(score)) + } + + /** The cheapest things worth saving for, as the shopping list a hard seat weighs hands against. */ + private val SHOPPING = listOf( + CatanRules.CITY_COST, + CatanRules.SETTLEMENT_COST, + CatanRules.DEV_COST, + CatanRules.ROAD_COST, + ) + + /** The shopping list entry the hand is closest to paying for, among a city, a settlement and a card. */ + private fun nearestTarget(hand: Map): Map = + listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST, CatanRules.DEV_COST) + .minByOrNull { cost -> cost.entries.sumOf { (r, n) -> maxOf(0, n - (hand[r] ?: 0)) } } + ?: CatanRules.CITY_COST + + /** What one card of [res] is worth to this seat right now: more if the next build lacks it, less if it is piled up. */ + private fun need(state: CatanState, seat: Int, res: Resource): Double { + val hand = state.resources[seat] + var v = want(res) + if ((hand[res] ?: 0) < (nearestTarget(hand)[res] ?: 0)) v += 0.6 + if ((hand[res] ?: 0) >= 4) v -= 0.4 + return v + } + + /** Whether this bank trade turns a pile (beyond any price) into a card the next build lacks. */ + private fun surplusTrade(state: CatanState, seat: Int, move: MaritimeTrade): Boolean { + val hand = state.resources[seat] + val ratio = CatanRules.bestPortRatio(state, seat, move.give) + val keep = SHOPPING.maxOf { it[move.give] ?: 0 } + val target = nearestTarget(hand) + return (hand[move.give] ?: 0) - keep >= ratio && + (hand[move.get] ?: 0) < (target[move.get] ?: 0) + } + + /** Whether [seat] is in the hunt for Longest Road: close enough to the holder, or the road is unclaimed. */ + private fun roadRace(state: CatanState, seat: Int): Boolean { + val holder = state.longestRoadOwner + if (holder == seat) return false + val mine = CatanRules.longestRoadFor(state, seat) + if (mine < 3) return false + return holder == null || mine + 1 >= CatanRules.longestRoadFor(state, holder) + } + + /** The opponent with the most public points, if any. */ + private fun leaderOf(state: CatanState, seat: Int): Int? { + val points = CatanRules.publicVictoryPoints(state) + return (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] } } /** Year of Plenty: two of whatever the nearest build is shortest of. */ - private fun yearOfPlenty(state: CatanState, legal: List): PlayYearOfPlenty? { + private fun yearOfPlenty(state: CatanState, seat: Int, legal: List, profile: AiProfile): PlayYearOfPlenty? { val takes = legal.filterIsInstance() if (takes.isEmpty()) return null val shortage = shortage(state) ?: return null + if (profile.level == AiLevel.HARD) { + return takes.maxByOrNull { take -> take.take.sumOf { need(state, seat, it) } } + } return takes.maxByOrNull { take -> take.take.count { it == shortage } * 2 + take.take.size } @@ -234,7 +476,7 @@ class CatanAi : GameAi { * At most one offer a turn: a declined offer changes nothing, so * re-offering it would only bounce between the same two seats forever. */ - private fun offer(state: CatanState, seat: Int): OfferTrade? { + private fun offer(state: CatanState, seat: Int, profile: AiProfile): OfferTrade? { if (offeredThisTurn(state, seat)) return null val seatHand = state.resources[seat] val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST) @@ -247,11 +489,16 @@ class CatanAi : GameAi { .filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) } .sortedByDescending { seatHand[it] ?: 0 } if (surplus.isEmpty()) continue - val target = (0 until state.playerCount) + val holders = (0 until state.playerCount) .filter { it != seat } .filter { (state.resources[it][need] ?: 0) > 0 } - .maxByOrNull { state.resources[it][need] ?: 0 } - ?: continue + val target = (if (profile.level == AiLevel.HARD) { + // Do not feed the leader when someone else can make the swap. + val points = CatanRules.publicVictoryPoints(state) + holders.maxByOrNull { (state.resources[it][need] ?: 0) - 0.5 * points[it] } + } else { + holders.maxByOrNull { state.resources[it][need] ?: 0 } + }) ?: continue val give = listOf(surplus.first(), surplus.first()) val trade = OfferTrade(to = target, give = give, get = listOf(need)) if (CatanRules.isLegalMove(state, seat, trade)) return trade @@ -274,38 +521,73 @@ class CatanAi : GameAi { // Free roads and trade responses // ----------------------------------------------------------------------- - private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List): CatanMove { - val roads = legal.filterIsInstance() - .filter { roadValue(state, it) > 0.05 } - .maxByOrNull { roadValue(state, it) } - return roads ?: legal.firstOrNull { it is EndFreeRoads } - ?: legal.filterIsInstance().firstOrNull() - ?: legal.first() + private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove { + val roads = legal.filterIsInstance().filter { roadValue(state, it) > 0.05 } + val road = if (profile.level == AiLevel.EASY) { + profile.pick(roads.sortedByDescending { roadValue(state, it) }.ifEmpty { return freeRoadFallback(legal) }) + } else { + roads.maxByOrNull { roadValue(state, it) } + } + return road ?: freeRoadFallback(legal) } - private fun chooseResponse(state: CatanState, seat: Int, legal: List): CatanMove { + private fun freeRoadFallback(legal: List): CatanMove = + legal.firstOrNull { it is EndFreeRoads } + ?: legal.filterIsInstance().firstOrNull() + ?: legal.first() + + /** + * How much extra the incoming cards must be worth to take a swap: nothing + * for the standard seat, a hard or spiteful seat wants more from the + * table's leader, and an easy seat is not too fussy. + */ + private fun tradeMargin(state: CatanState, seat: Int, other: Int, profile: AiProfile): Double { + var margin = 0.0 + if (profile.level == AiLevel.EASY) margin -= 0.6 + val leader = leaderOf(state, seat) + if (leader != null && other == leader) { + if (profile.level == AiLevel.HARD) margin += 0.4 + margin += 0.5 * profile.style.spite + } + return margin + } + + private fun tradeValue(state: CatanState, seat: Int, res: Resource, profile: AiProfile): Double = + if (profile.level == AiLevel.HARD) need(state, seat, res) else want(res) + + private fun chooseResponse(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove { val accept = legal.filterIsInstance().firstOrNull() val offer = state.pendingTrade if (accept == null || offer == null) return legal.first { it is DeclineTrade } // Accept when what arrives (the offerer's give) is worth more than what // leaves (the offerer's get): a mild table, not a shark. - val incoming = offer.give.sumOf { want(it) } - val outgoing = offer.get.sumOf { want(it) } - return if (incoming > outgoing) accept else legal.first { it is DeclineTrade } + val incoming = offer.give.sumOf { tradeValue(state, seat, it, profile) } + val outgoing = offer.get.sumOf { tradeValue(state, seat, it, profile) } + return if (incoming > outgoing + tradeMargin(state, seat, offer.by, profile)) { + accept + } else { + legal.first { it is DeclineTrade } + } } /** * Picks the counter-offer that gains the most by the same valuation, or * turns them all down. The AI never counters itself: it only answers. */ - private fun chooseCounter(state: CatanState, legal: List): CatanMove { + private fun chooseCounter(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove { val offer = state.pendingTrade ?: return legal.first() + fun gain(c: TradeCounter) = c.give.sumOf { tradeValue(state, seat, it, profile) } - + c.get.sumOf { tradeValue(state, seat, it, profile) } val best = legal.filterIsInstance() .map { choice -> choice to offer.counters.first { it.by == choice.seat } } - .maxByOrNull { (_, c) -> c.give.sumOf { want(it) } - c.get.sumOf { want(it) } } + .maxByOrNull { (_, c) -> gain(c) } if (best != null) { val (choice, c) = best - if (c.give.sumOf { want(it) } > c.get.sumOf { want(it) }) return choice + if (c.give.sumOf { tradeValue(state, seat, it, profile) } > + c.get.sumOf { tradeValue(state, seat, it, profile) } + tradeMargin(state, seat, c.by, profile) + ) { + return choice + } } return legal.first { it is DismissCounters } } @@ -315,6 +597,26 @@ class CatanAi : GameAi { // ----------------------------------------------------------------------- /** What a seat would think of building on [vertex]: production and ports. */ + /** What a spot beside a cloth village or an unclaimed tribe chip is worth beyond its cards. */ + private fun scenarioLure(state: CatanState, vertex: Int): Double { + // The Harbormaster: a town on a harbour is a point toward a title worth two. + if (CatanRules.harbormasterGame(state) && + state.topo.VERTEX_EDGES[vertex].any { e -> state.ports.any { it.edge == e } } + ) return HARBOR_LURE + val sea = state.sea ?: return 0.0 + var lure = 0.0 + for (h in state.topo.VERTEX_HEXES[vertex]) { + // A cloth token is a quarter-point, rolled as often as its number comes up. + if (state.terrains[h] == Terrain.VILLAGE && sea.villageCloth.getOrElse(h) { 0 } > 0) { + lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH + } + // An unfinished canal tile is a place to dig: points now, grain land later. + if (sea.canalNumbers.getOrElse(h) { 0 } != 0 && state.terrains[h] == Terrain.DESERT) lure += CANAL_LURE + if (sea.chips.getOrNull(h).let { it != null && it != TribeChip.NONE }) lure += CHIP_LURE + } + return lure + } + internal fun spotValue(state: CatanState, vertex: Int): Double { if (state.buildings[vertex] != null) return -1.0 var value = 0.0 @@ -325,6 +627,7 @@ class CatanAi : GameAi { kinds += resource } value += 0.12 * kinds.size // variety keeps a build plan alive + value += scenarioLure(state, vertex) for (e in state.topo.VERTEX_EDGES[vertex]) { val port = state.ports.firstOrNull { it.edge == e } ?: continue value += if (port.resource == null) 0.1 else 0.2 @@ -367,9 +670,16 @@ class CatanAi : GameAi { val next = mutableListOf() for (v in frontier) { if (!seen.add(v)) continue + // Fog is worth a look: a card on arrival, and land to settle that nobody has seen. + if (topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) best = maxOf(best, FOG_LURE * Math.pow(0.85, depth.toDouble())) + // A fortress is worth a raid once a ship lies beside it. + if (sea.fortress.isNotEmpty() && topo.VERTEX_EDGES[v].any { e -> + topo.edgeSea[e] && topo.EDGE_HEXES[e].any { sea.fortress.getOrElse(it) { 0 } > 0 } + } + ) best = maxOf(best, FORT_LURE * Math.pow(0.85, depth.toDouble())) if (CatanRules.vertexOpenForSettlement(state, v)) { val island = topo.vertexIsland[v] - val fresh = island >= 0 && island != sea.home && sea.islandBonus[island] == NO_PLAYER + val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble()) best = maxOf(best, value) } @@ -392,4 +702,17 @@ class CatanAi : GameAi { Resource.BRICK -> 0.95 Resource.WOOL -> 0.85 // abundant and only half of a dev card } + + private companion object { + /** A seven makes a seat holding more than this discard half. */ + const val DISCARD_LIMIT = 7 + + /** What a ship end beside undiscovered land is worth, on the scale of [spotValue]. */ + const val FOG_LURE = 0.6 + const val FORT_LURE = 1.2 + const val CLOTH_WORTH = 2.5 + const val CHIP_LURE = 0.5 + const val CANAL_LURE = 0.6 + const val HARBOR_LURE = 0.5 + } } diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanBoard.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanBoard.kt index 1e1187a..9920f08 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanBoard.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanBoard.kt @@ -429,6 +429,9 @@ object CatanBoard { * tokens go to four of the six board-corner hexes, which are pairwise * non-adjacent, and at most one corner can be desert. */ + /** The seed of the First Island scenario's fixed layout. */ + const val FIRST_ISLAND_SEED = 1L + fun generate(seed: Long): Generated { val random = Random(seed) val terrains = TERRAIN_TILES.shuffled(random) 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 index d156d39..4d61088 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt @@ -171,18 +171,22 @@ object CatanCk { /** 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 -> + (0 until state.topo.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. */ + /** Whether [seat] has a road, or a ship in Seafarers, on [edge]: the network a knight moves along. */ + private fun holds(state: CatanState, seat: Int, edge: Int): Boolean = + state.roads[edge] == seat || state.sea?.ships?.get(edge) == seat + + /** Empty crossings at the end of one of [seat]'s roads or ships on land: 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 } + (0 until state.topo.VERTEX_COUNT).filter { v -> + state.topo.vertexLand[v] && state.buildings[v] == null && knightAt(state, v) == null && + state.topo.VERTEX_EDGES[v].any { holds(state, seat, it) } } /** @@ -196,9 +200,9 @@ object CatanCk { 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) + for (e in state.topo.VERTEX_EDGES[v]) { + if (!holds(state, seat, e)) continue + val w = state.topo.otherEnd(e, v) if (!seen.add(w)) continue val building = state.buildings[w] val knight = knightAt(state, w) @@ -215,14 +219,14 @@ object CatanCk { 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 + if (state.buildings[w] != null || !state.topo.vertexLand[w]) 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 + state.buildings[w] == null && state.topo.vertexLand[w] && knightAt(state, w) == null }.sorted() private fun promotable(state: CatanState, k: Knight): Boolean { @@ -251,6 +255,8 @@ object CatanCk { points[seat] += ck.defenderVp[seat] + ck.progressVp[seat] } ck.merchant?.let { points[it.seat] += 1 } + state.harbormaster?.let { points[it] += CatanRules.HARBORMASTER_POINTS } + if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat) return points.toList() } @@ -266,15 +272,18 @@ object CatanCk { addAll(actionMoves(state, seat)) } - CatanPhase.ROBBER -> robberMoves(state, seat) + // The bishop moves the robber alone; a seven may send the pirate instead. + CatanPhase.ROBBER -> robberMoves(state, seat) + + (if (state.isSea && !state.ck!!.bishop) SeaRules.pirateMoves(state, seat) else emptyList()) CatanPhase.PLAYING -> playingMoves(state, seat) CatanPhase.FREE_ROADS -> - CatanRules.roadMoves(state, seat) + listOf(EndFreeRoads) + CatanRules.roadMoves(state, seat) + + (if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) + + listOf(EndFreeRoads) - // Seafarers is never combined with Cities & Knights. - CatanPhase.GOLD -> emptyList() + CatanPhase.GOLD -> SeaRules.goldMoves(state) CatanPhase.TRADE_PENDING -> { val offer = state.ck!!.goodsOffer @@ -327,7 +336,7 @@ object CatanCk { 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]) { + if (ck.attacked && k.vertex in state.topo.HEX_CORNERS[state.robber]) { moves += ChaseRobber(k.vertex) } } @@ -340,6 +349,11 @@ object CatanCk { val moves = mutableListOf() if (affordable(state, seat, CatanRules.ROAD_COST)) moves += CatanRules.roadMoves(state, seat) + if (state.isSea) { + if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat) + moves += SeaRules.moveShipMoves(state, seat) + moves += SeaRules.scenarioMoves(state, seat) + } if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) { moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) } } @@ -380,7 +394,7 @@ object CatanCk { /** 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 -> + return (0 until state.topo.VERTEX_COUNT).filter { v -> state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && v !in state.ck!!.walls } } @@ -423,20 +437,37 @@ object CatanCk { 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 + for (h in 0 until state.topo.HEX_COUNT) { + if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG) 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) } + bishop -> moves += MoveRobber(state.topo.HEXES[h]) + victims.isEmpty() -> moves += MoveRobber(state.topo.HEXES[h]) + else -> victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) } } } return moves } + /** The pirate, sent by a seven once the barbarians have landed: it robs a seat with a ship beside it. */ + fun applyPirate(state: CatanState, seat: Int, move: MovePirate): CatanState { + val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}") + var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1) + var stole = "" + val hand = move.victim?.let { goodsList(s, it) }.orEmpty() + if (move.victim != null && hand.isNotEmpty()) { + val card = hand[Random(state.seed * 1_000_003L + state.rngStep).nextInt(hand.size)] + s = give(s, move.victim, seat, listOf(card)) + stole = " and takes a ${good(card)} from seat ${move.victim}" + } + return s.copy( + phase = if (state.ck!!.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING, + log = s.log + "Seat $seat sends the pirate to (${move.hex.q},${move.hex.r})$stole.", + ) + } + private fun victimsAt(state: CatanState, seat: Int, hex: Int): List = - CatanBoard.HEX_CORNERS[hex] + state.topo.HEX_CORNERS[hex] .mapNotNull { state.buildings[it] } .map { it.owner } .filter { it != seat } @@ -445,7 +476,7 @@ object CatanCk { fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState { val ck = state.ck!! - val hex = CatanBoard.hexIndex[move.hex] + val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}") var s = state.copy(robber = hex, rngStep = state.rngStep + 1) val rng = Random(state.seed * 1_000_003L + state.rngStep) @@ -495,7 +526,7 @@ object CatanCk { 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 } + val hexes = (0 until state.topo.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) } @@ -530,7 +561,7 @@ object CatanCk { ProgressType.INTRIGUE -> ck.knights.filter { k -> - k.owner != seat && CatanBoard.VERTEX_EDGES[k.vertex].any { state.roads[it] == seat } + k.owner != seat && state.topo.VERTEX_EDGES[k.vertex].any { holds(state, seat, it) } }.map { PlayProgress(id, vertex = it.vertex) } ProgressType.SABOTEUR -> listOf(PlayProgress(id)) @@ -559,9 +590,9 @@ object CatanCk { } ProgressType.MERCHANT -> - (0 until CatanBoard.HEX_COUNT).filter { h -> - state.terrains[h] != Terrain.DESERT && - CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } + (0 until state.topo.HEX_COUNT).filter { h -> + state.terrains[h] != Terrain.DESERT && state.topo.land[h] && state.terrains[h] != Terrain.FOG && + state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } }.map { PlayProgress(id, hexA = it) } ProgressType.MERCHANT_FLEET -> Good.entries.map { PlayProgress(id, good = it) } @@ -596,19 +627,19 @@ object CatanCk { } private fun hexesNextTo(state: CatanState, seat: Int, terrain: Terrain): List = - (0 until CatanBoard.HEX_COUNT).filter { h -> + (0 until state.topo.HEX_COUNT).filter { h -> state.terrains[h] == terrain && - CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } + state.topo.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 -> + (0 until state.topo.EDGE_COUNT).filter { e -> if (state.roads[e] == NO_PLAYER) return@filter false - val (a, b) = CatanBoard.EDGE_VERTICES[e] + val (a, b) = state.topo.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 } + state.topo.VERTEX_EDGES[v].none { it != e && state.roads[it] != NO_PLAYER } } } @@ -1500,12 +1531,12 @@ object CatanCk { private fun production(state: CatanState, number: Int): CatanState { val gains = List(state.playerCount) { mutableMapOf() } - for (h in 0 until CatanBoard.HEX_COUNT) { + for (h in 0 until state.topo.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]) { + for (v in state.topo.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 } @@ -1539,6 +1570,7 @@ object CatanCk { } val said = if (notes.isEmpty()) "nothing for anyone" else notes.joinToString(", ") s = s.copy(log = s.log + "The dice pay: $said.") + if (state.isSea) s = SeaRules.payCloth(s, number) // 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 } @@ -1546,7 +1578,11 @@ object CatanCk { 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) + val next = if (queued.ck!!.chores.isNotEmpty()) queued else queued.copy(phase = CatanPhase.PLAYING) + // Gold is chosen before anything else; the chores (and then play) wait behind it. + val gold = if (state.isSea) CatanRules.goldOwed(state, number) else emptyList() + if (gold.isEmpty()) return next + return SeaRules.settleGold(next.copy(sea = next.sea!!.copy(gold = gold, afterGold = next.phase))) } // ----------------------------------------------------------------------- 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 index 9958915..2fea080 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt @@ -1,5 +1,9 @@ package org.prolibertate.games.game.catan +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles + /** * The computer opponent for Cities & Knights, reached through [CatanAi]. * @@ -14,28 +18,44 @@ package org.prolibertate.games.game.catan * 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. + * + * The seat's profile shapes it as in [CatanAi]: easy plays on a whim and + * discards badly, hard sizes its knights to the barbarian threat, values cards + * by what its improvements still need and keeps its robber off its own hexes, + * and the style leans it toward knights and expansion, toward spending down + * a big hand, or toward the leader. */ -internal class CatanCkAi(private val ai: CatanAi) { +internal class CatanCkAi( + private val ai: CatanAi, + private val profiles: AiProfiles = AiProfiles.DEFAULT, +) { - fun choose(state: CatanState, seat: Int, legal: List): CatanMove = when (state.phase) { + fun choose(state: CatanState, seat: Int, legal: List): CatanMove { + val profile = profiles.of(seat) + return choose(state, seat, legal, profile) + } + + private fun choose(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove = when (state.phase) { CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> - ai.chooseSetup(state, seat, legal.filterIsInstance()) + ai.chooseSetup(state, seat, legal.filterIsInstance(), profile) CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice } - CatanPhase.CHORE -> chooseChore(state, seat, legal) + CatanPhase.CHORE -> chooseChore(state, seat, legal, profile) - CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance()) + CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance(), profile) - CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal) + CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal, profile) - CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal) + CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal, profile) CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal) - CatanPhase.PLAYING -> choosePlaying(state, seat, legal) + CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile) - CatanPhase.DISCARDING, CatanPhase.GOLD, CatanPhase.GAME_OVER -> legal.firstOrNull() + CatanPhase.GOLD -> ai.chooseGold(state, seat, legal, profile) + + CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull() ?: error("Nothing to choose in phase ${state.phase}") } @@ -43,18 +63,25 @@ internal class CatanCkAi(private val ai: CatanAi) { // Chores // ----------------------------------------------------------------------- - private fun chooseChore(state: CatanState, seat: Int, legal: List): CatanMove { + private fun chooseChore(state: CatanState, seat: Int, legal: List, profile: AiProfile): 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)) + val hand = CatanCk.goodsList(state, seat) + // Easy now and then throws away whatever comes to hand. + val order = if (profile.slips()) { + hand.shuffled(profile.random) + } else { + hand.sortedBy { worth(state, seat, it, profile) } + } + DiscardGoods(order.take(n)) } is GiveChore -> { val pool = CatanCk.goodsList(state, seat) .filter { !chore.commoditiesOnly || it.isCommodity } - .sortedBy { worth(state, seat, it) } + .sortedBy { worth(state, seat, it, profile) } GiveGoods(pool.take(minOf(chore.count, pool.size))) } @@ -98,37 +125,62 @@ internal class CatanCkAi(private val ai: CatanAi) { // The robber // ----------------------------------------------------------------------- - private fun chooseRobber(state: CatanState, seat: Int, moves: List): MoveRobber { + private fun chooseRobber(state: CatanState, seat: Int, moves: List, profile: AiProfile): MoveRobber { require(moves.isNotEmpty()) { "No legal robber move" } val points = CatanRules.publicVictoryPoints(state) - return moves.maxByOrNull { move -> - val hex = CatanBoard.hexIndex.getValue(move.hex) + val hard = profile.level == AiLevel.HARD + val spite = profile.style.spite + val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] } + val victimWeight = 0.3 + 0.1 * profile.style.boldness + fun score(move: MoveRobber): Double { + val hex = state.topo.hexIndex.getValue(move.hex) var score = 0.0 - for (v in CatanBoard.HEX_CORNERS[hex]) { + for (v in state.topo.HEX_CORNERS[hex]) { val building = state.buildings[v] ?: continue 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 + score += if (building.owner == seat) { + -worth * odds * (if (hard) 1.5 else 1.0) + } else { + worth * odds + if (building.owner == leader) spite * worth * odds else 0.0 + } } if (move.victim != null) { - score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim] + val cards = CatanCk.goodsCount(state, move.victim) + if (!hard || cards > 0) { + score += 0.15 * cards + victimWeight * points[move.victim] + if (move.victim == leader) score += 0.4 * spite + } } - score - } ?: moves.first() + return score + } + return if (profile.level == AiLevel.EASY) { + profile.pick(moves.sortedByDescending { score(it) }) + } else { + moves.maxByOrNull { score(it) } ?: moves.first() + } } // ----------------------------------------------------------------------- // The main phase // ----------------------------------------------------------------------- - private fun choosePlaying(state: CatanState, seat: Int, legal: List): CatanMove { + private fun choosePlaying(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove { val ck = state.ck!! - val soon = ck.barbarians <= 3 + val easy = profile.level == AiLevel.EASY + val hard = profile.level == AiLevel.HARD + val boldness = profile.style.boldness + // A cautious seat starts getting ready a step earlier. + val soon = ck.barbarians <= (if (boldness < 0) 4 else 3) + val endTurn = legal.lastOrNull { it is EndTurn } + + // Easy loses the thread now and then and just passes. + if (easy && endTurn != null && profile.slips() && profile.random.nextBoolean()) return endTurn // 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 } + val robberHurtsMe = state.topo.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat } if (onMine != null && robberHurtsMe) return onMine } @@ -138,10 +190,38 @@ internal class CatanCkAi(private val ai: CatanAi) { .maxByOrNull { production(state, it.vertex!!) } ?.let { return it } - legal.filterIsInstance().maxByOrNull { production(state, it.vertex) } - ?.let { return it } + val cities = legal.filterIsInstance() + val settlements = legal.filterIsInstance() + if (hard) { + val city = cities.maxByOrNull { production(state, it.vertex) } + val settlement = settlements.maxByOrNull { ai.hardSpot(state, seat, it.vertex) } + val cityScore = if (city != null) production(state, city.vertex) + 0.15 else Double.NEGATIVE_INFINITY + val settleScore = if (settlement != null) { + ai.hardSpot(state, seat, settlement.vertex) + 0.3 + 0.1 * boldness + } else { + Double.NEGATIVE_INFINITY + } + if (city != null && cityScore >= settleScore) return city + if (settlement != null) return settlement + } else if (easy) { + if (cities.isNotEmpty()) return profile.pick(cities.sortedByDescending { production(state, it.vertex) }) + if (settlements.isNotEmpty()) { + return profile.pick(settlements.sortedByDescending { ai.spotValue(state, it.vertex) }) + } + } else { + cities.maxByOrNull { production(state, it.vertex) }?.let { return it } - legal.filterIsInstance().maxByOrNull { ai.spotValue(state, it.vertex) } + settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it } + } + + legal.filterIsInstance().firstOrNull()?.let { return it } + legal.filterIsInstance().firstOrNull()?.let { return it } + legal.filterIsInstance().firstOrNull()?.let { return it } + + // Seafarers: a ship toward land nobody has settled is worth more than a card. + legal.filterIsInstance() + .filter { ai.shipValue(state, seat, it.edge) > 0.5 } + .maxByOrNull { ai.shipValue(state, seat, it.edge) } ?.let { return it } // Improvements spend cards nothing else needs. Favour the track furthest along. @@ -150,16 +230,21 @@ internal class CatanCkAi(private val ai: CatanAi) { ?.let { return it } // Knights: stand ready by the time the barbarians land. - defence(state, seat, legal, soon)?.let { return it } + if (!(easy && profile.slips())) defence(state, seat, legal, soon, profile)?.let { return it } - progress(state, seat, legal)?.let { return it } + if (!(easy && profile.slips())) progress(state, seat, legal)?.let { return it } legal.filterIsInstance().firstOrNull()?.let { - if (CatanCk.goodsCount(state, seat) >= 7) return it + if (CatanCk.goodsCount(state, seat) >= (if (boldness < 0) 5 else 7)) return it } + legal.filterIsInstance() + .filter { ai.shipValue(state, seat, it.edge) > 0.15 } + .maxByOrNull { ai.shipValue(state, seat, it.edge) } + ?.let { return it } + legal.filterIsInstance() - .filter { ai.roadValue(state, it) > 0.1 } + .filter { ai.roadValue(state, it) > 0.1 - 0.04 * boldness } .maxByOrNull { ai.roadValue(state, it) } ?.let { return it } @@ -168,18 +253,43 @@ internal class CatanCkAi(private val ai: CatanAi) { .minByOrNull { CatanCk.bankRatio(state, seat, it.give) } ?.let { return it } - return legal.lastOrNull { it is EndTurn } ?: legal.first() + // Stay under the seven: a cautious (or careful) seat sells down a big hand. + if ((hard || boldness < 0) && CatanCk.goodsCount(state, seat) > 7) { + legal.filterIsInstance() + .maxByOrNull { t -> + held(state, seat, t.give) * 10.0 - CatanCk.bankRatio(state, seat, t.give) * 3.0 + + worth(state, seat, t.get, profile) + } + ?.let { return it } + } + + return 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? { + private fun defence( + state: CatanState, + seat: Int, + legal: List, + soon: Boolean, + profile: AiProfile, + ): CatanMove? { val mine = CatanCk.knightsOf(state, seat) val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0 + // Hard sizes the defence to the threat: the barbarians bring one strength + // per city on the board, and once the table's active knights outweigh them + // and this seat pulls its weight, the rest of its goods go elsewhere. + if (profile.level == AiLevel.HARD && soon && CatanCk.strength(state, seat) >= 1) { + val threat = state.buildings.count { it?.type == BuildingType.CITY } + val table = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) } + if (table > threat) return null + } + if (soon) { legal.filterIsInstance().firstOrNull()?.let { return it } } - val want = if (soon) 4 else 2 + val want = if (soon) 4 else if (profile.style.boldness > 0.5) 3 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. @@ -267,7 +377,7 @@ internal class CatanCkAi(private val ai: CatanAi) { /** 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 -> + state.topo.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 } @@ -289,26 +399,44 @@ internal class CatanCkAi(private val ai: CatanAi) { } } - private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List): CatanMove = - legal.filterIsInstance() - .filter { ai.roadValue(state, it) > 0.05 } - .maxByOrNull { ai.roadValue(state, it) } + private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List, profile: AiProfile): CatanMove { + val roads = legal.filterIsInstance().filter { ai.roadValue(state, it) > 0.05 } + val road = when { + roads.isEmpty() -> null + profile.level == AiLevel.EASY -> profile.pick(roads.sortedByDescending { ai.roadValue(state, it) }) + else -> roads.maxByOrNull { ai.roadValue(state, it) } + } + return road ?: legal.firstOrNull { it is EndFreeRoads } ?: legal.first() + } - private fun chooseResponse(state: CatanState, seat: Int, legal: List): CatanMove { + /** Extra the incoming cards must be worth: easy is not fussy, hard and spiteful seats are tougher on the leader. */ + private fun tradeMargin(state: CatanState, seat: Int, other: Int, profile: AiProfile): Double { + var margin = 0.0 + if (profile.level == AiLevel.EASY) margin -= 0.6 + val points = CatanRules.publicVictoryPoints(state) + val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] } + if (leader != null && other == leader) { + if (profile.level == AiLevel.HARD) margin += 0.4 + margin += 0.5 * profile.style.spite + } + return margin + } + + private fun chooseResponse(state: CatanState, seat: Int, legal: List, profile: AiProfile): 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 incoming = offer.give.sumOf { worth(state, seat, it, profile) } + val outgoing = offer.get.sumOf { worth(state, seat, it, profile) } + return if (incoming > outgoing + tradeMargin(state, seat, offer.by, profile)) 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 + return if (incoming > outgoing + tradeMargin(state, seat, plain.by, profile)) accept else decline } /** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */ @@ -320,12 +448,22 @@ internal class CatanCkAi(private val ai: CatanAi) { // ----------------------------------------------------------------------- /** 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) { + private fun worth(state: CatanState, seat: Int, good: Good, profile: AiProfile = AiProfile.DEFAULT): Double { + val base = good.resource?.let { ai.want(it) } ?: when (good) { Good.PAPER -> 0.95 Good.COIN -> 0.9 else -> 0.85 } + if (profile.level != AiLevel.HARD) return base + // Hard keeps the commodity an improvement is about to need. + var bonus = if (good.resource == Resource.ORE || good.resource == Resource.GRAIN) 0.1 else 0.0 + for (track in Track.entries) { + if (Good.of(track.commodity) != good) continue + val level = CatanCk.improvement(state, seat, track) + if (level < IMPROVEMENT_MAX && held(state, seat, good) <= level + 1) bonus += 0.5 + } + return base + bonus + } /** Which track to lean toward when two are level: politics for the knights, then science. */ private fun trackLean(track: Track): Int = when (track) { 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 a38d599..3e079bf 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 @@ -1,7 +1,16 @@ package org.prolibertate.games.game.catan +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject /** Sentinel stored in [CatanState.roads] for an unclaimed road position. */ const val NO_PLAYER = -1 @@ -45,6 +54,36 @@ enum class Terrain(val produces: Resource?) { /** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */ GOLD(null), + + /** + * Seafarers: land not yet found. It becomes a resource or gold when a road or ship first reaches it + * (`SeaState.fogDeck`). Until then nothing is built on it, nothing produces, and the robber keeps off. + */ + FOG(null), + + /** Seafarers, Cloth for Catan: a village. It pays cloth, not cards, to the settlements around it when its number rolls. */ + VILLAGE(null), + + /** + * Seafarers, Pirate Islands: a pirate fortress. It stands until ships raid it (`SeaState.fortress`), and + * nobody settles its island meanwhile. Once taken it becomes desert. + */ + FORTRESS(null), +} + +/** Base-island scenarios: the board and goals of a game without Seafarers. Seafarers games choose a [SeaScenario] instead. */ +@Serializable +enum class BaseScenario(val title: String, val blurb: String) { + RANDOM_MAP("Random map", "The usual island, its tiles and numbers shuffled for every game."), + FIRST_ISLAND( + "First Island", + "The same fixed island every game, so the set-up is not a surprise: good for a first game or a rematch.", + ), + HARBORMASTER( + "The Harbormaster", + "Harbours matter. A settlement on a harbour counts a point and a city two; the first to three has the " + + "Harbormaster title, worth 2 victory points, and whoever gets more takes it.", + ), } /** Axial coordinate of one hex on the board. See [CatanBoard]. */ @@ -151,12 +190,9 @@ enum class CatanPhase { CHORE, } -/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */ +/** An expansion a table can switch on. The base game is what is left with none selected. */ @Serializable -enum class CatanRuleSet { - /** The base game: development cards, Largest Army, a robber from the start. */ - BASE, - +enum class CatanExpansion { /** Cities & Knights: commodities, knights, barbarians, city improvements. */ CITIES_AND_KNIGHTS, @@ -164,16 +200,59 @@ enum class CatanRuleSet { SEAFARERS, } -@Serializable +/** + * What a table plays: the expansions switched on, and the Seafarers map when that one is. + * + * Saves written before expansions became toggles carry `"ruleSet"` instead of `"expansions"`; + * [CatanOptionsSerializer] reads either. + */ +@Serializable(with = CatanOptionsSerializer::class) data class CatanOptions( val playerCount: Int = 3, val pointsToWin: Int = 10, - val ruleSet: CatanRuleSet = CatanRuleSet.BASE, + val expansions: Set = emptySet(), + val scenario: SeaScenario = SeaScenario.NEW_SHORES, + val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP, ) { init { require(playerCount in 3..4) { "The island seats three or four players" } require(pointsToWin >= 2) { "pointsToWin must be at least 2" } } + + val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions + val citiesAndKnights: Boolean get() = CatanExpansion.CITIES_AND_KNIGHTS in expansions +} + +/** The wire shape of [CatanOptions]. */ +@Serializable +private class CatanOptionsWire( + val playerCount: Int = 3, + val pointsToWin: Int = 10, + val expansions: Set = emptySet(), + val scenario: SeaScenario = SeaScenario.NEW_SHORES, + val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP, +) + +/** Reads the legacy single `ruleSet` field as an expansion set. */ +object CatanOptionsSerializer : KSerializer { + override val descriptor: SerialDescriptor = CatanOptionsWire.serializer().descriptor + + override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue( + CatanOptionsWire.serializer(), + CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario, value.baseScenario), + ) + + override fun deserialize(decoder: Decoder): CatanOptions { + val input = decoder as JsonDecoder + var obj = input.decodeJsonElement().jsonObject + val legacy = (obj["ruleSet"] as? JsonPrimitive)?.content + if (legacy != null) { + val expansions = if (legacy == "BASE") emptyList() else listOf(JsonPrimitive(legacy)) + obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions))) + } + val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj) + return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario, wire.baseScenario) + } } /** @@ -198,6 +277,8 @@ data class CatanState( val turn: Int, /** How many setup placements are done, 0 until 2 * playerCount. */ val setupIndex: Int, + /** The seat that places first and takes the first turn; drawn from the table seed. */ + val firstSeat: Int = 0, /** Monotonic turn counter, 0 during setup, 1 from the first real turn. */ val turnNumber: Int, /** Terrain per hex, indexed like [CatanBoard.HEXES]. */ @@ -242,6 +323,8 @@ data class CatanState( val log: List, /** Everything Cities & Knights adds; null in the base game. */ val ck: CkState? = null, + /** The Harbormaster scenario: the seat holding the title, or null. */ + val harbormaster: Int? = null, /** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */ val sea: SeaState? = null, ) { @@ -414,12 +497,85 @@ data class SeaState( val shipMoved: Boolean = false, /** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */ val islandBonus: List, - /** The island the players start on. */ + /** The island that counts as home: the largest, and the pirate-free start of the original map. */ val home: Int, + /** The islands the starting settlements may go on. Empty in a save from before there were several: just [home]. */ + val starts: List = emptyList(), /** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */ val gold: List = emptyList(), + /** + * The tiles still face down, next to be found first. Hidden from every view but the host's, like the + * development deck. + */ + val fogDeck: List = emptyList(), + /** Where the game goes when the gold queue empties: [CatanPhase.PLAYING] unless a find interrupted something else. */ + val afterGold: CatanPhase? = null, + /** Cloth tokens each seat holds (Cloth for Catan); empty when the scenario has none. Two make a victory point. */ + val cloth: List = emptyList(), + /** Cloth still on each hex's village, by hex (Cloth for Catan); empty when the scenario has none. */ + val villageCloth: List = emptyList(), + /** The tribe chip waiting on each hex (Forgotten Tribe), by hex; empty when the scenario has none. */ + val chips: List = emptyList(), + /** Seats that took a harbour chip and so trade 3:1 anywhere. */ + val harbourSeats: List = emptyList(), + /** Victory points each seat holds from tribe chips; empty until one is taken. */ + val chipPoints: List = emptyList(), + /** Strength still left in each hex's pirate fortress, by hex (Pirate Islands); empty when the map has none. */ + val fortress: List = emptyList(), + /** Victory points each seat holds for taking fortresses; empty until one falls. */ + val raidPoints: List = emptyList(), + /** Wonder stages each seat has built (Wonders of Catan), by seat; empty when the scenario has none. */ + val wonders: List = emptyList(), + /** The chest under each hex of fog, by hex (Treasure Islands, Oases); found when the hex is. Hidden from other views. */ + val treasures: List = emptyList(), + /** Units dug so far into each canal tile, by hex (Great Canal); empty when the map has none. */ + val canalUnits: List = emptyList(), + /** The number token waiting under each canal tile, by hex; it shows when the tile is finished. */ + val canalNumbers: List = emptyList(), + /** Canal units each seat has dug; every two are a victory point. */ + val canalDug: List = emptyList(), ) +/** What a treasure chest holds (Treasure Islands). */ +@Serializable +enum class Treasure { + NONE, + + /** One brick, one wool and one grain. */ + BRICK_WOOL_GRAIN, + + /** One resource of the finder's choice. */ + CHOICE, + + /** A development card (a resource of choice in Cities & Knights, which has none). */ + DEV_CARD, + + /** Two roads' worth of cards: two lumber and two brick. */ + ROADS, +} + +/** What the first settler beside a tribe hex takes (Forgotten Tribe). */ +@Serializable +enum class TribeChip { + NONE, + + /** One victory point. */ + POINT, + + /** A 3:1 harbour, good wherever the seat trades. */ + HARBOUR, + + /** Two resources of the seat's choice. */ + GOLD, +} + +/** One tile of the fog: what the hex turns out to be and the number token that comes with it. */ +@Serializable +data class FogTile(val terrain: Terrain, val number: Int) + +/** Whether [island] is one the game starts on, and so never earns the new-island bonus. */ +fun SeaState.isStart(island: Int): Boolean = island == home || island in starts + @Serializable data class GoldOwed(val seat: Int, val count: Int) @@ -442,3 +598,18 @@ data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove @Serializable @SerialName("gold") data class PickGold(val cards: List) : CatanMove + +/** Raid the pirate fortress on [hex] from a ship beside it: 1 ore, 1 wool and 1 grain take one point of its strength. */ +@Serializable +@SerialName("raid") +data class RaidFortress(val hex: HexCoord) : CatanMove + +/** Build the next stage of your wonder, which needs a city of yours; the stage's cost is in `SeaRules.WONDER_STAGES`. */ +@Serializable +@SerialName("wonder") +data object BuildWonder : CatanMove + +/** Dig one unit of the canal tile on [hex]: 1 brick and 1 ore, from a seat with a settlement or city beside it. */ +@Serializable +@SerialName("dig") +data class DigCanal(val hex: HexCoord) : CatanMove diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt index 20f8083..7e0674d 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 @@ -54,15 +54,17 @@ object CatanRules : GameRules { "Seat count ${config.seats.size} does not match ${options.playerCount} players" } - val sea = options.ruleSet == CatanRuleSet.SEAFARERS - val scenario = SeaScenario.NEW_SHORES + val sea = options.seafarers + val scenario = options.scenario val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) } - ?: CatanBoard.generate(config.seed) + ?: CatanBoard.generate( + if (options.baseScenario == BaseScenario.FIRST_ISLAND) CatanBoard.FIRST_ISLAND_SEED else config.seed, + ) // A different random stream than the island, so the same seed still // produces both a fixed island and a fixed deck. - val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS + val ck = options.citiesAndKnights // Cities & Knights has no development cards: knights and progress // cards take their place. val devDeck = if (ck) { @@ -73,12 +75,17 @@ object CatanRules : GameRules { .shuffled(Random(config.seed + 1)) } + // Seat 0 used to lead every game; a separate stream from the board and + // the deck keeps the same seed giving the same table. + val firstSeat = Random(config.seed + 2).nextInt(options.playerCount) + return CatanState( options = options, seed = config.seed, phase = CatanPhase.SETUP_FIRST, - turn = 0, + turn = firstSeat, setupIndex = 0, + firstSeat = firstSeat, turnNumber = 0, terrains = board.terrains, numbers = board.numbers, @@ -114,6 +121,17 @@ object CatanRules : GameRules { pirate = it.pirate, islandBonus = List(topo.islandCount) { NO_PLAYER }, home = it.home, + starts = it.starts, + fogDeck = it.fogDeck, + cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 }, + villageCloth = it.villageCloth, + chips = it.chips, + fortress = it.fortress, + treasures = it.treasures, + canalUnits = if (it.canalNumbers.isEmpty()) emptyList() else List(topo.HEX_COUNT) { 0 }, + canalNumbers = it.canalNumbers, + canalDug = if (it.canalNumbers.isEmpty()) emptyList() else List(options.playerCount) { 0 }, + wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(), ) }, ) @@ -121,8 +139,9 @@ object CatanRules : GameRules { override fun currentSeat(state: CatanState): Int? = when (state.phase) { CatanPhase.GAME_OVER -> null - CatanPhase.SETUP_FIRST -> state.setupIndex - CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex + CatanPhase.SETUP_FIRST -> (state.firstSeat + state.setupIndex) % state.playerCount + CatanPhase.SETUP_SECOND -> + (state.firstSeat + 2 * state.playerCount - 1 - state.setupIndex) % state.playerCount CatanPhase.DISCARDING -> state.discardSeat CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 } ?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 } @@ -201,8 +220,10 @@ object CatanRules : GameRules { val moves = mutableListOf() for (v in 0 until state.topo.VERTEX_COUNT) { if (!vertexOpenForSettlement(state, v)) continue - // Seafarers: everyone starts on the home island. - if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue + // Seafarers: the starting settlements go on a starting island. + if (state.sea != null && !state.sea.isStart(state.topo.vertexIsland[v])) continue + // Nobody starts beside land that has not been found. + if (state.topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) continue for (e in state.topo.VERTEX_EDGES[v]) { if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue moves += SetupPlace(v, e) @@ -238,7 +259,7 @@ object CatanRules : GameRules { private fun robberMoves(state: CatanState, seat: Int): List { val moves = mutableListOf() for (h in 0 until state.topo.HEX_COUNT) { - if (h == state.robber || !state.topo.land[h]) continue + if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG || state.terrains[h] == Terrain.FORTRESS) continue val victims = state.topo.HEX_CORNERS[h] .mapNotNull { state.buildings[it] } .map { it.owner } @@ -270,6 +291,7 @@ object CatanRules : GameRules { if (state.isSea) { if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat) moves += SeaRules.moveShipMoves(state, seat) + moves += SeaRules.scenarioMoves(state, seat) } if (!state.devCardPlayedThisTurn) { @@ -404,6 +426,7 @@ object CatanRules : GameRules { /** An empty vertex with no building on it or one edge away. */ internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] && + !SeaRules.fortressHolds(state, v) && state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } /** @@ -457,6 +480,9 @@ object CatanRules : GameRules { is MoveShip -> applyMoveShip(state, seat, move) is MovePirate -> applyMovePirate(state, seat, move) is PickGold -> applyPickGold(state, seat, move) + is RaidFortress -> SeaRules.applyRaid(state, seat, move) + is DigCanal -> SeaRules.applyDig(state, seat, move) + BuildWonder -> SeaRules.applyWonder(state, seat) is BuildRoad -> applyBuildRoad(state, seat, move) is BuildSettlement -> applyBuildSettlement(state, seat, move) is BuildCity -> applyBuildCity(state, seat, move) @@ -508,13 +534,13 @@ object CatanRules : GameRules { } val newIndex = state.setupIndex + 1 - s = s.copy(setupIndex = newIndex) + s = refreshHarbormaster(s.copy(setupIndex = newIndex)) return when { newIndex >= 2 * n -> s.copy( phase = CatanPhase.AWAIT_ROLL, - turn = 0, + turn = state.firstSeat, turnNumber = 1, - log = s.log + "Setup done — seat 0 leads.", + log = s.log + "Setup done — seat ${state.firstSeat} leads.", ) newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND) @@ -586,12 +612,13 @@ object CatanRules : GameRules { s = s.copy( log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.", ) + if (state.isSea) s = SeaRules.payCloth(s, number) if (goldOwed.isEmpty()) return s return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed))) } /** What each seat is owed for gold fields showing [number], in seat order from the roller. */ - private fun goldOwed(state: CatanState, number: Int): List { + internal fun goldOwed(state: CatanState, number: Int): List { val topo = state.topo val owed = IntArray(state.playerCount) for (h in 0 until topo.HEX_COUNT) { @@ -654,7 +681,9 @@ object CatanRules : GameRules { s = s.copy(pendingFreeRoads = left) if (left == 0) s = s.copy(phase = phaseAfterRoads(s)) } - return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")) + s = s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.") + if (state.sea != null) s = SeaRules.reveal(s, seat, move.edge) + return checkWin(s) } private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState { @@ -673,7 +702,7 @@ object CatanRules : GameRules { s = s.copy(pendingFreeRoads = left) if (left == 0) s = s.copy(phase = phaseAfterRoads(s)) } - return checkWin(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}.")) + return checkWin(SeaRules.reveal(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."), seat, move.edge)) } private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState { @@ -686,12 +715,17 @@ object CatanRules : GameRules { val s = state.copy( sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to), ) - return checkWin(refreshLongestRoad(s).let { - it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.") - }) + return checkWin( + SeaRules.reveal( + refreshLongestRoad(s).let { it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.") }, + seat, + move.to, + ), + ) } private fun applyMovePirate(state: CatanState, seat: Int, move: MovePirate): CatanState { + if (state.isCk) return CatanCk.applyPirate(state, seat, move) val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}") var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1) var stolen = "" @@ -726,7 +760,7 @@ object CatanRules : GameRules { }, ) s = payCost(s, seat, SETTLEMENT_COST) - s = SeaRules.claimIsland(s, seat, move.vertex) + s = SeaRules.settled(s, seat, move.vertex) // A settlement can break another player's road, so the award is // recomputed for everyone. s = refreshLongestRoad(s) @@ -991,16 +1025,64 @@ object CatanRules : GameRules { } state.longestRoadOwner?.let { points[it] += 5 } state.largestArmyOwner?.let { points[it] += 5 } + state.harbormaster?.let { points[it] += HARBORMASTER_POINTS } if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat) return points.toList() } + // ----------------------------------------------------------------------- + // The Harbormaster + // ----------------------------------------------------------------------- + + /** The scenario's title: points for a harbour town and for the Harbormaster. */ + const val HARBORMASTER_MINIMUM = 3 + const val HARBORMASTER_POINTS = 2 + + /** Whether this table plays the Harbormaster (never in a Seafarers game, which has its own maps). */ + internal fun harbormasterGame(state: CatanState): Boolean = + state.options.baseScenario == BaseScenario.HARBORMASTER && !state.isSea + + /** Harbour points: a settlement on a harbour counts 1, a city 2, for each harbour it touches. */ + fun harbourPoints(state: CatanState, seat: Int): Int { + var total = 0 + for (port in state.ports) { + val (a, b) = state.topo.EDGE_VERTICES[port.edge] + val own = listOfNotNull(state.buildings[a], state.buildings[b]).firstOrNull { it.owner == seat } + if (own != null) total += if (own.type == BuildingType.CITY) 2 else 1 + } + return total + } + + /** Gives the title to the first seat with three harbour points, and to a seat with strictly more than the holder. */ + internal fun refreshHarbormaster(state: CatanState): CatanState { + if (!harbormasterGame(state)) return state + val points = List(state.playerCount) { harbourPoints(state, it) } + val holder = state.harbormaster + val best = points.max() + val leaders = points.indices.filter { points[it] == best } + val next = when { + holder == null -> leaders.singleOrNull()?.takeIf { best >= HARBORMASTER_MINIMUM } + best > points[holder] -> leaders.singleOrNull() ?: holder + else -> holder + } + if (next == holder) return state + return state.copy( + harbormaster = next, + log = state.log + if (holder == null) { + "Seat $next is now harbormaster." + } else { + "Seat $next has taken the title harbormaster from seat $holder." + }, + ) + } + fun victoryPoints(state: CatanState, seat: Int): Int = publicVictoryPoints(state)[seat] + state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT } - internal fun checkWin(state: CatanState): CatanState { - if (state.phase == CatanPhase.GAME_OVER) return state + internal fun checkWin(settled: CatanState): CatanState { + if (settled.phase == CatanPhase.GAME_OVER) return settled + val state = refreshHarbormaster(settled) val seat = state.turn val points = victoryPoints(state, seat) if (points < state.options.pointsToWin) return state @@ -1129,7 +1211,7 @@ object CatanRules : GameRules { cards.groupingBy { it }.eachCount() /** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */ - private fun withCounts( + internal fun withCounts( resources: List>, seat: Int, delta: Map, @@ -1167,6 +1249,7 @@ object CatanRules : GameRules { val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat if (mine) best = port.ratio } + if (best > 3 && state.sea?.harbourSeats?.contains(seat) == true) best = 3 return best } @@ -1177,7 +1260,7 @@ object CatanRules : GameRules { val (a, b) = state.topo.EDGE_VERTICES[port.edge] if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio } - return 4 + return if (state.sea?.harbourSeats?.contains(seat) == true) 3 else 4 } // ----------------------------------------------------------------------- @@ -1198,6 +1281,7 @@ object CatanRules : GameRules { state.copy( // Hidden information stays with the holder and the host. devDeck = emptyList(), + sea = state.sea?.copy(fogDeck = emptyList(), treasures = emptyList()), devHands = state.devHands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() }, diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt index 54f96ee..3a4fad6 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt @@ -5,11 +5,69 @@ import kotlin.random.Random /** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */ @Serializable -enum class SeaScenario(val title: String, val blurb: String) { +enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart = SeaStart.HOME_ONLY) { NEW_SHORES( "Heading for New Shores", "One large home island and three small ones to find by ship. Gold fields on two of the small islands.", ), + FOUR_ISLANDS( + "Four Islands", + "Four equal islands, one start each, and two gold islets in the middle. Settle your own island, then sail.", + SeaStart.MAIN_ISLANDS, + ), + THROUGH_THE_DESERT( + "Through the Desert", + "One island cut by a broad desert that yields nothing. Reach the far side by road, or sail round to the islets.", + ), + FOG_ISLANDS( + "The Fog Islands", + "A home island, an islet, and a bank of fog. Land in the fog is face down until a road or ship reaches it; " + + "the finder takes a card of what lies there.", + ), + TREASURE_ISLANDS( + "The Treasure Islands", + "Chests lie hidden on the fog-covered isles beside a home island. Whoever uncovers one opens it: " + + "cards, a resource of choice, a development card or a road's worth of materials.", + ), + OASES( + "The Oases", + "An enormous desert, with fertile oases hidden inside it. Roads that reach an oasis uncover it " + + "and whatever treasure is buried there.", + ), + GREAT_CANAL( + "The Great Canal", + "A desert strip splits the island. Dig the canal tile by tile (a brick and an ore a unit) and each finished " + + "tile becomes grain land; every two units dug are a victory point.", + ), + PIRATE_ISLANDS( + "Pirate Islands", + "Two isles are held by pirate fortresses. Sail ships beside one and raid it with ore, wool and grain; " + + "nobody may settle the isle until the fortress falls, and the one who takes it earns two points.", + ), + WONDERS_OF_CATAN( + "Wonders of Catan", + "Two big islands and room to grow. Raise a city, then build a wonder in four stages; each stage is a " + + "victory point and the first to finish all four wins.", + ), + CLOTH_FOR_CATAN( + "Cloth for Catan", + "Four villages weave cloth. Settle beside one and it pays you a cloth token whenever its number rolls; " + + "every two tokens are a victory point.", + ), + FORGOTTEN_TRIBE( + "The Forgotten Tribe", + "Four small isles hold the chips of a lost tribe. The first to settle beside one takes it: " + + "a victory point, a harbour, or gold.", + ), +} + +/** Where the starting settlements may go. */ +enum class SeaStart { + /** The largest island only. */ + HOME_ONLY, + + /** Any island big enough to live on (four hexes or more); the small ones are prizes to sail to. */ + MAIN_ISLANDS, } /** @@ -33,8 +91,134 @@ object SeaBoards { "..........", ) + private val FOUR_ISLANDS_MAP = listOf( + "............", + "..###..###..", + "..###..###..", + "............", + "..$.....$...", + "............", + "..###..###..", + "..###..###..", + "............", + ) + + private val THROUGH_THE_DESERT_MAP = listOf( + "............", + ".###DD###...", + ".###DDD###..", + "..###DD###..", + "............", + "..$.....$...", + "............", + "...##...#...", + "............", + ) + + /** `F` is fog: land that is face down until found. */ + private val FOG_ISLANDS_MAP = listOf( + "............", + ".###....FF..", + ".####..FFF..", + "..#D#..FFFF.", + ".###...FFF..", + ".......FF...", + "...##.......", + "............", + ) + + /** `V` is a village; `t` is a resource hex that carries a tribe chip. */ + private val CLOTH_FOR_CATAN_MAP = listOf( + "............", + "..V###..V##.", + "..#####..##.", + "..####V..#..", + "...###......", + ".......##V..", + "............", + "...##.......", + "............", + ) + + private val FORGOTTEN_TRIBE_MAP = listOf( + "............", + "..###..t#...", + "..#####..#t.", + "..#####..#..", + "..####......", + "........t##.", + ".#t.........", + "............", + ) + + private val TREASURE_ISLANDS_MAP = listOf( + "............", + "..###..FF...", + "..####..FF..", + "..#####.....", + "..###...F...", + ".......FF...", + "..F.........", + "............", + ) + + private val OASES_MAP = listOf( + "............", + "..###DD###..", + "..#DDFDD#...", + "..#DDDFD#...", + "..#DFDDD#...", + "..###DD###..", + "............", + ) + + /** `C` is a canal tile: desert with a number hidden under it until it is dug out. */ + private val GREAT_CANAL_MAP = listOf( + "............", + "..#####.....", + "..#####.....", + "..CCCCC.....", + "..#####.....", + "..#####.....", + "........$$..", + "............", + ) + + /** `P` is a pirate fortress. */ + private val PIRATE_ISLANDS_MAP = listOf( + "............", + "..####.P##..", + "..####..##..", + "..####......", + "..####..P#..", + "...##...##..", + "............", + "............", + ) + + private val WONDERS_OF_CATAN_MAP = listOf( + "............", + "..####.###..", + ".#####.####.", + "..###D.###..", + ".......##...", + "..$......$..", + "...##.......", + "............", + ) + private fun rows(scenario: SeaScenario): List = when (scenario) { SeaScenario.NEW_SHORES -> NEW_SHORES_MAP + SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP + SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP + SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP + SeaScenario.TREASURE_ISLANDS -> TREASURE_ISLANDS_MAP + SeaScenario.OASES -> OASES_MAP + SeaScenario.GREAT_CANAL -> GREAT_CANAL_MAP + SeaScenario.PIRATE_ISLANDS -> PIRATE_ISLANDS_MAP + SeaScenario.WONDERS_OF_CATAN -> WONDERS_OF_CATAN_MAP + SeaScenario.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP + SeaScenario.FORGOTTEN_TRIBE -> FORGOTTEN_TRIBE_MAP } private val topologies = HashMap() @@ -72,8 +256,34 @@ object SeaBoards { val pirate: Int, val ports: List, val home: Int, + /** The islands the starting settlements may go on; always includes [home]. */ + val starts: List = listOf(home), + /** The tiles under the fog, in the order they will be found. */ + val fogDeck: List = emptyList(), + /** The tribe chip on each hex, or [TribeChip.NONE]; empty when the map has none. */ + val chips: List = emptyList(), + /** Cloth waiting on each village, by hex; empty when the map has none. */ + val villageCloth: List = emptyList(), + /** Strength of the fortress on each hex, by hex; empty when the map has none. */ + val fortress: List = emptyList(), + /** The chest under each hex, by hex; empty when the map has none. */ + val treasures: List = emptyList(), + /** The number under each canal tile, by hex; empty when the map has none. */ + val canalNumbers: List = emptyList(), ) + /** An island of this many land hexes or more can be started on. */ + private const val MAIN_ISLAND_HEXES = 4 + + /** Cloth each village starts with. */ + const val VILLAGE_CLOTH = 6 + + /** Ships' raids a pirate fortress takes to fall. */ + const val FORTRESS_STRENGTH = 3 + + /** Chests dealt to the fog, at most. */ + private const val CHESTS = 5 + private val RESOURCE_TILES = listOf( Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS, ) @@ -88,19 +298,24 @@ object SeaBoards { val random = Random(seed) // Resources fill the '#' hexes as evenly as the count allows, in shuffled order. - val plain = kinds.indices.filter { kinds[it] == '#' } + val plain = kinds.indices.filter { kinds[it] == '#' || kinds[it] == 't' } val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random) val terrains = MutableList(kinds.size) { Terrain.SEA } kinds.forEachIndexed { h, ch -> terrains[h] = when (ch) { '$' -> Terrain.GOLD 'D' -> Terrain.DESERT - '#' -> pool[plain.indexOf(h)] + '#', 't' -> pool[plain.indexOf(h)] + 'V' -> Terrain.VILLAGE + 'P' -> Terrain.FORTRESS + 'C' -> Terrain.DESERT + 'F' -> Terrain.FOG else -> Terrain.SEA } } - val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT } + // The fog hexes take part as slots, so the tokens they hide are dealt from the same pile. + val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && (terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS || kinds[it] == 'C') } check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" } val tokens = TOKENS.take(numbered.size).shuffled(random) val numbers = IntArray(terrains.size) @@ -119,6 +334,14 @@ object SeaBoards { attempt++ } + // What lies under the fog: mostly resources, one gold field in five, drawn from the same tokens. + val fogHexes = terrains.indices.filter { terrains[it] == Terrain.FOG } + val fogKinds = List(fogHexes.size) { i -> + if (i % 5 == 4) Terrain.GOLD else RESOURCE_TILES[i % RESOURCE_TILES.size] + }.shuffled(random) + val fogDeck = fogHexes.mapIndexed { i, h -> FogTile(fogKinds[i], numbers[h]) }.shuffled(random) + fogHexes.forEach { numbers[it] = 0 } + val portTypes = ( listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null } ).shuffled(random) @@ -129,7 +352,12 @@ object SeaBoards { val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 } ?: numbered.first() - val home = (0 until topo.islandCount).maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0 + // Home is the largest island that is not under the fog: nobody starts on land they cannot see. + val fogged = (0 until topo.islandCount).filter { island -> + topo.islandOf.indices.filter { topo.islandOf[it] == island }.all { terrains[it] == Terrain.FOG } + }.toSet() + val home = (0 until topo.islandCount).filter { it !in fogged } + .maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0 // The pirate starts in the open sea: the sea hex farthest from every shore. val sea = terrains.indices.filter { terrains[it] == Terrain.SEA } val pirate = sea.maxByOrNull { h -> @@ -139,6 +367,52 @@ object SeaBoards { } ?: 0 } ?: sea.first() - return Generated(terrains, numbers.toList(), desert, pirate, ports, home) + val starts = when (scenario.start) { + SeaStart.HOME_ONLY -> listOf(home) + SeaStart.MAIN_ISLANDS -> (0 until topo.islandCount).filter { island -> + topo.islandOf.count { it == island } >= MAIN_ISLAND_HEXES + }.ifEmpty { listOf(home) } + } + + // Tribe chips: one of each kind in turn, dealt to the marked hexes in random order. + val chipHexes = kinds.indices.filter { kinds[it] == 't' } + val chips = if (chipHexes.isEmpty()) { + emptyList() + } else { + val kindsOfChip = listOf(TribeChip.POINT, TribeChip.HARBOUR, TribeChip.GOLD) + val dealt = List(chipHexes.size) { kindsOfChip[it % kindsOfChip.size] }.shuffled(random) + List(terrains.size) { h -> chipHexes.indexOf(h).let { if (it < 0) TribeChip.NONE else dealt[it] } } + } + val villages = if (kinds.none { it == 'V' }) { + emptyList() + } else { + List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 } + } + + val fortress = if (kinds.none { it == 'P' }) { + emptyList() + } else { + List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 } + } + + // Chests under the fog: the four rewards in turn, on hexes picked at random. + val chestKinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.CHOICE, Treasure.DEV_CARD, Treasure.ROADS) + val chestHexes = if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) { + fogHexes.shuffled(random).take(CHESTS) + } else { + emptyList() + } + val treasures = if (chestHexes.isEmpty()) { + emptyList() + } else { + List(terrains.size) { h -> chestHexes.indexOf(h).let { if (it < 0) Treasure.NONE else chestKinds[it % chestKinds.size] } } + } + + // Canal tiles keep their number under the tile until it is dug out. + val canalHexes = kinds.indices.filter { kinds[it] == 'C' } + val canalNumbers = if (canalHexes.isEmpty()) emptyList() else List(terrains.size) { h -> if (h in canalHexes) numbers[h] else 0 } + canalHexes.forEach { numbers[it] = 0 } + + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress, treasures, canalNumbers) } } diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt index 1f59aac..c9d1126 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt @@ -83,7 +83,7 @@ internal object SeaRules { .map { sea.ships[it] } .filter { it != NO_PLAYER && it != thief } .distinct() - .filter { state.resourceCount(it) > 0 } + .filter { if (state.isCk) CatanCk.goodsCount(state, it) > 0 else state.resourceCount(it) > 0 } } fun pirateMoves(state: CatanState, seat: Int): List { @@ -135,25 +135,356 @@ internal object SeaRules { val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) } val queue = if (supply > 0) sea.gold else emptyList() return if (queue.isEmpty()) { - state.copy(sea = sea.copy(gold = emptyList()), phase = CatanPhase.PLAYING) + state.copy(sea = sea.copy(gold = emptyList(), afterGold = null), phase = sea.afterGold ?: CatanPhase.PLAYING) } else { state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD) } } + // ----------------------------------------------------------------------- + // Fog + // ----------------------------------------------------------------------- + + /** Hidden hexes a piece on [edge] reaches: any that touches either end. */ + private fun fogReached(state: CatanState, edge: Int): List { + val topo = state.topo + return topo.EDGE_VERTICES[edge].toList() + .flatMap { topo.VERTEX_HEXES[it] } + .filter { state.terrains[it] == Terrain.FOG } + .distinct() + .sorted() + } + + /** + * After a road or ship lands on [edge]: turn each fog hex it now touches face up. The finder takes one card + * of what it yields, or chooses any resource if it is gold, supply permitting. The caller's phase is kept. + */ + fun reveal(state: CatanState, seat: Int, edge: Int): CatanState { + var s = state + val found = fogReached(state, edge) + if (found.isEmpty()) return s + val owed = mutableListOf() + for (hex in found) { + val sea = s.sea!! + val tile = sea.fogDeck.firstOrNull() ?: break + val name = tile.terrain.name.lowercase() + s = s.copy( + terrains = s.terrains.toMutableList().also { it[hex] = tile.terrain }, + numbers = s.numbers.toMutableList().also { it[hex] = tile.number }, + sea = sea.copy(fogDeck = sea.fogDeck.drop(1)), + log = s.log + "Seat $seat uncovers ${if (tile.terrain == Terrain.GOLD) "a gold field" else "the $name"} " + + "(${s.topo.HEXES[hex].q},${s.topo.HEXES[hex].r})" + + if (tile.number != 0) " with a ${tile.number}." else ".", + ) + val resource = tile.terrain.produces + if (resource != null && CatanRules.bankSupply(s, resource) > 0) { + s = s.copy(resources = CatanRules.withCounts(s.resources, seat, mapOf(resource to 1), 1)) + } else if (tile.terrain == Terrain.GOLD) { + owed += GoldOwed(seat, 1) + } + val chest = s.sea!!.treasures.getOrNull(hex) ?: Treasure.NONE + if (chest != Treasure.NONE) { + val (opened, choose) = openChest(s, seat, chest) + s = opened + if (choose > 0) owed += GoldOwed(seat, choose) + } + } + if (owed.isEmpty()) return s + return owe(s, seat, owed.sumOf { it.count }) + } + + /** + * [seat] opens [chest]: the state after what it pays directly, and the number of cards of the seat's own + * choice still owed. Cards the bank cannot cover are simply not paid. + */ + private fun openChest(state: CatanState, seat: Int, chest: Treasure): Pair { + var s = state + fun pay(cards: Map) { + val real = cards.filter { (r, n) -> CatanRules.bankSupply(s, r) >= n } + if (real.isNotEmpty()) s = s.copy(resources = CatanRules.withCounts(s.resources, seat, real, 1)) + } + var choose = 0 + val said = when (chest) { + Treasure.BRICK_WOOL_GRAIN -> { + pay(mapOf(Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1)) + "a brick, a wool and a grain" + } + Treasure.ROADS -> { + pay(mapOf(Resource.LUMBER to 2, Resource.BRICK to 2)) + "materials for two roads" + } + Treasure.DEV_CARD -> if (!s.isCk && s.devDeck.isNotEmpty()) { + val card = s.devDeck.first().copy(boughtTurn = s.turnNumber) + val hands = s.devHands.toMutableList().also { it[seat] = it[seat] + card } + s = s.copy(devDeck = s.devDeck.drop(1), devHands = hands, devHandCounts = hands.map { it.size }) + "a development card" + } else { + choose = 1 + "a resource of its choice" + } + else -> { + choose = 1 + "a resource of its choice" + } + } + s = s.copy(log = s.log + "Seat $seat opens a treasure chest: $said.") + return s to choose + } + + /** Queues [count] cards of [seat]'s choice, to be taken before play resumes where it left off. */ + private fun owe(state: CatanState, seat: Int, count: Int): CatanState { + val sea = state.sea!! + val queued = sea.copy( + gold = sea.gold + GoldOwed(seat, count), + afterGold = state.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold, + ) + return settleGold(state.copy(sea = queued)) + } + // ----------------------------------------------------------------------- // Islands and scoring // ----------------------------------------------------------------------- - /** The bonus points [seat] holds for first landings. */ - fun bonusPoints(state: CatanState, seat: Int): Int = - (state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS + /** The bonus points [seat] holds: first landings, tribe chips, and one for every two cloth. */ + fun bonusPoints(state: CatanState, seat: Int): Int { + val sea = state.sea ?: return 0 + return sea.islandBonus.count { it == seat } * ISLAND_BONUS + + (sea.raidPoints.getOrNull(seat) ?: 0) + + (sea.wonders.getOrNull(seat) ?: 0) + + clothOf(state, seat) / CLOTH_PER_POINT + + (sea.chipPoints.getOrNull(seat) ?: 0) + + (sea.canalDug.getOrNull(seat) ?: 0) / CANAL_UNITS_PER_POINT + } + + // ----------------------------------------------------------------------- + // The Great Canal + // ----------------------------------------------------------------------- + + val DIG_COST: Map = mapOf(Resource.BRICK to 1, Resource.ORE to 1) + + /** Units that finish a canal tile. */ + const val CANAL_TILE_UNITS = 3 + + /** Two units dug are a victory point. */ + const val CANAL_UNITS_PER_POINT = 2 + + /** Tiles [seat] may dig a unit of: unfinished, with a settlement or city of the seat's on a corner. */ + private fun digTargets(state: CatanState, seat: Int): List { + val sea = state.sea!! + if (sea.canalUnits.isEmpty()) return emptyList() + return sea.canalUnits.indices.filter { h -> + sea.canalNumbers[h] != 0 && sea.canalUnits[h] in 0 until CANAL_TILE_UNITS && + state.terrains[h] == Terrain.DESERT && + state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } + } + } + + fun applyDig(state: CatanState, seat: Int, move: DigCanal): CatanState { + val sea = state.sea!! + val hex = state.topo.hexIndex.getValue(move.hex) + val units = sea.canalUnits[hex] + 1 + val dug = sea.canalDug.toMutableList().also { it[seat] += 1 } + var s = state.copy( + resources = CatanRules.withCounts(state.resources, seat, DIG_COST, -1), + sea = sea.copy(canalUnits = sea.canalUnits.toMutableList().also { it[hex] = units }, canalDug = dug), + log = state.log + "Seat $seat digs the canal at (${move.hex.q},${move.hex.r}): $units of $CANAL_TILE_UNITS.", + ) + if (units == CANAL_TILE_UNITS) { + s = s.copy( + terrains = s.terrains.toMutableList().also { it[hex] = Terrain.FIELDS }, + numbers = s.numbers.toMutableList().also { it[hex] = sea.canalNumbers[hex] }, + log = s.log + "The tile is dug out: it is fields now, with a ${sea.canalNumbers[hex]}.", + ) + } + return CatanRules.checkWin(s) + } + + // ----------------------------------------------------------------------- + // Cloth for Catan + // ----------------------------------------------------------------------- + + const val CLOTH_PER_POINT = 2 + + fun clothOf(state: CatanState, seat: Int): Int = state.sea?.cloth?.getOrNull(seat) ?: 0 + + /** + * After the dice pay: every village showing [number] pays one cloth to each settlement or city beside it + * (a city is not worth more here), until its stock runs out. A robber on the village stops it. + */ + fun payCloth(state: CatanState, number: Int): CatanState { + val sea = state.sea ?: return state + if (sea.villageCloth.isEmpty()) return state + var s = state + val topo = state.topo + for (h in 0 until topo.HEX_COUNT) { + if (state.terrains[h] != Terrain.VILLAGE || state.numbers[h] != number || h == state.robber) continue + val takers = topo.HEX_CORNERS[h].mapNotNull { state.buildings[it]?.owner } + if (takers.isEmpty()) continue + // Seats in turn order from the roller get the last of the stock. + val order = takers.sortedBy { (it - state.turn + state.playerCount) % state.playerCount } + val current = s.sea!! + var stock = current.villageCloth[h] + val cloth = current.cloth.toMutableList() + val said = mutableListOf() + for (seat in order) { + if (stock == 0) break + cloth[seat] += 1 + stock-- + said += "seat $seat" + } + if (said.isEmpty()) continue + val stocks = current.villageCloth.toMutableList().also { it[h] = stock } + s = s.copy( + sea = current.copy(cloth = cloth, villageCloth = stocks), + log = s.log + "The village at (${topo.HEXES[h].q},${topo.HEXES[h].r}) pays cloth to ${said.joinToString(", ")}.", + ) + } + return s + } + + // ----------------------------------------------------------------------- + // Pirate Islands + // ----------------------------------------------------------------------- + + val RAID_COST: Map = mapOf(Resource.ORE to 1, Resource.WOOL to 1, Resource.GRAIN to 1) + + const val FORTRESS_POINTS = 2 + + /** Whether a pirate fortress still stands on the island [vertex] is part of, so nobody may settle there. */ + fun fortressHolds(state: CatanState, vertex: Int): Boolean { + val sea = state.sea ?: return false + if (sea.fortress.isEmpty()) return false + val island = state.topo.vertexIsland[vertex] + return island >= 0 && sea.fortress.indices.any { sea.fortress[it] > 0 && state.topo.islandOf[it] == island } + } + + /** Fortress hexes [seat] has a ship beside. */ + private fun raidTargets(state: CatanState, seat: Int): List { + val sea = state.sea!! + if (sea.fortress.isEmpty()) return emptyList() + val topo = state.topo + return sea.fortress.indices.filter { h -> + sea.fortress[h] > 0 && (0 until topo.EDGE_COUNT).any { h in topo.EDGE_HEXES[it] && sea.ships[it] == seat } + } + } + + fun applyRaid(state: CatanState, seat: Int, move: RaidFortress): CatanState { + val sea = state.sea!! + val hex = state.topo.hexIndex.getValue(move.hex) + val left = sea.fortress[hex] - 1 + var s = state.copy(resources = CatanRules.withCounts(state.resources, seat, RAID_COST, -1)) + val strength = sea.fortress.toMutableList().also { it[hex] = left } + if (left > 0) { + return s.copy( + sea = sea.copy(fortress = strength), + log = s.log + "Seat $seat raids the fortress at (${move.hex.q},${move.hex.r}); $left strength left.", + ) + } + val points = sea.raidPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList().also { it[seat] += FORTRESS_POINTS } + s = s.copy( + terrains = s.terrains.toMutableList().also { it[hex] = Terrain.DESERT }, + sea = sea.copy(fortress = strength, raidPoints = points), + log = s.log + "Seat $seat takes the fortress at (${move.hex.q},${move.hex.r}): +$FORTRESS_POINTS victory points, " + + "and the island is open.", + ) + return CatanRules.checkWin(s) + } + + // ----------------------------------------------------------------------- + // Wonders of Catan + // ----------------------------------------------------------------------- + + /** What each of the four stages of a wonder costs. */ + val WONDER_STAGES: List> = listOf( + mapOf(Resource.BRICK to 2, Resource.LUMBER to 1, Resource.GRAIN to 1), + mapOf(Resource.BRICK to 1, Resource.ORE to 2, Resource.WOOL to 1), + mapOf(Resource.ORE to 3, Resource.GRAIN to 1, Resource.LUMBER to 1), + mapOf(Resource.ORE to 3, Resource.GRAIN to 2, Resource.BRICK to 1), + ) + + /** The next stage [seat] could build, if the scenario has wonders and the seat has a city to raise it in. */ + private fun nextStage(state: CatanState, seat: Int): Map? { + val stage = state.sea?.wonders?.getOrNull(seat) ?: return null + if (stage >= WONDER_STAGES.size) return null + if (state.buildings.none { it?.owner == seat && it.type == BuildingType.CITY }) return null + return WONDER_STAGES[stage] + } + + /** The extra moves a scenario offers during play: raids and wonder stages. */ + fun scenarioMoves(state: CatanState, seat: Int): List { + val moves = mutableListOf() + if (CatanRules.affordable(state, seat, RAID_COST)) { + raidTargets(state, seat).forEach { moves += RaidFortress(state.topo.HEXES[it]) } + } + if (CatanRules.affordable(state, seat, DIG_COST)) { + digTargets(state, seat).forEach { moves += DigCanal(state.topo.HEXES[it]) } + } + nextStage(state, seat)?.let { if (CatanRules.affordable(state, seat, it)) moves += BuildWonder } + return moves + } + + fun applyWonder(state: CatanState, seat: Int): CatanState { + val sea = state.sea!! + val stage = sea.wonders[seat] + 1 + val built = sea.wonders.toMutableList().also { it[seat] = stage } + val s = state.copy( + resources = CatanRules.withCounts(state.resources, seat, WONDER_STAGES[stage - 1], -1), + sea = sea.copy(wonders = built), + log = state.log + "Seat $seat builds stage $stage of a wonder.", + ) + if (stage < WONDER_STAGES.size) return CatanRules.checkWin(s) + return s.copy( + phase = CatanPhase.GAME_OVER, + winner = seat, + log = s.log + "Seat $seat completes a wonder of Catan and wins.", + ) + } + + // ----------------------------------------------------------------------- + // The Forgotten Tribe + // ----------------------------------------------------------------------- + + /** After a settlement on [vertex]: take any tribe chip lying on a hex beside it. */ + fun takeChips(state: CatanState, seat: Int, vertex: Int): CatanState { + var s = state + if (s.sea?.chips.isNullOrEmpty()) return s + var gold = 0 + for (hex in state.topo.VERTEX_HEXES[vertex]) { + val sea = s.sea!! + val chip = sea.chips.getOrNull(hex) ?: continue + if (chip == TribeChip.NONE) continue + val rest = sea.chips.toMutableList().also { it[hex] = TribeChip.NONE } + var next = sea.copy(chips = rest) + val said = when (chip) { + TribeChip.POINT -> { + val points = next.chipPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList() + points[seat] += 1 + next = next.copy(chipPoints = points) + "a victory point" + } + TribeChip.HARBOUR -> { + next = next.copy(harbourSeats = (next.harbourSeats + seat).distinct()) + "a harbour that trades 3:1" + } + else -> { + gold += 2 + "two resources of its choice" + } + } + s = s.copy(sea = next, log = s.log + "Seat $seat takes the tribe's chip beside it: $said.") + } + return if (gold > 0) owe(s, seat, gold) else s + } + + /** What happens to a new settlement of [seat]'s on [vertex] beyond the base game. */ + fun settled(state: CatanState, seat: Int, vertex: Int): CatanState = + takeChips(claimIsland(state, seat, vertex), seat, vertex) /** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */ fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState { val sea = state.sea ?: return state val island = state.topo.vertexIsland[vertex] - if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state + if (island < 0 || sea.isStart(island) || sea.islandBonus[island] != NO_PLAYER) return state val bonus = sea.islandBonus.toMutableList().also { it[island] = seat } return state.copy( sea = sea.copy(islandBonus = bonus), diff --git a/app/src/main/java/org/prolibertate/games/game/checkers/CheckersAi.kt b/app/src/main/java/org/prolibertate/games/game/checkers/CheckersAi.kt index edf6d58..995fb99 100644 --- a/app/src/main/java/org/prolibertate/games/game/checkers/CheckersAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/checkers/CheckersAi.kt @@ -1,6 +1,9 @@ package org.prolibertate.games.game.checkers import kotlin.random.Random +import kotlin.math.roundToInt +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -17,22 +20,49 @@ import org.prolibertate.games.game.engine.GameAi * recapture as its only legal move, so the search plays it whether it wanted to * or not. */ -class CheckersAi(private val level: CheckersLevel? = null) : - GameAi { +class CheckersAi( + private val profiles: AiProfiles = AiProfiles.DEFAULT, + private val level: CheckersLevel? = null, +) : GameAi { private var nodeBudget = 0 private var nodesUsed = 0 + // Evaluation weights and viewpoint for the move being chosen. At the default + // (balanced) style they equal the plain constants, so Medium is unchanged. + private var rootSeat = 0 + private var wAdvance = ADVANCE + private var wEdge = EDGE + private var wBackRow = BACK_ROW + private var wTheirMobility = MOBILITY + override fun chooseMove( state: CheckersState, seat: Int, legal: List, ): CheckersMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) val setting = level ?: state.options.level if (legal.size == 1) return legal.first() - nodeBudget = NODE_BUDGET + val bold = profile.style.boldness + val spite = profile.style.spite + rootSeat = seat + // Bold seats push men forward and care less about the edge and back row; + // cautious ones do the reverse. Spiteful seats weigh the opponent's + // freedom of movement more heavily. + wAdvance = ADVANCE + (bold * 2).roundToInt() + wEdge = EDGE - (bold * 2).roundToInt() + wBackRow = BACK_ROW - (bold * 3).roundToInt() + wTheirMobility = MOBILITY + (spite * 2).roundToInt() + + val maxDepth = when (profile.level) { + AiLevel.EASY -> maxOf(2, setting.depth - 2) + AiLevel.MEDIUM -> setting.depth + AiLevel.HARD -> setting.depth + 2 + } + nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET nodesUsed = 0 val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode()) @@ -40,7 +70,7 @@ class CheckersAi(private val level: CheckersLevel? = null) : var best = legal.first() var scored = legal.map { it to 0 } - for (depth in 1..setting.depth) { + for (depth in 1..maxDepth) { val results = mutableListOf>() val ordered = order(legal, preferred = best) for (move in ordered) { @@ -61,7 +91,16 @@ class CheckersAi(private val level: CheckersLevel? = null) : val bestScore = scored.maxOf { it.second } val acceptable = scored.filter { it.second >= bestScore - setting.slack() } - return acceptable[random.nextInt(acceptable.size)].first + val chosen = acceptable[random.nextInt(acceptable.size)].first + if (profile.level != AiLevel.EASY) return chosen + + // Easy now and then settles for another sound-looking move: only ones + // within a man and a half of the best, never a real blunder. + val ranked = listOf(chosen) + scored + .filter { it.first != chosen && it.second >= bestScore - EASY_TOLERANCE } + .sortedByDescending { it.second } + .map { it.first } + return profile.pick(ranked) } private fun CheckersLevel.slack(): Int = when (this) { @@ -126,29 +165,32 @@ class CheckersAi(private val level: CheckersLevel? = null) : */ private fun evaluate(state: CheckersState): Int { val us = state.turn - val them = other(us) var score = 0 + val me = rootSeat for (square in 0 until SQUARES) { val piece = state.board[square] ?: continue - val sign = if (piece.seat == us) 1 else -1 + val sign = if (piece.seat == me) 1 else -1 score += sign * if (piece.king) KING else MAN if (!piece.king) { // How many rows it has come, out of the seven it needs. val advanced = if (piece.seat == BLACK_SEAT) rowOf(square) else 7 - rowOf(square) - score += sign * advanced * ADVANCE + score += sign * advanced * wAdvance } // Sitting on the edge cannot be jumped, which is worth a little in // a game whose entire tactic is being jumped. - if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * EDGE + if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * wEdge - if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * BACK_ROW + if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * wBackRow } - score += MOBILITY * (mobility(state, us) - mobility(state, them)) - return score + val mine = mobility(state, me) + val theirs = mobility(state, other(me)) + score += MOBILITY * mine - wTheirMobility * theirs + // Scored from the root seat's side; flip for the side to move. + return if (us == me) score else -score } /** @@ -178,6 +220,8 @@ class CheckersAi(private val level: CheckersLevel? = null) : const val INFINITY = 1_000_000 const val WIN = 100_000 const val NODE_BUDGET = 200_000 + const val HARD_NODE_BUDGET = 450_000 + const val EASY_TOLERANCE = 150 const val MAN = 100 const val KING = 175 diff --git a/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt b/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt index 3b4cb04..64ec95e 100644 --- a/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt @@ -2,6 +2,9 @@ package org.prolibertate.games.game.chess import kotlin.math.abs import kotlin.random.Random +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -16,7 +19,14 @@ import org.prolibertate.games.game.engine.GameAi * so the setup screen sets the strength without the caller having to thread it * through. */ -class ChessAi(private val level: ChessLevel? = null) : GameAi { +class ChessAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi { + + /** Fixed strength that overrides the table's option (used by tests and tools). */ + private var level: ChessLevel? = null + + constructor(level: ChessLevel) : this(AiProfiles.DEFAULT) { + this.level = level + } /** * Ceiling on how much thinking any one move gets. A phone should not stall @@ -28,10 +38,22 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi): ChessMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) val setting = level ?: state.options.level if (legal.size == 1) return legal.first() - nodeBudget = NODE_BUDGET + // Easy looks one move less far ahead; Hard looks one further and gets + // roughly twice the node budget (still a couple of seconds on a phone). + val maxDepth = when (profile.level) { + AiLevel.EASY -> maxOf(1, setting.depth - 1) + AiLevel.MEDIUM -> setting.depth + AiLevel.HARD -> setting.depth + 1 + } + nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET + aiWhite = state.whiteToMove + styleActive = profile.style.boldness != 0.0 || profile.style.spite != 0.0 + styleBoldness = profile.style.boldness + styleSpite = profile.style.spite nodesUsed = 0 // Deterministic per position, so a host and a client replaying the same @@ -42,7 +64,7 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi>() // Search the previous iteration's best first, so the sub-searches // below it have a decent bound to prune against. @@ -74,9 +96,56 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi= bestScore - setting.slack() } - return acceptable[random.nextInt(acceptable.size)].first + val chosen = acceptable[random.nextInt(acceptable.size)].first + if (profile.level != AiLevel.EASY) return chosen + + // Easy sometimes settles for a worse move, but only one within a modest + // margin of the best, so it never throws a piece away or walks into mate. + val ranked = scored.filter { it.second >= bestScore - setting.slack() - EASY_EXTRA_SLACK } + .sortedByDescending { it.second }.map { it.first } + val ordered = listOf(chosen) + ranked.filter { it != chosen } + return profile.pick(ordered) } + // Per-move style state, set at the start of chooseMove. + private var aiWhite = true + private var styleActive = false + private var styleBoldness = 0.0 + private var styleSpite = 0.0 + + /** + * A small temperament term (well under a pawn) from the mover's point of + * view. Aggressive seats like pieces near the enemy king, cautious seats + * like a sheltered own king, spiteful seats like the enemy being badly + * placed. Zero for the default style, so default play is unchanged. + */ + private fun styleTerm(state: ChessState): Int { + val enemyKing = state.kingSquare(white = !aiWhite) + val myKing = state.kingSquare(white = aiWhite) + var attackers = 0 + var shield = 0 + var enemyPlacement = 0 + for (square in 0 until 64) { + val piece = state.board[square] ?: continue + if (piece.white == aiWhite) { + if (piece.kind == PieceKind.KING) continue + if (enemyKing != null && piece.kind != PieceKind.PAWN && distance(square, enemyKing) <= 3) attackers++ + if (myKing != null && distance(square, myKing) <= 1) shield++ + } else { + val table = tableFor(piece.kind) + enemyPlacement += table[if (piece.white) square else square xor 56] + } + } + var term = 0.0 + if (styleBoldness > 0) term += styleBoldness * 4 * attackers + if (styleBoldness < 0) term += -styleBoldness * 5 * shield + term -= styleSpite * 0.1 * enemyPlacement + val own = term.toInt().coerceIn(-40, 40) + return if (state.whiteToMove == aiWhite) own else -own + } + + private fun distance(a: Int, b: Int): Int = maxOf(abs(fileOf(a) - fileOf(b)), abs(rankOf(a) - rankOf(b))) + private fun ChessLevel.slack(): Int = when (this) { ChessLevel.CASUAL -> 70 ChessLevel.CLUB -> 20 @@ -116,7 +185,7 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi= beta) return beta var localAlpha = maxOf(alpha, standPat) if (ply >= MAX_QUIESCENCE_PLY) return localAlpha @@ -205,6 +274,8 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi { - enum class Difficulty( - /** Play an eight anyway once the hand is down to this size. */ - val eightEndgame: Int, - ) { - EASY(eightEndgame = 99), - NORMAL(eightEndgame = 3), - CAUTIOUS(eightEndgame = 2), - } - override fun chooseMove( state: CrazyEightsState, seat: Int, legal: List, ): CrazyEightsMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) val plays = legal.filterIsInstance() if (plays.isEmpty()) return legal.first() + val move: CrazyEightsMove = when (profile.level) { + AiLevel.MEDIUM -> standardRanking(state, seat, plays, EIGHT_ENDGAME).first() + AiLevel.EASY -> profile.pick(standardRanking(state, seat, plays, EASY_EIGHT_ENDGAME)) + AiLevel.HARD -> hard(state, seat, plays, profile) + } + return if (move in legal) move else legal.first() + } + + /** + * Ordinary cards first, most expensive leading, then eights with the suit + * this hand holds most of named first. + */ + private fun standardRanking( + state: CrazyEightsState, + seat: Int, + plays: List, + eightEndgame: Int, + ): List { val hand = state.hands[seat] val ordinary = plays.filter { !isWild(it.card) } // An eight is the only card that always plays, so it is worth more held // than spent — unless the hand is nearly gone, or nothing else works. - val useEight = ordinary.isEmpty() || hand.size <= difficulty.eightEndgame + val useEight = ordinary.isEmpty() || hand.size <= eightEndgame val pool = if (useEight) plays else ordinary - val best = pool - .filter { !isWild(it.card) } - .maxByOrNull { crazyEightsPenalty(it.card) } - - if (best != null) return best + val ranked = pool.filter { !isWild(it.card) }.sortedByDescending { crazyEightsPenalty(it.card) } + if (ranked.isNotEmpty() && !useEight) return ranked // Playing an eight: switch the game to whatever we hold most of. - val strongest = Suit.entries.maxByOrNull { suit -> hand.count { it.suit == suit } } - ?: Suit.SPADES - return pool.firstOrNull { it.nominatedSuit == strongest } ?: pool.first() + val suits = Suit.entries.sortedByDescending { suit -> hand.count { it.suit == suit } } + val wilds = suits.flatMap { suit -> pool.filter { isWild(it.card) && it.nominatedSuit == suit } } + return ranked + wilds.ifEmpty { pool.filter { isWild(it.card) } } + } + + // ----------------------------------------------------------------------- + // Hard + // ----------------------------------------------------------------------- + + private fun hard( + state: CrazyEightsState, + seat: Int, + plays: List, + profile: AiProfile, + ): CrazyEightsMove { + val hand = state.hands[seat] + val options = state.options + + // Cards this seat can account for: its own hand and everything on the pile. + val known = HashSet().also { it.addAll(hand); it.addAll(state.discard) } + val unseen = FULL_DECK.filter { it !in known } + fun unseenIn(suit: Suit): Int = unseen.count { it.suit == suit } + + val next = (seat + 1) % options.playerCount + val nextCount = state.handCounts[next] + val smallestOpponent = (0 until options.playerCount).filter { it != seat } + .minOf { state.handCounts[it] } + // How hard to squeeze the suits: more when somebody is about to go out. + val urgency = 1.0 + profile.style.spite + + (if (nextCount <= 2) 1.0 else 0.0) + (if (smallestOpponent <= 2) 0.5 else 0.0) + + /** Value of leaving the game showing [suit], with [remaining] the cards still held. */ + fun suitValue(suit: Suit, remaining: List): Double = + 3.0 * remaining.count { it.suit == suit } - 0.7 * urgency * unseenIn(suit) + + val ordinary = plays.filter { !isWild(it.card) } + val wilds = plays.filter { isWild(it.card) } + + // Aggressive seats spend eights earlier; cautious ones keep them as insurance. + val endgame = (EIGHT_ENDGAME + profile.style.boldness * 2.0) + val blocking = smallestOpponent <= 1 || (nextCount <= 2 && urgency >= 2.0) + val useEight = ordinary.isEmpty() || hand.size <= endgame || (blocking && wilds.isNotEmpty()) + + if (hand.size == 1) { + plays.firstOrNull()?.let { return it } + } + + if (!useEight || wilds.isEmpty()) { + if (ordinary.isNotEmpty()) { + // Cautious seats shed liabilities harder, bold ones care more about position. + val shedWeight = 1.0 - 0.3 * profile.style.boldness + return ordinary.maxBy { play -> + val remaining = hand - play.card + crazyEightsPenalty(play.card) * shedWeight + suitValue(play.card.suit, remaining) + } + } + } + + // An eight: name the suit that is best for this hand and worst for the others. + return wilds.maxBy { play -> + val remaining = hand - play.card + suitValue(play.nominatedSuit ?: play.card.suit, remaining) + } + } + + private companion object { + const val EIGHT_ENDGAME = 3 + const val EASY_EIGHT_ENDGAME = 99 + val FULL_DECK: List = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } } } } diff --git a/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageAi.kt b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageAi.kt index 7db04e3..fc4eb83 100644 --- a/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/cribbage/CribbageAi.kt @@ -3,8 +3,12 @@ package org.prolibertate.games.game.cribbage import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.cards.Decks import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi import kotlin.math.abs +import kotlin.random.Random /** * A cribbage opponent that counts rather than guesses. @@ -18,8 +22,14 @@ import kotlin.math.abs * * The play is a heuristic: take the points on offer, and try not to leave the * count where a ten-card collects fifteen or thirty-one. + * + * Per seat, from [profiles]: Easy settles for a worse lay-away or card now and + * then; Hard also samples the crib (what the unseen cards might add to it) and + * weighs what a reply could score off each card it plays. Boldness trades the + * two counts-to-avoid and crib ownership; spite weighs the other side's crib. */ -class CribbageAi : GameAi { +class CribbageAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : + GameAi { override fun chooseMove( state: CribbageState, @@ -27,30 +37,78 @@ class CribbageAi : GameAi { legal: List, ): CribbageMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) val layAways = legal.filterIsInstance() - if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways) - return choosePeg(state, legal.filterIsInstance()) + if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways, profile) + val pegs = legal.filterIsInstance() + if (pegs.isNotEmpty()) return choosePeg(state, seat, pegs, profile) + return legal.first() } private fun chooseLayAway( state: CribbageState, seat: Int, choices: List, + profile: AiProfile, ): LayAway { val hand = state.hands[seat] val held = hand.toSet() val unseen = Decks.standard52().filter { it !in held } // A partner's crib is your crib. That is the whole of four-handed play. val ownCrib = state.options.teamOf(seat) == state.options.teamOf(state.dealer) + val boldness = profile.style.boldness + // Bold seats back their own crib; careful ones fear the other side's. + val cribSign = if (ownCrib) { + 1.0 + 0.25 * boldness + } else { + -(1.0 - 0.25 * boldness + 0.3 * profile.style.spite) + } + val trials = if (profile.level == AiLevel.HARD) sampleCribs(hand, unseen, state.options) else null - return choices.maxByOrNull { choice -> + val scored = choices.map { choice -> val given = choice.cards.toSet() val keep = hand.filter { it !in given } val expected = unseen.sumOf { starter -> CribbageScoring.show(keep, starter, isCrib = false).sumOf { it.points } }.toDouble() / unseen.size - expected + cribValue(choice.cards) * (if (ownCrib) 1.0 else -1.0) - } ?: choices.first() + val crib = if (trials != null) { + // Heuristic and sampled estimates agree on direction; averaging + // them smooths the sampling noise without losing the structure. + (sampledCribValue(choice.cards, trials) + cribValue(choice.cards)) / 2.0 + } else { + cribValue(choice.cards) + } + choice to expected + crib * cribSign + } + // Stable sort: ties resolve to the first choice, as maxByOrNull did. + val ranked = scored.sortedByDescending { it.second }.map { it.first } + return profile.pick(ranked) + } + + /** Sets of unseen cards a crib might receive, plus a cut, for Hard to average over. */ + private fun sampleCribs( + hand: List, + unseen: List, + options: CribbageOptions, + ): List> { + val random = Random(hand.sumOf { it.rank.ordinal * 4 + it.suit.ordinal }.toLong()) + // Whatever the crib is short of after our own cards, plus the cut. + val others = (CRIB_SIZE - options.layAwaySize).coerceAtLeast(0) + return List(CRIB_SAMPLES) { + val picked = LinkedHashSet() + while (picked.size < others + 1) picked += unseen[random.nextInt(unseen.size)] + picked.toList() + } + } + + private fun sampledCribValue(given: List, trials: List>): Double { + var total = 0 + for (trial in trials) { + val starter = trial.last() + val crib = given + trial.dropLast(1) + total += CribbageScoring.show(crib, starter, isCrib = true).sumOf { it.points } + } + return total.toDouble() / trials.size } /** @@ -83,22 +141,63 @@ class CribbageAi : GameAi { return value } - private fun choosePeg(state: CribbageState, choices: List): PegCard { + private fun choosePeg( + state: CribbageState, + seat: Int, + choices: List, + profile: AiProfile, + ): PegCard { val count = state.count val onTheTable = state.series.map { it.card } + val boldness = profile.style.boldness + // Bold seats risk leaving five or twenty-one; careful ones really avoid it. + val countPenalty = 3.0 - boldness * 1.5 + val leadFivePenalty = 4.0 - boldness * 2.0 + val hard = profile.level == AiLevel.HARD + // Cards a reply could come from: nothing this seat holds, has seen played or was cut. + val unseen = if (hard) { + val known = (state.remaining(seat) + state.played.map { it.card } + listOfNotNull(state.starter)).toSet() + Decks.standard52().filter { it !in known && it !in state.hands[seat] } + } else { + emptyList() + } - return choices.maxByOrNull { choice -> - val scored = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points } + val scored = choices.map { choice -> + val points = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points } val after = count + pipValue(choice.card) - var value = scored * 10.0 + var value = points * 10.0 // Five and twenty-one are the two counts to avoid leaving: any of // the sixteen ten-cards takes fifteen or thirty-one off them. - if (after == 5 || after == 21) value -= 3.0 + if (after == 5 || after == 21) value -= countPenalty // Leading a five is the same present, offered first. - if (count == 0 && choice.card.rank == Rank.FIVE) value -= 4.0 + if (count == 0 && choice.card.rank == Rank.FIVE) value -= leadFivePenalty // Big cards early. Low ones are what let you keep playing at the // awkward end of a series, where the last card is worth a point. - value + pipValue(choice.card) * 0.1 - } ?: choices.first() + value += pipValue(choice.card) * 0.1 + if (hard && unseen.isNotEmpty() && after < CRIBBAGE_LIMIT) { + value -= replyThreat(onTheTable + choice.card, after, unseen) * 8.0 + } + choice to value + } + val ranked = scored.sortedByDescending { it.second }.map { it.first } + return profile.pick(ranked) + } + + /** Average points a reply scores off [series], over the cards still unseen. */ + private fun replyThreat(series: List, after: Int, unseen: List): Double { + var total = 0 + var playable = 0 + for (card in unseen) { + if (after + pipValue(card) > CRIBBAGE_LIMIT) continue + playable++ + total += CribbageScoring.peg(series + card).sumOf { it.points } + } + // Nothing playable is a go for them, worth a point to whoever last played. + return if (playable == 0) 0.0 else total.toDouble() / unseen.size + } + + private companion object { + const val CRIB_SIZE = 4 + const val CRIB_SAMPLES = 60 } } diff --git a/app/src/main/java/org/prolibertate/games/game/engine/AiProfile.kt b/app/src/main/java/org/prolibertate/games/game/engine/AiProfile.kt new file mode 100644 index 0000000..a601609 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/engine/AiProfile.kt @@ -0,0 +1,123 @@ +package org.prolibertate.games.game.engine + +import kotlinx.serialization.Serializable +import kotlin.random.Random + +/** How well one computer seat plays. */ +enum class AiLevel { + /** Makes frequent, plausible mistakes and plays without much foresight. */ + EASY, + + /** The game's standard computer opponent. */ + MEDIUM, + + /** Plays carefully: more foresight, no deliberate mistakes. */ + HARD, +} + +/** What the person at setup chooses for the table's computer seats. */ +@Serializable +enum class AiDifficulty(val label: String) { + EASY("Easy"), + MEDIUM("Medium"), + HARD("Hard"), + + /** Each computer seat gets its own level, picked at random. */ + MIXED("Mixed"), +} + +/** + * A computer seat's temperament. Never shown to the human players: it only + * shapes how the AI trades off the options in front of it. + */ +enum class AiStyle( + /** -1 plays safe, +1 gambles; 0 is the game's standard balance. */ + val boldness: Double, + /** How much the seat cares about stopping opponents rather than building its own position. */ + val spite: Double, +) { + BALANCED(0.0, 0.0), + AGGRESSIVE(0.8, 0.3), + CAUTIOUS(-0.8, 0.0), + SPOILER(0.0, 0.8), +} + +/** + * One computer seat's level and style, and a source of randomness of its own. + * + * [DEFAULT] is the game's standard opponent: [AiLevel.MEDIUM], [AiStyle.BALANCED], + * and it never slips, so an AI built without a profile plays exactly as it did + * before profiles existed. + */ +class AiProfile( + val level: AiLevel = AiLevel.MEDIUM, + val style: AiStyle = AiStyle.BALANCED, + val random: Random = Random(0), +) { + /** + * Whether this seat should throw away its best option this time. Only + * [AiLevel.EASY] does, and only some of the time, so it still plays like a + * person who is not concentrating rather than like a random-move generator. + */ + fun slips(): Boolean = level == AiLevel.EASY && random.nextDouble() < EASY_SLIP_CHANCE + + /** + * Picks from [ranked] (best first). Easy sometimes settles for a worse choice, + * drawn from the better half so it stays plausible; other levels take the best. + */ + fun pick(ranked: List): T { + require(ranked.isNotEmpty()) { "Nothing to pick from" } + if (!slips() || ranked.size == 1) return ranked.first() + val pool = ranked.take(maxOf(2, (ranked.size + 1) / 2)) + return pool[random.nextInt(pool.size)] + } + + /** [base] nudged by this seat's boldness: a gambler raises it, a cautious seat lowers it. */ + fun bolder(base: Double, strength: Double = 1.0): Double = base + style.boldness * strength + + companion object { + const val EASY_SLIP_CHANCE = 0.3 + val DEFAULT = AiProfile() + } +} + +/** + * The profile for every computer seat at a table, worked out from the table's + * difficulty setting and its seed alone. + * + * Derived rather than stored, so a saved game, a rematch from the same config + * and a guest's copy of a hosted table all give the same seat the same + * personality without it travelling anywhere. + */ +class AiProfiles( + private val difficulty: AiDifficulty, + private val seed: Long, +) { + private val cache = HashMap() + + constructor(config: TableConfig) : this(config.aiDifficulty, config.seed) + + @Synchronized + fun of(seat: Int): AiProfile = cache.getOrPut(seat) { + val picker = Random(seed * 31 + seat * 7919L + 17) + val level = when (difficulty) { + AiDifficulty.EASY -> AiLevel.EASY + AiDifficulty.MEDIUM -> AiLevel.MEDIUM + AiDifficulty.HARD -> AiLevel.HARD + AiDifficulty.MIXED -> AiLevel.entries[picker.nextInt(AiLevel.entries.size)] + } + // The standard opponent is balanced by definition; the others each get a + // temperament, so no two computer seats feel alike. + val style = if (difficulty == AiDifficulty.MEDIUM) { + AiStyle.BALANCED + } else { + AiStyle.entries[picker.nextInt(AiStyle.entries.size)] + } + AiProfile(level, style, Random(picker.nextLong())) + } + + companion object { + /** Standard opponents, for a caller that has no table to ask. */ + val DEFAULT = AiProfiles(AiDifficulty.MEDIUM, 0L) + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt index ae7b456..60670f3 100644 --- a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt +++ b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt @@ -43,6 +43,8 @@ data class TableConfig( val optionsJson: String, /** Shared shuffle seed so a hand is reproducible from its state. */ val seed: Long, + /** How hard the computer seats play. Old saves and old hosts omit it and get the standard opponent. */ + val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM, ) /** diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt index 52cd6da..24dee40 100644 --- a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt @@ -3,6 +3,10 @@ package org.prolibertate.games.game.euchre import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.cards.Rank import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -13,7 +17,7 @@ import org.prolibertate.games.game.engine.GameAi * That is enough to be a reasonable table opponent without pretending to be a * solver. */ -class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : +class EuchreAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi { /** @@ -22,25 +26,50 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : * means every seat bids and the card is never turned down — see * EuchreBiddingRateTest, which pins the resulting order-up rate. */ - enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) { - /** Bids loosely and plays straightforwardly. */ - EASY(bidThreshold = 21, aloneThreshold = 32), + private companion object { + const val BID_THRESHOLD = 24.0 + const val ALONE_THRESHOLD = 32.0 + } - NORMAL(bidThreshold = 24, aloneThreshold = 32), + /** Cards this AI has watched played in the current hand, per seat it plays. */ + private val memory = HashMap() - /** Bids tighter and is harder to sneak a trick past. */ - CAUTIOUS(bidThreshold = 27, aloneThreshold = 31), + private class SeenCards { + var key: Long = Long.MIN_VALUE + val cards = HashSet() + } + + /** Public information only: cards that have appeared on the table this hand. */ + private fun observe(state: EuchreState, seat: Int): Set = synchronized(memory) { + val seen = memory.getOrPut(seat) { SeenCards() } + val key = state.seed * 1_000_003L + state.handNumber + if (key != seen.key || state.phase == EuchrePhase.BID_ROUND_1) { + seen.key = key + seen.cards.clear() + } + state.trick.forEach { seen.cards.add(it.card) } + state.completedTrick.forEach { seen.cards.add(it.card) } + state.turnedDown?.let { seen.cards.add(it) } + seen.cards.removeAll(state.hands[seat].toSet()) + seen.cards.toSet() } override fun chooseMove(state: EuchreState, seat: Int, legal: List): EuchreMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } - return when (state.phase) { - EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal) - EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal) - EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal) - EuchrePhase.PLAYING -> choosePlay(state, seat, legal) - else -> legal.first() + val profile = profiles.of(seat) + val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet() + val move = try { + when (state.phase) { + EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal, profile) + EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal, profile) + EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal, profile) + EuchrePhase.PLAYING -> choosePlay(state, seat, legal, profile, seen) + else -> legal.first() + } + } catch (e: RuntimeException) { + legal.first() } + return if (move in legal) move else legal.first() } // ----------------------------------------------------------------------- @@ -75,27 +104,61 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : return value } + /** The five-card hand left after the dealer picks up [upCard] and discards the least useful card. */ + private fun bestAfterPickup(hand: List, upCard: Card, trump: Suit): Int { + val six = hand + upCard + return six.maxOf { dropped -> handValue(six - dropped, trump) } + } + + /** A hand worth playing without a partner: real trump length with a bower to lead it. */ + private fun soundAlone(hand: List, trump: Suit): Boolean { + val trumps = hand.count { isTrumpCard(it, trump) } + val bower = hand.any { isRightBower(it, trump) || isLeftBower(it, trump) } + return trumps >= 3 && bower + } + + /** Easy misjudges how good its hand is. */ + private fun misjudge(value: Int, profile: AiProfile): Int = + if (profile.level == AiLevel.EASY) value + profile.random.nextInt(-5, 6) else value + private fun chooseBidRound1( state: EuchreState, seat: Int, legal: List, + profile: AiProfile, ): EuchreMove { val upCard = state.upCard ?: return Pass val trump = upCard.suit - var hand = state.hands[seat] + val isDealer = seat == state.dealer + val partnerDealer = partnerOf(seat) == state.dealer + val hard = profile.level == AiLevel.HARD // If our partner is the dealer they gain the turn card, and if we are // the dealer we gain it ourselves — both make the hand stronger than it // looks. Ordering up into an opponent dealer instead hands them a card. - if (seat == state.dealer) hand = hand + upCard - var value = handValue(hand, trump) - if (partnerOf(seat) == state.dealer) value += 2 - if (seat != state.dealer && partnerOf(seat) != state.dealer) value -= 2 + val owned = if (isDealer) state.hands[seat] + upCard else state.hands[seat] + var value = when { + isDealer && hard -> bestAfterPickup(state.hands[seat], upCard, trump) + 3 + else -> handValue(owned, trump) + } + if (partnerDealer) value += 2 + if (!isDealer && !partnerDealer) { + value -= 2 + // An opposing dealer who picks up a jack gets a bower out of our bid. + if (hard && upCard.rank == Rank.JACK) value -= 2 + } + value = misjudge(value, profile) + + val bidThreshold = profile.bolder(BID_THRESHOLD, -3.0) + var aloneThreshold = profile.bolder(ALONE_THRESHOLD, -3.0) + if (profile.level == AiLevel.EASY) aloneThreshold += 4.0 val alone = OrderUp(alone = true) - if (value >= difficulty.aloneThreshold && legal.contains(alone)) return alone + if (value >= aloneThreshold && legal.contains(alone) && (!hard || soundAlone(owned, trump))) { + return alone + } val order = OrderUp(alone = false) - if (value >= difficulty.bidThreshold && legal.contains(order)) return order + if (value >= bidThreshold && legal.contains(order)) return order return if (legal.contains(Pass)) Pass else legal.first() } @@ -103,37 +166,64 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : state: EuchreState, seat: Int, legal: List, + profile: AiProfile, ): EuchreMove { val hand = state.hands[seat] val callable = legal.filterIsInstance() if (callable.isEmpty()) return legal.first() + val plain = callable.filter { !it.alone } - val best = callable - .filter { !it.alone } - .maxByOrNull { handValue(hand, it.suit) } - ?: callable.first() - val bestValue = handValue(hand, best.suit) + val best: CallTrump + val bestValue: Int + if (profile.level == AiLevel.EASY) { + val noisy = plain.associateWith { handValue(hand, it.suit) + profile.random.nextInt(-5, 6) } + val ranked = plain.sortedByDescending { noisy.getValue(it) } + best = if (ranked.isEmpty()) callable.first() else profile.pick(ranked) + bestValue = noisy[best] ?: handValue(hand, best.suit) + } else { + best = plain.maxByOrNull { handValue(hand, it.suit) } ?: callable.first() + bestValue = handValue(hand, best.suit) + } + + val bidThreshold = profile.bolder(BID_THRESHOLD, -3.0) + var aloneThreshold = profile.bolder(ALONE_THRESHOLD, -3.0) + if (profile.level == AiLevel.EASY) aloneThreshold += 4.0 val alone = callable.firstOrNull { it.alone && it.suit == best.suit } - if (alone != null && bestValue >= difficulty.aloneThreshold) return alone - if (bestValue >= difficulty.bidThreshold) return best + val alonePlausible = profile.level != AiLevel.HARD || soundAlone(hand, best.suit) + if (alone != null && bestValue >= aloneThreshold && alonePlausible) return alone + if (bestValue >= bidThreshold) return best // Stuck as dealer with a bad hand: name the least-bad suit anyway. return if (legal.contains(Pass)) Pass else best } - private fun chooseDiscard(state: EuchreState, legal: List): EuchreMove { + private fun chooseDiscard( + state: EuchreState, + legal: List, + profile: AiProfile, + ): EuchreMove { val trump = requireNotNull(state.trump) val discards = legal.filterIsInstance() // Prefer to void a side suit entirely; otherwise throw the lowest card. val hand = state.hands[state.dealer] val suitCounts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount() - return discards.minByOrNull { discard -> + fun keepScore(discard: Discard): Int { val card = discard.card val suit = effectiveSuit(card, trump) val trumpPenalty = if (suit == trump) 1000 else 0 val singletonBonus = if (suitCounts[suit] == 1 && card.rank != Rank.ACE) -20 else 0 - trumpPenalty + singletonBonus + card.rank.order - } ?: discards.first() + return trumpPenalty + singletonBonus + card.rank.order + } + return when (profile.level) { + AiLevel.HARD -> discards + .sortedWith( + compareByDescending { handValue(hand - it.card, trump) } + .thenBy { keepScore(it) }, + ) + .first() + AiLevel.EASY -> profile.pick(discards.sortedBy { keepScore(it) }) + AiLevel.MEDIUM -> discards.minByOrNull { keepScore(it) } ?: discards.first() + } } // ----------------------------------------------------------------------- @@ -144,12 +234,40 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : state: EuchreState, seat: Int, legal: List, + profile: AiProfile, + seen: Set, ): EuchreMove { val trump = requireNotNull(state.trump) val cards = legal.filterIsInstance().map { it.card } if (cards.size == 1) return PlayCard(cards.first()) - if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, trump)) + val maker = state.maker + val defending = maker != null && teamOf(maker) != teamOf(seat) + val makerSide = maker != null && teamOf(maker) == teamOf(seat) + // A spoiler on the defence leads trump to cut the maker's trump down. + val spiteful = defending && profile.style.spite >= 0.5 + + val best = if (profile.level == AiLevel.HARD) { + hardPlay(state, seat, cards, trump, seen, makerSide, spiteful) + } else { + standardPlay(state, seat, cards, trump, spiteful, profile) + } + if (profile.level != AiLevel.EASY) return PlayCard(best) + + // Easy sometimes burns a high card it should have kept. + val ranked = listOf(best) + (cards - best).sortedByDescending { trumpStrength(it, trump) } + return PlayCard(profile.pick(ranked)) + } + + private fun standardPlay( + state: EuchreState, + seat: Int, + cards: List, + trump: Suit, + spiteful: Boolean, + profile: AiProfile, + ): Card { + if (state.trick.isEmpty()) return chooseLead(cards, trump, spiteful) val ledSuit = effectiveSuit(state.trick.first().card, trump) val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) } @@ -157,22 +275,120 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : val lastToPlay = state.trick.size == state.activeSeatCount - 1 // Partner already has it and we are last: never waste a good card. - if (partnerWinning && lastToPlay) return PlayCard(lowest(cards, trump, ledSuit)) + // (Easy sometimes forgets and overtakes anyway.) + if (partnerWinning && lastToPlay && !profile.slips()) { + return lowest(cards, trump, ledSuit) + } val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit) val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength } return when { // Take it as cheaply as we can. - winners.isNotEmpty() && (!partnerWinning || !lastToPlay) -> - PlayCard(winners.minBy { trickStrength(it, trump, ledSuit) }) + winners.isNotEmpty() && (!partnerWinning || !lastToPlay || profile.level == AiLevel.EASY) -> + winners.minBy { trickStrength(it, trump, ledSuit) } - else -> PlayCard(lowest(cards, trump, ledSuit)) + else -> lowest(cards, trump, ledSuit) } } - private fun chooseLead(cards: List, trump: Suit): Card { + /** + * The strong player's choice. It remembers which cards have gone, so it + * knows when a card is the best one left, and it lets a partner who is + * already safe take the trick. + */ + private fun hardPlay( + state: EuchreState, + seat: Int, + cards: List, + trump: Suit, + seen: Set, + makerSide: Boolean, + spiteful: Boolean, + ): Card { + val hand = state.hands[seat] + // Cards that could still be in somebody else's hand. + val unseen = Decks.euchre(state.options.deckSize).filter { it !in seen && it !in hand } + val unseenTrump = unseen.count { isTrumpCard(it, trump) } + + /** No unseen card of its suit beats it; trump is ignored for a side-suit card. */ + fun isBoss(card: Card): Boolean { + val led = effectiveSuit(card, trump) + val mine = trickStrength(card, trump, led) + val cardIsTrump = isTrumpCard(card, trump) + return unseen.none { other -> + (cardIsTrump || !isTrumpCard(other, trump)) && trickStrength(other, trump, led) > mine + } + } + + if (state.trick.isEmpty()) { + val myTrump = cards.filter { isTrumpCard(it, trump) } + val off = cards.filter { !isTrumpCard(it, trump) } + val bossTrump = myTrump.filter { isBoss(it) }.maxByOrNull { trumpStrength(it, trump) } + if (unseenTrump > 0 && bossTrump != null && (makerSide || myTrump.size >= 2)) return bossTrump + if (unseenTrump > 0 && makerSide && myTrump.size >= 3) { + return myTrump.maxBy { trumpStrength(it, trump) } + } + if (unseenTrump > 0 && spiteful && myTrump.size >= 2) { + return myTrump.maxBy { trumpStrength(it, trump) } + } + val bossOff = off.filter { isBoss(it) }.maxByOrNull { it.rank.order } + if (bossOff != null) return bossOff + if (bossTrump != null) return bossTrump + if (off.isEmpty()) return cards.maxBy { trumpStrength(it, trump) } + if (makerSide) return chooseLead(cards, trump, false) + // On the defence with nothing sure: lead low from the shortest suit, + // which keeps the aces and sets up a ruff. + val counts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount() + return off.minWith( + compareBy({ counts[effectiveSuit(it, trump)] ?: 0 }, { it.rank.order }), + ) + } + + val ledSuit = effectiveSuit(state.trick.first().card, trump) + val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) } + val partnerWinning = partnerOf(bestSoFar.seat) == seat + val remaining = state.activeSeatCount - state.trick.size - 1 + val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit) + val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength } + + fun safe(card: Card): Boolean = + remaining == 0 || + unseen.none { trickStrength(it, trump, ledSuit) > trickStrength(card, trump, ledSuit) } + + fun throwAway(): Card { + // A card that neither follows nor trumps costs nothing; shed the + // lowest one that is not a sure winner later. + val junk = cards.filter { trickStrength(it, trump, ledSuit) == 0 } + if (junk.isNotEmpty()) { + return junk.minBy { it.rank.order + if (isBoss(it)) 100 else 0 } + } + return lowest(cards, trump, ledSuit) + } + + if (partnerWinning) { + val partnerSecure = remaining == 0 || + unseen.none { trickStrength(it, trump, ledSuit) > bestStrength } + if (partnerSecure) return throwAway() + // Partner may yet be beaten: back them with a side-suit card, but do + // not spend trump on top of their lead. + val backing = winners.filter { !isTrumpCard(it, trump) } + .minByOrNull { trickStrength(it, trump, ledSuit) } + return backing ?: throwAway() + } + if (winners.isNotEmpty()) { + val sure = winners.filter { safe(it) } + return (sure.ifEmpty { winners }).minBy { trickStrength(it, trump, ledSuit) } + } + return throwAway() + } + + private fun chooseLead(cards: List, trump: Suit, spiteTrump: Boolean): Card { // Right bower up front pulls trump and protects the left. cards.firstOrNull { isRightBower(it, trump) }?.let { return it } + if (spiteTrump) { + val trumps = cards.filter { isTrumpCard(it, trump) } + if (trumps.size >= 2) return trumps.maxBy { trumpStrength(it, trump) } + } // Off-suit aces are most likely to survive early. cards.filter { !isTrumpCard(it, trump) && it.rank == Rank.ACE } .maxByOrNull { it.rank.order } diff --git a/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt b/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt index 02cf85e..b9d5c63 100644 --- a/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/golf/GolfAi.kt @@ -1,49 +1,78 @@ package org.prolibertate.games.game.golf +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** - * A heuristic Golf opponent. + * A Golf opponent whose strength and temperament come from the seat's profile. * - * It takes a card off the discard pile when it is cheap or completes a column, - * replaces its worst known card, and turns cards over when it has nothing - * better to do. It never peeks at a face-down card — including its own, which - * it genuinely does not know. + * MEDIUM (the default) takes a card off the discard pile when it is cheap or + * completes a column, replaces its worst known card, and turns cards over when + * it has nothing better to do. It never peeks at a face-down card — including + * its own, which it genuinely does not know. + * + * EASY is pickier about the discard pile, closes the hole from a poor position + * and sometimes makes a worse play than it saw. HARD keeps count of every card + * it has seen (turned-over grids and the discard pile), values its unknown + * cards at what is still unseen, and chooses each draw and placement by how + * much it lowers its expected score, including the chance of completing a + * column and whether closing the hole would leave it ahead. */ -class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) : +class GolfAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi { - enum class Difficulty( + /** The tuning the standard plan runs on. */ + private class Params( /** Take a discard worth this or less rather than gambling on the stock. */ val discardAppetite: Int, /** Only close the hole out when the visible score is at or under this. */ val closeOutBelow: Int, - ) { - EASY(discardAppetite = 2, closeOutBelow = 30), - NORMAL(discardAppetite = 5, closeOutBelow = 16), - CAUTIOUS(discardAppetite = 7, closeOutBelow = 12), - } + ) override fun chooseMove(state: GolfState, seat: Int, legal: List): GolfMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } - return when (state.phase) { - GolfPhase.SETUP -> chooseReveal(state, seat, legal) - GolfPhase.DRAW -> chooseDraw(state, seat, legal) - GolfPhase.PLACE -> choosePlace(state, seat, legal) + val profile = profiles.of(seat) + val move = when (state.phase) { + GolfPhase.SETUP -> chooseReveal(state, seat, legal, profile) + GolfPhase.DRAW -> chooseDraw(state, seat, legal, profile) + GolfPhase.PLACE -> choosePlace(state, seat, legal, profile) else -> legal.first() } + return if (move in legal) move else legal.first() } + private fun paramsFor(level: AiLevel): Params = when (level) { + AiLevel.EASY -> EASY + else -> NORMAL + } + + // ----------------------------------------------------------------------- + // Setup + // ----------------------------------------------------------------------- + /** * Opening reveals are spread across columns. Two cards in the same column * tell you whether that column can be cancelled but nothing about the rest * of the board; one in each column leaves more of the grid legible. */ - private fun chooseReveal(state: GolfState, seat: Int, legal: List): GolfMove { + private fun chooseReveal( + state: GolfState, + seat: Int, + legal: List, + profile: AiProfile, + ): GolfMove { val reveals = legal.filterIsInstance() if (reveals.isEmpty()) return legal.first() val options = state.options + // A distracted player turns over whatever is nearest. + if (profile.slips()) return reveals[profile.random.nextInt(reveals.size)] + val untouchedColumn = reveals.firstOrNull { move -> val column = move.index % options.cols (0 until options.rows).none { row -> @@ -53,17 +82,61 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) : return untouchedColumn ?: reveals.first() } - private fun chooseDraw(state: GolfState, seat: Int, legal: List): GolfMove { + // ----------------------------------------------------------------------- + // Drawing + // ----------------------------------------------------------------------- + + private fun chooseDraw( + state: GolfState, + seat: Int, + legal: List, + profile: AiProfile, + ): GolfMove { + if (profile.level == AiLevel.HARD) return hardDraw(state, seat, legal, profile) + + val params = paramsFor(profile.level) val top = state.discard.lastOrNull() + var take = false if (top != null && legal.contains(DrawFromDiscard)) { - val worthTaking = golfValue(top) <= difficulty.discardAppetite || + take = golfValue(top) <= params.discardAppetite || completesColumn(state, seat, top) != null - if (worthTaking) return DrawFromDiscard } + // Easy sometimes flips its own decision. + if (profile.slips() && legal.contains(DrawFromDiscard) && legal.contains(DrawFromStock)) take = !take + if (take && legal.contains(DrawFromDiscard)) return DrawFromDiscard return if (legal.contains(DrawFromStock)) DrawFromStock else legal.first() } - private fun choosePlace(state: GolfState, seat: Int, legal: List): GolfMove { + // ----------------------------------------------------------------------- + // Placing + // ----------------------------------------------------------------------- + + private fun choosePlace( + state: GolfState, + seat: Int, + legal: List, + profile: AiProfile, + ): GolfMove { + if (profile.level == AiLevel.HARD) return hardPlace(state, seat, legal, profile) + + val best = standardPlace(state, seat, legal, paramsFor(profile.level)) + if (profile.level != AiLevel.EASY || !profile.slips()) return best + + // A lapse: turn something over or cover a face-down card instead. + val alternatives = buildList { + addAll(legal.filterIsInstance()) + addAll(legal.filterIsInstance().filter { !state.revealed[seat][it.index] }) + }.filter { it != best } + if (alternatives.isEmpty()) return best + return alternatives[profile.random.nextInt(alternatives.size)] + } + + private fun standardPlace( + state: GolfState, + seat: Int, + legal: List, + params: Params, + ): GolfMove { val drawn = state.drawn ?: return legal.first() val replacements = legal.filterIsInstance() val flips = legal.filterIsInstance() @@ -90,7 +163,7 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) : val showing = state.grids[seat].indices .filter { state.revealed[seat][it] } .sumOf { golfValue(state.grids[seat][it]) } - if (showing > difficulty.closeOutBelow) return DiscardOnly + if (showing > params.closeOutBelow) return DiscardOnly } // Nothing worth swapping: throw it and turn something over instead, @@ -106,7 +179,7 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) : private fun completesColumn( state: GolfState, seat: Int, - card: org.prolibertate.games.game.cards.Card, + card: Card, ): Int? { val options = state.options for (col in 0 until options.cols) { @@ -122,4 +195,229 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) : } return null } + + // ----------------------------------------------------------------------- + // Hard + // ----------------------------------------------------------------------- + + /** + * What this seat knows about the hole: the cards in every turned-over cell, + * the discard pile and (on its own turn) the card in its hand. Everything + * else — face-down cells and the stock — is valued at the average of the + * cards not yet seen. + */ + private class Board(private val state: GolfState, seat: Int) { + val options = state.options + private val counts = HashMap() + private val unseenTotal: Int + val unknownValue: Double + private val lines: List> + + init { + val copies = if (options.playerCount * options.gridSize > 24) 2 else 1 + Rank.standard.forEach { counts[it] = 4 * copies } + fun see(card: Card) { + counts[card.rank] = maxOf(0, (counts[card.rank] ?: 0) - 1) + } + state.grids.forEachIndexed { player, grid -> + grid.forEachIndexed { index, card -> if (state.revealed[player][index]) see(card) } + } + state.discard.forEach { see(it) } + if (seat == state.turn) state.drawn?.let { see(it) } + + unseenTotal = counts.values.sum() + unknownValue = if (unseenTotal == 0) { + 5.0 + } else { + counts.entries.sumOf { (rank, n) -> n * valueOf(rank).toDouble() } / unseenTotal + } + + val columns = (0 until options.cols).map { col -> (0 until options.rows).map { it * options.cols + col } } + val rows = if (options.rows == 3) { + (0 until options.rows).map { row -> (0 until options.cols).map { row * options.cols + it } } + } else { + emptyList() + } + lines = columns + rows + } + + fun valueOf(rank: Rank): Int = golfValue(Card(rank, Suit.SPADES)) + + private fun chanceOf(rank: Rank): Double = + if (unseenTotal == 0) 0.0 else (counts[rank] ?: 0).toDouble() / unseenTotal + + /** A seat's cells as far as this seat can see them; null is a face-down card. */ + fun cellsOf(player: Int): List = + state.grids[player].mapIndexed { index, card -> if (state.revealed[player][index]) card else null } + + /** Expected score of a grid, counting the chance that an unknown cell completes a line. */ + fun expected(cells: List): Double { + val cancelled = BooleanArray(cells.size) + for (line in lines) { + val line0 = line.map { cells[it] } + if (line0.all { it != null } && line0.map { it!!.rank }.distinct().size == 1) { + line.forEach { cancelled[it] = true } + } + } + var total = 0.0 + for (index in cells.indices) { + if (cancelled[index]) continue + val card = cells[index] + total += if (card != null) golfValue(card).toDouble() else unknownValue + } + for (line in lines) { + if (line.any { cancelled[it] }) continue + val unknown = line.filter { cells[it] == null } + if (unknown.size != 1) continue + val known = line.mapNotNull { cells[it] } + if (known.isEmpty() || known.map { it.rank }.distinct().size != 1) continue + val rank = known.first().rank + total -= chanceOf(rank) * (known.sumOf { golfValue(it) } + valueOf(rank)) + } + return total + } + + /** Unseen ranks and how many of each remain, for averaging over a stock draw. */ + fun unseenRanks(): List> = counts.entries.filter { it.value > 0 }.map { it.key to it.value } + + /** How likely turning over [index] is to finish a line that already shows one rank. */ + fun matchHope(cells: List, index: Int): Double { + var best = 0.0 + for (line in lines) { + if (index !in line) continue + val others = line.filter { it != index } + val known = others.mapNotNull { cells[it] } + if (known.size != others.size || known.isEmpty()) continue + if (known.map { it.rank }.distinct().size != 1) continue + val rank = known.first().rank + best = maxOf(best, chanceOf(rank) * (known.sumOf { golfValue(it) } + valueOf(rank))) + } + return best + } + } + + private fun effectiveBoldness(state: GolfState, seat: Int, profile: AiProfile): Double { + // Well behind late in the match: take more chances. + val holesLeft = state.options.holes - state.hole - 1 + val best = (0 until state.options.playerCount).filter { it != seat }.minOf { state.scores[it] } + val behind = state.scores[seat] - best + return profile.style.boldness + if (holesLeft <= 2 && behind > 8) 0.3 else 0.0 + } + + /** Scores one way of using the card in hand: lower grid score, more information, less risk. */ + private class Planner( + val state: GolfState, + val seat: Int, + val board: Board, + val boldness: Double, + val spite: Double, + ) { + private val cells = board.cellsOf(seat) + private val before = board.expected(cells) + private val hidden = cells.count { it == null } + private val finalTurn = state.closedBy != null + private val rivalBest: Double = (0 until state.options.playerCount) + .filter { it != seat } + .minOfOrNull { board.expected(board.cellsOf(it)) } ?: before + + private fun closePenalty(after: Double): Double { + if (finalTurn) return 0.0 + val margin = 2.0 - 2.0 * boldness + return maxOf(0.0, after - (rivalBest - margin)) * 0.8 + } + + private fun handoff(drawn: Card): Double = + maxOf(0.0, 3.0 - golfValue(drawn)) * 0.15 * (1.0 + spite) + + private fun flipBonus(index: Int): Double = + if (finalTurn || hidden <= 1) 0.0 else 0.35 + 0.5 * board.matchHope(cells, index) + + /** Utility of [move] with [drawn] in hand; higher is better. */ + fun utility(move: GolfMove, drawn: Card): Double = when (move) { + is ReplaceCard -> { + val after = cells.toMutableList().also { it[move.index] = drawn } + val closes = hidden == 1 && cells[move.index] == null + val afterScore = board.expected(after) + before - afterScore - if (closes) closePenalty(afterScore) else 0.0 + } + + is DiscardAndFlip -> { + val closes = hidden == 1 + flipBonus(move.index) - handoff(drawn) - if (closes) closePenalty(before) else 0.0 + } + + is DiscardOnly -> -handoff(drawn) + else -> Double.NEGATIVE_INFINITY + } + + /** The moves open to a card in hand, standing in for the legal list during look-ahead. */ + fun candidates(fromDiscard: Boolean, putt: Boolean): List = buildList { + for (index in cells.indices) add(ReplaceCard(index)) + if (!fromDiscard) { + for (index in cells.indices) if (cells[index] == null) add(DiscardAndFlip(index)) + if (putt && hidden == 1) add(DiscardOnly) + } + } + } + + private fun planner(state: GolfState, seat: Int, profile: AiProfile): Planner { + val board = Board(state, seat) + return Planner(state, seat, board, effectiveBoldness(state, seat, profile), profile.style.spite) + } + + private fun hardDraw( + state: GolfState, + seat: Int, + legal: List, + profile: AiProfile, + ): GolfMove { + val top = state.discard.lastOrNull() + val canStock = legal.contains(DrawFromStock) + val canDiscard = legal.contains(DrawFromDiscard) && top != null + if (!canDiscard) return if (canStock) DrawFromStock else legal.first() + if (!canStock) return DrawFromDiscard + + val plan = planner(state, seat, profile) + val putt = state.options.lineUpFinalPutt + + // The discard has to be used, so it is only as good as its best replacement. + val takeUtility = plan.candidates(fromDiscard = true, putt = putt) + .maxOf { plan.utility(it, top!!) } + + profile.style.spite * 0.3 * maxOf(0, 4 - golfValue(top!!)) + + // The stock is a gamble on the average of what is left, but can be thrown back. + val ranks = plan.board.unseenRanks() + val total = ranks.sumOf { it.second } + val stockUtility = if (total == 0) { + Double.NEGATIVE_INFINITY + } else { + val moves = plan.candidates(fromDiscard = false, putt = putt) + ranks.sumOf { (rank, n) -> + val sample = Card(rank, Suit.SPADES) + n * moves.maxOf { plan.utility(it, sample) } + } / total + } + + // A bold seat wants a clear edge before it passes up the unknown card. + val take = takeUtility - plan.boldness * 0.4 > stockUtility + return if (take) DrawFromDiscard else DrawFromStock + } + + private fun hardPlace( + state: GolfState, + seat: Int, + legal: List, + profile: AiProfile, + ): GolfMove { + val drawn = state.drawn ?: return legal.first() + val plan = planner(state, seat, profile) + val options = legal.filter { it is ReplaceCard || it is DiscardAndFlip || it is DiscardOnly } + if (options.isEmpty()) return legal.first() + return options.maxBy { plan.utility(it, drawn) } + } + + private companion object { + private val EASY = Params(discardAppetite = 2, closeOutBelow = 30) + private val NORMAL = Params(discardAppetite = 5, closeOutBelow = 16) + } } diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt index a9e1ca9..d1c7330 100644 --- a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt @@ -3,35 +3,98 @@ package org.prolibertate.games.game.hearts import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.cards.Rank import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** - * A cautious Hearts player. + * A Hearts player whose strength and temperament come from the seat's profile. * - * It plays to avoid points rather than to shoot the moon: it sheds the queen of - * spades and the guards above her at the first opportunity, ducks under whoever - * is winning a trick when it can, and takes a trick as cheaply as possible when - * it cannot get out of the way. That is enough to make it an opponent worth - * beating without pretending to read the table. + * MEDIUM (the default) avoids points rather than shooting the moon: it sheds + * the queen of spades and the guards above her at the first opportunity, ducks + * under whoever is winning a trick when it can, and takes a trick as cheaply as + * possible when it cannot get out of the way. + * + * EASY plays the same plan but sometimes settles for a worse card in each + * decision. HARD counts the penalty cards and spades that have been taken, + * leads by risk, sheds by what is still dangerous, shoots the moon when its + * hand and the table allow it and tries to break a moon somebody else is on. + * It only uses its own hand and what the table has shown. */ -object HeartsAi : GameAi { +class HeartsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi { override fun chooseMove(state: HeartsState, seat: Int, legal: List): HeartsMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } - return when (state.phase) { - HeartsPhase.PASSING -> choosePass(state, seat, legal) - else -> choosePlay(state, seat, legal) + val profile = profiles.of(seat) + val move = when (state.phase) { + HeartsPhase.PASSING -> choosePass(state, seat, legal, profile) + else -> choosePlay(state, seat, legal, profile) } + return if (move in legal) move else legal.first() } - private fun choosePass(state: HeartsState, seat: Int, legal: List): HeartsMove { + // ----------------------------------------------------------------------- + // Passing + // ----------------------------------------------------------------------- + + private fun choosePass( + state: HeartsState, + seat: Int, + legal: List, + profile: AiProfile, + ): HeartsMove { val hand = state.hands[seat] - val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet() + val wanted: Set = when (profile.level) { + AiLevel.MEDIUM -> hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet() + AiLevel.EASY -> easyPass(hand, profile) + AiLevel.HARD -> hardPass(state, hand, profile) + } // Prefer the exact bundle the heuristic asked for, but fall back rather // than fail: the caller's list is what the host will validate against. return legal.firstOrNull { it is PassCards && it.cards.toSet() == wanted } ?: legal.first() } + private fun easyPass(hand: List, profile: AiProfile): Set { + val ranked = hand.sortedByDescending { passDanger(it, hand) } + val chosen = ranked.take(PASS_SIZE).toMutableList() + if (profile.slips()) { + // Keeps one of the cards it should have sent. + val spare = ranked.drop(PASS_SIZE).take(4) + if (spare.isNotEmpty()) { + chosen[profile.random.nextInt(chosen.size)] = spare[profile.random.nextInt(spare.size)] + } + } + return chosen.toSet() + } + + private fun hardPass(state: HeartsState, hand: List, profile: AiProfile): Set { + if (state.options.allowShootTheMoon && moonHand(hand, profile)) { + // Keep every high card and heart, send the three smallest. + val junk = hand.filter { !(it.suit == Suit.HEARTS && it.rank.order >= 10) } + .sortedBy { it.rank.order } + if (junk.size >= PASS_SIZE) return junk.take(PASS_SIZE).toSet() + } + return hand.sortedByDescending { passDanger(it, hand) + shortSuitBonus(it, hand) } + .take(PASS_SIZE).toSet() + } + + /** Sending away the last cards of a short suit makes a void, which is worth a lot when discarding. */ + private fun shortSuitBonus(card: Card, hand: List): Int { + if (card.suit == Suit.HEARTS || card.suit == Suit.SPADES) return 0 + val length = hand.count { it.suit == card.suit } + return if (length > 3) 0 else (4 - length) * 8 + } + + /** A hand worth trying to take all twenty-six with: lots of high cards and a heart suit to run. */ + private fun moonHand(hand: List, profile: AiProfile): Boolean { + val hearts = hand.count { it.suit == Suit.HEARTS } + if (hearts < 4) return false + val strength = hand.count { it.rank.order >= Rank.QUEEN.order } + + hand.count { it.suit == Suit.HEARTS && it.rank.order >= 9 } + return strength >= profile.bolder(8.0, -2.0) + } + /** * How much a card wants to be somebody else's problem. * @@ -52,27 +115,41 @@ object HeartsAi : GameAi { } } - private fun choosePlay(state: HeartsState, seat: Int, legal: List): HeartsMove { + // ----------------------------------------------------------------------- + // Playing + // ----------------------------------------------------------------------- + + private fun choosePlay( + state: HeartsState, + seat: Int, + legal: List, + profile: AiProfile, + ): HeartsMove { val cards = legal.filterIsInstance().map { it.card } if (cards.isEmpty()) return legal.first() - val chosen = when { - state.trick.isEmpty() -> lead(cards) - else -> follow(state, cards) + val chosen = when (profile.level) { + AiLevel.MEDIUM -> standardRanking(state, cards).first() + AiLevel.EASY -> profile.pick(standardRanking(state, cards)) + AiLevel.HARD -> hardPlay(state, seat, cards, profile) } - return PlayCard(chosen) + return PlayCard(if (chosen in cards) chosen else cards.first()) } + /** The standard plan's preferences, best first. */ + private fun standardRanking(state: HeartsState, cards: List): List = + if (state.trick.isEmpty()) rankedLead(cards) else rankedFollow(state, cards) + /** * Leads low, and keeps off spades while the queen is still unaccounted for — * leading a spade under her is how a seat ends up holding her. */ - private fun lead(cards: List): Card { - val offSpades = cards.filter { it.suit != Suit.SPADES } - val pool = if (offSpades.isNotEmpty()) offSpades else cards - return pool.minBy { it.rank.order } + private fun rankedLead(cards: List): List { + val offSpades = cards.filter { it.suit != Suit.SPADES }.sortedBy { it.rank.order } + val spades = cards.filter { it.suit == Suit.SPADES }.sortedBy { it.rank.order } + return offSpades + spades } - private fun follow(state: HeartsState, cards: List): Card { + private fun rankedFollow(state: HeartsState, cards: List): List { val led = state.trick.first().card.suit val best = state.trick.maxOf { trickStrength(it.card, led) } val pot = pointsIn(state.trick.map { it.card }) @@ -82,27 +159,189 @@ object HeartsAi : GameAi { if (ducking.isNotEmpty()) { // Safe under the current winner, so shed the most expensive card // that still loses. - return ducking.maxBy { it.rank.order + pointsOf(it) * 2 } + val rest = (cards - ducking.toSet()).sortedBy { it.rank.order } + return ducking.sortedByDescending { it.rank.order + pointsOf(it) * 2 } + rest } - val void = cards.none { it.suit == led } - if (void) return discard(cards) - // Every card left would take the trick. Playing last with nothing in the // pot means it costs nothing, so there is no reason to waste a high card // on it; otherwise take it as cheaply as possible. - return if (last && pot == 0) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order } + return if (last && pot == 0) { + cards.sortedByDescending { it.rank.order } + } else { + cards.sortedBy { it.rank.order } + } } - /** Void in the led suit and free to throw anything, so throw the worst of it. */ - private fun discard(cards: List): Card { - cards.firstOrNull { it == QUEEN_OF_SPADES }?.let { return it } - cards.filter { it.suit == Suit.SPADES && it.rank.order > Rank.QUEEN.order } - .maxByOrNull { it.rank.order } - ?.let { return it } - cards.filter { it.suit == Suit.HEARTS } - .maxByOrNull { it.rank.order } - ?.let { return it } - return cards.maxBy { it.rank.order } + // ----------------------------------------------------------------------- + // Hard play + // ----------------------------------------------------------------------- + + /** What this seat can legitimately know: its hand, and every card taken or on the table. */ + private class Memory(state: HeartsState, seat: Int) { + val hand: List = state.hands[seat] + val unseen: List + + init { + val seen = HashSet() + state.taken.forEach { seen.addAll(it) } + state.trick.forEach { seen.add(it.card) } + seen.addAll(hand) + unseen = FULL_DECK.filter { it !in seen } + } + + val queenUnseen: Boolean get() = QUEEN_OF_SPADES in unseen + + fun unseenIn(suit: Suit): Int = unseen.count { it.suit == suit } + + fun unseenHigher(card: Card): Int = + unseen.count { it.suit == card.suit && it.rank.order > card.rank.order } + } + + private fun hardPlay(state: HeartsState, seat: Int, cards: List, profile: AiProfile): Card { + val mem = Memory(state, seat) + if (isShooting(state, seat, mem, profile)) return shootPlay(state, cards, mem) + return if (state.trick.isEmpty()) { + hardLead(cards, mem, profile) + } else { + hardFollow(state, seat, cards, mem, profile) + } + } + + /** Holding every point so far, with a hand that can keep winning. */ + private fun isShooting(state: HeartsState, seat: Int, mem: Memory, profile: AiProfile): Boolean { + if (!state.options.allowShootTheMoon) return false + val all = pointsIn(state.taken.flatten()) + val mine = pointsIn(state.taken[seat]) + if (mine != all) return false + if (mine == 0 && !moonHand(mem.hand, profile)) return false + if (mem.hand.isEmpty()) return false + val masters = mem.hand.count { mem.unseenHigher(it) == 0 } + return masters.toDouble() / mem.hand.size >= profile.bolder(0.6, -0.15) + } + + private fun shootPlay(state: HeartsState, cards: List, mem: Memory): Card { + if (state.trick.isEmpty()) { + val masters = cards.filter { mem.unseenHigher(it) == 0 } + if (masters.isNotEmpty()) { + return masters.sortedWith( + compareBy { it.suit == Suit.HEARTS }.thenByDescending { it.rank.order }, + ).first() + } + return cards.maxBy { it.rank.order } + } + val led = state.trick.first().card.suit + val best = state.trick.maxOf { trickStrength(it.card, led) } + val remaining = state.options.playerCount - state.trick.size - 1 + val winning = cards.filter { trickStrength(it, led) > best } + if (winning.isNotEmpty()) { + if (remaining == 0) return winning.minBy { it.rank.order } + return winning.filter { mem.unseenHigher(it) == 0 }.minByOrNull { it.rank.order } + ?: winning.maxBy { it.rank.order } + } + // Cannot win this one: lose it with a card that is not a point. + return cards.minBy { pointsOf(it) * 100 + it.rank.order } + } + + /** Another seat that has taken every penalty card so far and looks set to run the table. */ + private fun moonThreat(state: HeartsState, seat: Int): Int? { + if (!state.options.allowShootTheMoon) return null + val all = pointsIn(state.taken.flatten()) + if (all < 14 && !(state.trickNumber >= 6 && all >= 8)) return null + return (0 until state.options.playerCount).firstOrNull { + it != seat && pointsIn(state.taken[it]) == all + } + } + + private fun scoreLeader(state: HeartsState, seat: Int): Int? = + (0 until state.options.playerCount).filter { it != seat }.minByOrNull { state.scores[it] } + + private fun hardLead(cards: List, mem: Memory, profile: AiProfile): Card { + // A cautious seat weighs a trick it might win more heavily than a bold one. + val weight = 1.0 - 0.25 * profile.style.boldness + + fun risk(card: Card): Double { + val others = mem.unseenIn(card.suit) + val higher = mem.unseenHigher(card) + val winChance = if (others == 0) 1.0 else Math.pow(1.0 - higher.toDouble() / others, 3.0) + var points = 0.5 + if (card.suit == Suit.HEARTS) points += 2.0 + if (card.suit == Suit.SPADES && mem.queenUnseen) points += 13 * 0.3 + // A suit nobody else holds lets everyone dump on whoever wins it. + if (others == 0) points += 3.0 + val shedding = if (winChance < 0.15) 0.05 * card.rank.order else 0.0 + return winChance * points * weight - shedding + } + return cards.minBy { risk(it) } + } + + private fun hardFollow( + state: HeartsState, + seat: Int, + cards: List, + mem: Memory, + profile: AiProfile, + ): Card { + val trick = state.trick + val led = trick.first().card.suit + val winner = trick.maxBy { trickStrength(it.card, led) } + val best = trickStrength(winner.card, led) + val pot = pointsIn(trick.map { it.card }) + val remaining = state.options.playerCount - trick.size - 1 + val winning = cards.filter { trickStrength(it, led) > best } + val ducking = cards.filter { trickStrength(it, led) < best } + val threat = moonThreat(state, seat) + + // Taking even a few points is far better than letting a moon through. + if (threat != null && pot > 0 && winning.isNotEmpty()) { + return winning.minBy { it.rank.order } + } + + val void = cards.none { it.suit == led } + if (void) return hardDischarge(state, seat, cards, mem, profile, winner.seat, threat) + + if (ducking.isNotEmpty()) { + return ducking.maxBy { it.rank.order + pointsOf(it) * 2 } + } + // Forced to win. Last to play, the trick is taken whatever is played, + // so spend the highest card; otherwise win as cheaply as possible. + return if (remaining == 0) { + cards.maxBy { it.rank.order } + } else { + cards.minBy { it.rank.order } + } + } + + /** Void in the led suit: throw away whatever is still dangerous to hold. */ + private fun hardDischarge( + state: HeartsState, + seat: Int, + cards: List, + mem: Memory, + profile: AiProfile, + winnerSeat: Int, + threat: Int?, + ): Card { + val spoil = profile.style.spite >= 0.5 && winnerSeat == scoreLeader(state, seat) + + fun score(card: Card): Double { + var s: Double = when { + card == QUEEN_OF_SPADES -> 1000.0 + else -> card.rank.order.toDouble() + } + // The ace and king of spades only matter while the queen is out there. + if (card.suit == Suit.SPADES && card.rank.order > Rank.QUEEN.order && mem.queenUnseen) s += 200 + if (card.suit == Suit.HEARTS) s += 100 + if (pointsOf(card) == 0 && mem.hand.count { it.suit == card.suit } <= 2) s += 10 + if (spoil && pointsOf(card) > 0) s += 300 + // Do not feed a seat that is running for the moon. + if (threat != null && winnerSeat == threat && pointsOf(card) > 0) s -= 2000 + return s + } + return cards.maxBy { score(it) } + } + + private companion object { + val FULL_DECK: List = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } } } } diff --git a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserAi.kt b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserAi.kt index f80b2b8..6c13442 100644 --- a/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/kaiser/KaiserAi.kt @@ -3,6 +3,10 @@ package org.prolibertate.games.game.kaiser import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.cards.Rank import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -12,25 +16,51 @@ import org.prolibertate.games.game.engine.GameAi * in play chases the five of hearts while trying not to be the one holding the * trick the three of spades lands in. */ -class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) : +class KaiserAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi { - enum class Difficulty( + private companion object { /** Hand strength needed before bidding above the floor. */ - val bidThreshold: Int, - ) { - EASY(bidThreshold = 12), - NORMAL(bidThreshold = 16), - CAUTIOUS(bidThreshold = 19), + const val BID_THRESHOLD = 16.0 + const val EASY_BID_THRESHOLD = 15.0 + } + + /** Cards this AI has watched played in the current hand, per seat it plays. */ + private val memory = HashMap() + + private class SeenCards { + var key: Long = Long.MIN_VALUE + val cards = HashSet() + } + + /** Public information only: cards that have appeared on the table this hand. */ + private fun observe(state: KaiserState, seat: Int): Set = synchronized(memory) { + val seen = memory.getOrPut(seat) { SeenCards() } + val key = state.seed * 1_000_003L + state.handNumber + if (key != seen.key || state.phase == KaiserPhase.BIDDING) { + seen.key = key + seen.cards.clear() + } + state.trick.forEach { seen.cards.add(it.card) } + state.completedTrick.forEach { seen.cards.add(it.card) } + seen.cards.removeAll(state.hands[seat].toSet()) + seen.cards.toSet() } override fun chooseMove(state: KaiserState, seat: Int, legal: List): KaiserMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } - return when (state.phase) { - KaiserPhase.BIDDING -> chooseBid(state, seat, legal) - KaiserPhase.PLAYING -> choosePlay(state, seat, legal) - else -> legal.first() + val profile = profiles.of(seat) + val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet() + val move = try { + when (state.phase) { + KaiserPhase.BIDDING -> chooseBid(state, seat, legal, profile) + KaiserPhase.PLAYING -> choosePlay(state, seat, legal, profile, seen) + else -> legal.first() + } + } catch (e: RuntimeException) { + legal.first() } + return if (move in legal) move else legal.first() } /** Trump length and top cards, plus a nod to holding the counters. */ @@ -50,41 +80,137 @@ class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) : return value } - private fun chooseBid(state: KaiserState, seat: Int, legal: List): KaiserMove { + /** + * A finer rating on the same scale as [suitValue]: it rewards a run of top + * trump, short side suits to ruff with, and only counts the five of hearts + * when the hand can protect it. + */ + private fun hardValue(hand: List, trump: Suit): Int { + val trumps = hand.filter { it.suit == trump } + var value = trumps.size * 3 + for (card in trumps) { + value += when (card.rank) { + Rank.ACE -> 4 + Rank.KING -> 3 + Rank.QUEEN -> 1 + else -> 0 + } + } + // A king backed by the ace, or a queen backed by both, is a real trick. + val ranks = trumps.map { it.rank.order }.toSet() + if (Rank.ACE.order in ranks && Rank.KING.order in ranks) value += 2 + if (Rank.ACE.order in ranks && Rank.KING.order in ranks && Rank.QUEEN.order in ranks) value += 1 + + for (card in hand) { + if (card.suit == trump) continue + if (card.rank == Rank.ACE) value += 2 + else if (card.rank == Rank.KING && hand.any { it.suit == card.suit && it.rank == Rank.ACE }) value += 1 + } + if (trumps.size >= 3) { + value += Suit.entries.filter { it != trump }.count { suit -> hand.none { it.suit == suit } } * 2 + } + if (hand.contains(FIVE_OF_HEARTS)) { + val guarded = trump == Suit.HEARTS || + hand.any { it.suit == Suit.HEARTS && it.rank == Rank.ACE } + value += if (guarded) 3 else -1 + } + if (hand.contains(THREE_OF_SPADES)) value -= if (trump == Suit.SPADES) 1 else 2 + return value + } + + private fun chooseBid( + state: KaiserState, + seat: Int, + legal: List, + profile: AiProfile, + ): KaiserMove { + val hand = state.hands[seat] + val hard = profile.level == AiLevel.HARD + val easy = profile.level == AiLevel.EASY + val naming = legal.filterIsInstance() if (naming.isNotEmpty()) { - val hand = state.hands[seat] - val best = naming - .filter { it.suit != null } - .maxByOrNull { suitValue(hand, it.suit!!) } - return best ?: naming.first() + val named = naming.filter { it.suit != null } + return when { + hard -> named.maxByOrNull { hardValue(hand, it.suit!!) } + easy -> { + val noisy = named.associateWith { suitValue(hand, it.suit!!) + profile.random.nextInt(-4, 5) } + named.sortedByDescending { noisy.getValue(it) }.let { if (it.isEmpty()) null else profile.pick(it) } + } + else -> named.maxByOrNull { suitValue(hand, it.suit!!) } + } ?: naming.first() } - val hand = state.hands[seat] - val bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES - val strength = suitValue(hand, bestSuit) + val bestSuit: Suit + val strength: Int + if (hard) { + bestSuit = Suit.entries.maxBy { hardValue(hand, it) } + strength = hardValue(hand, bestSuit) + } else { + bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES + strength = suitValue(hand, bestSuit) + if (easy) profile.random.nextInt(-5, 6) else 0 + } val bids = legal.filterIsInstance().filter { !it.bid.noTrump } - if (strength >= difficulty.bidThreshold && bids.isNotEmpty()) { + var needed = profile.bolder(if (easy) EASY_BID_THRESHOLD else BID_THRESHOLD, -4.0) + // A spoiler steals contracts from the other side. + val bidder = state.highBidder + if (bidder != null && teamOf(bidder) != teamOf(seat) && profile.style.spite >= 0.5) needed -= 2.0 + if (hard && bids.isNotEmpty()) { + val cheapest = bids.minBy { it.bid.points } + // A bigger contract needs a bigger hand, and a partner who is + // already bidding is better supported than outbid. + needed += 2.0 * (cheapest.bid.points - state.options.minimumBid) + if (bidder != null && bidder == partnerOf(seat)) needed += 4.0 + } + if (strength >= needed && bids.isNotEmpty()) { // Bid the floor rather than reaching; the contract is what matters. return bids.minBy { it.bid.points } } return legal.firstOrNull { it is PassBid } ?: bids.minByOrNull { it.bid.points } ?: legal.first() } - private fun choosePlay(state: KaiserState, seat: Int, legal: List): KaiserMove { + private fun choosePlay( + state: KaiserState, + seat: Int, + legal: List, + profile: AiProfile, + seen: Set, + ): KaiserMove { val cards = legal.filterIsInstance().map { it.card } if (cards.size == 1) return PlayCard(cards.first()) + val bidder = state.highBidder + val defending = bidder != null && teamOf(bidder) != teamOf(seat) + val spiteful = defending && profile.style.spite >= 0.5 + val best = if (profile.level == AiLevel.HARD) { + hardPlay(state, seat, cards, seen, bidder != null && !defending) + } else { + standardPlay(state, seat, cards, spiteful, profile) + } + if (profile.level != AiLevel.EASY) return PlayCard(best) + + // Easy sometimes throws a high card, or a counter, at the wrong time. + val ranked = listOf(best) + (cards - best).sortedByDescending { it.rank.order } + return PlayCard(profile.pick(ranked)) + } + + private fun standardPlay( + state: KaiserState, + seat: Int, + cards: List, + spiteful: Boolean, + profile: AiProfile, + ): Card { if (state.trick.isEmpty()) { // Leading: pull with the top trump, else lead an ace. val trump = state.trump val topTrump = cards.filter { trump != null && it.suit == trump } .maxByOrNull { it.rank.order } - if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return PlayCard(topTrump) + if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return topTrump val ace = cards.filter { it.rank == Rank.ACE }.maxByOrNull { it.rank.order } - if (ace != null) return PlayCard(ace) - return PlayCard(cards.minBy { it.rank.order }) + if (ace != null) return ace + return cards.minBy { it.rank.order } } val ledSuit = state.trick.first().card.suit @@ -96,22 +222,129 @@ class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) : // The five of hearts is worth more than the trick it rides in on. val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS } val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES } + // A spoiler on the defence will not leave a trick to a partner who may be beaten. + val notLast = state.trick.size < 3 + val letPartner = partnerWinning && !threeOnTable && + !(spiteful && notLast && winners.isNotEmpty()) && !profile.slips() return when { // Partner has it and the trick is worth taking: let them have it, // unless the three of spades is in there. - partnerWinning && !threeOnTable -> PlayCard(cards.minBy { it.rank.order }) + letPartner -> cards.minBy { it.rank.order } fiveOnTable && winners.isNotEmpty() -> - PlayCard(winners.maxBy { trickStrength(it, state.trump, ledSuit) }) + winners.maxBy { trickStrength(it, state.trump, ledSuit) } // Nobody wants the three; duck if the trick is already lost. - threeOnTable && winners.isEmpty() -> PlayCard(cards.minBy { it.rank.order }) + threeOnTable && winners.isEmpty() -> cards.minBy { it.rank.order } winners.isNotEmpty() && !threeOnTable -> - PlayCard(winners.minBy { trickStrength(it, state.trump, ledSuit) }) + winners.minBy { trickStrength(it, state.trump, ledSuit) } - else -> PlayCard(cards.minBy { it.rank.order }) + else -> cards.minBy { it.rank.order } } } + + /** + * The strong player's choice: it remembers what has been played, so it + * knows which of its cards are sure winners, feeds the five of hearts to a + * partner who is safe, and buries the three of spades under the other side. + */ + private fun hardPlay( + state: KaiserState, + seat: Int, + cards: List, + seen: Set, + bidding: Boolean, + ): Card { + val trump = state.trump + val hand = state.hands[seat] + val unseen = Decks.kaiser().filter { it !in seen && it !in hand } + val counters = setOf(FIVE_OF_HEARTS, THREE_OF_SPADES) + + fun isBoss(card: Card): Boolean { + val mine = trickStrength(card, trump, card.suit) + val cardIsTrump = trump != null && card.suit == trump + return unseen.none { other -> + (cardIsTrump || trump == null || other.suit != trump) && + trickStrength(other, trump, card.suit) > mine + } + } + + if (state.trick.isEmpty()) { + val plain = cards.filter { it !in counters }.ifEmpty { return cards.minBy { it.rank.order } } + val myTrump = plain.filter { trump != null && it.suit == trump } + val unseenTrump = unseen.count { trump != null && it.suit == trump } + val bossTrump = myTrump.filter { isBoss(it) }.maxByOrNull { it.rank.order } + if (bidding && unseenTrump > 0 && bossTrump != null) return bossTrump + plain.filter { it !in myTrump && isBoss(it) }.maxByOrNull { it.rank.order }?.let { return it } + if (bidding && unseenTrump > 0 && myTrump.size >= 2) return myTrump.maxBy { it.rank.order } + if (bossTrump != null) return bossTrump + val counts = hand.groupingBy { it.suit }.eachCount() + val offSuit = plain.filter { it !in myTrump }.ifEmpty { plain } + // The bidders draw a suit out; the defenders shorten one to ruff. + return if (bidding) { + offSuit.minWith(compareBy({ -(counts[it.suit] ?: 0) }, { it.rank.order })) + } else { + offSuit.minWith(compareBy({ counts[it.suit] ?: 0 }, { it.rank.order })) + } + } + + val ledSuit = state.trick.first().card.suit + val best = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) } + val partnerWinning = partnerOf(best.seat) == seat + val remaining = 4 - state.trick.size - 1 + val bestStrength = trickStrength(best.card, trump, ledSuit) + val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength } + val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS } + val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES } + + fun sure(card: Card): Boolean = + remaining == 0 || + unseen.none { trickStrength(it, trump, ledSuit) > trickStrength(card, trump, ledSuit) } + + fun throwAway(): Card { + val plain = cards.filter { it !in counters } + if (plain.isNotEmpty()) return plain.minBy { it.rank.order } + // Only counters left: the five belongs with partner, the three does not. + return if (partnerWinning) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order } + } + + if (partnerWinning) { + if (sure(best.card)) { + // Partner has it: feed them the five of hearts if we can. + if (FIVE_OF_HEARTS in cards && trickStrength(FIVE_OF_HEARTS, trump, ledSuit) <= bestStrength) { + return FIVE_OF_HEARTS + } + return throwAway() + } + if (!threeOnTable) { + winners.filter { sure(it) }.minByOrNull { trickStrength(it, trump, ledSuit) }?.let { return it } + } + return throwAway() + } + + // The other side is winning. + if (threeOnTable) return throwAway() + if (fiveOnTable && winners.isNotEmpty()) { + val safe = winners.filter { sure(it) } + return if (safe.isNotEmpty()) { + safe.minBy { trickStrength(it, trump, ledSuit) } + } else { + winners.maxBy { trickStrength(it, trump, ledSuit) } + } + } + // Bury the three under a trick the other side is certain to take. + if (THREE_OF_SPADES in cards && + trickStrength(THREE_OF_SPADES, trump, ledSuit) <= bestStrength && + sure(best.card) + ) { + return THREE_OF_SPADES + } + if (winners.isNotEmpty()) { + val safe = winners.filter { sure(it) } + return (safe.ifEmpty { winners }).minBy { trickStrength(it, trump, ledSuit) } + } + return throwAway() + } } diff --git a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt index ca22325..eb31ef1 100644 --- a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt @@ -1,6 +1,8 @@ package org.prolibertate.games.game.mastermind import kotlin.random.Random +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -16,9 +18,18 @@ import org.prolibertate.games.game.engine.GameAi * - **Strong** is Knuth's idea one ply deep: among the codes still standing, it * plays the one whose *worst* possible answer leaves the smallest number of * candidates behind, so it cannot be given a reply that tells it little. + * + * The computer difficulty chosen at setup picks the level: Easy plays Casual, + * Medium plays whatever the table's saved [MastermindOptions.level] says (Club + * for a new game) and Hard plays Strong. [level] overrides all of that, which is + * what the tests use. */ -class MastermindAi(private val level: MastermindLevel? = null) : - GameAi { +class MastermindAi( + private val profiles: AiProfiles = AiProfiles.DEFAULT, + private val level: MastermindLevel? = null, +) : GameAi { + + constructor(level: MastermindLevel?) : this(AiProfiles.DEFAULT, level) override fun chooseMove( state: MastermindState, @@ -26,7 +37,12 @@ class MastermindAi(private val level: MastermindLevel? = null) : legal: List, ): MastermindMove { require(legal.isNotEmpty()) { "No legal guess for seat $seat" } - val setting = level ?: state.options.level + val profile = profiles.of(seat) + val setting = level ?: when (profile.level) { + AiLevel.EASY -> MastermindLevel.CASUAL + AiLevel.MEDIUM -> state.options.level + AiLevel.HARD -> MastermindLevel.STRONG + } // Deterministic for a given position, so a host and a client watching // the same game agree on what the computer did. diff --git a/app/src/main/java/org/prolibertate/games/game/morris/MorrisAi.kt b/app/src/main/java/org/prolibertate/games/game/morris/MorrisAi.kt index fbc65ae..a87ae1b 100644 --- a/app/src/main/java/org/prolibertate/games/game/morris/MorrisAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/morris/MorrisAi.kt @@ -1,6 +1,9 @@ package org.prolibertate.games.game.morris import kotlin.random.Random +import kotlin.math.roundToInt +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -19,7 +22,20 @@ import org.prolibertate.games.game.engine.GameAi * * Strength comes from the table's own options unless a level is passed in. */ -class MorrisAi(private val level: MorrisLevel? = null) : GameAi { +class MorrisAi( + private val profiles: AiProfiles = AiProfiles.DEFAULT, + private val level: MorrisLevel? = null, +) : GameAi { + + // Viewpoint and weights for the move being chosen. At the default (balanced) + // style they equal the plain constants, so Medium is unchanged. + private var rootSeat = 0 + private var wMill = MILL + private var wAlmost = ALMOST + private var wTheirAlmost = ALMOST + private var wMobility = MOBILITY + private var wTheirMobility = MOBILITY + private var wDanger = DANGER /** * Ceiling on how much thinking any one move gets, so a phone does not stall @@ -32,10 +48,29 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi): MorrisMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) val setting = level ?: state.options.level if (legal.size == 1) return legal.first() - nodeBudget = NODE_BUDGET + val bold = profile.style.boldness + val spite = profile.style.spite + rootSeat = seat + // Bold seats value mills and half-built mills more; cautious ones value + // mobility and avoiding the three-piece trap. Spiteful seats weigh the + // opponent's mill threats and freedom of movement more. + wMill = MILL + (bold * 6).roundToInt() + wAlmost = ALMOST + (bold * 3).roundToInt() + wTheirAlmost = ALMOST + (spite * 4).roundToInt() + (-bold * 2).roundToInt() + wMobility = MOBILITY + (-bold * 1).roundToInt() + wTheirMobility = MOBILITY + (spite * 2).roundToInt() + (-bold * 1).roundToInt() + wDanger = DANGER + (-bold * 10).roundToInt() + + val maxDepth = when (profile.level) { + AiLevel.EASY -> maxOf(2, setting.depth - 2) + AiLevel.MEDIUM -> setting.depth + AiLevel.HARD -> setting.depth + 1 + } + nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET nodesUsed = 0 // Deterministic for a given position, so a host and a client replaying @@ -46,7 +81,7 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi>() val ordered = order(legal, preferred = best) for (move in ordered) { @@ -73,7 +108,16 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi= bestScore - setting.slack() } - return acceptable[random.nextInt(acceptable.size)].first + val chosen = acceptable[random.nextInt(acceptable.size)].first + if (profile.level != AiLevel.EASY) return chosen + + // Easy now and then settles for another sound-looking move: only ones + // within about a piece of the best, never a real blunder. + val ranked = listOf(chosen) + scored + .filter { it.first != chosen && it.second >= bestScore - EASY_TOLERANCE } + .sortedByDescending { it.second } + .map { it.first } + return profile.pick(ranked) } /** How far below best a move may score and still be considered. */ @@ -155,16 +199,17 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi { +class PiratesAi( + private val profiles: AiProfiles = AiProfiles.DEFAULT, + private val level: PiratesLevel? = null, +) : GameAi { private var nodeBudget = 0 private var nodesUsed = 0 + // Evaluation weights for the seat now choosing; the standard values unless + // a style or level bends them. Set at the top of each chooseMove. + private var wBulgar = BULGAR + private var wStronghold = IN_STRONGHOLD + private var wAdvance = ADVANCE + private var wExposed = EXPOSED + private var wRoom = PIRATE_ROOM + + private fun tune(profile: AiProfile, seat: Int) { + val bold = profile.style.boldness + val spite = profile.style.spite + var bulgar = 1.0 + var stronghold = 1.0 + var advance = 1.0 + var exposed = 1.0 + var room = 1.0 + if (seat == BULGAR_SEAT) { + // A bold crowd pushes forward and risks being jumped; a cautious one stays tight. + exposed -= 0.3 * bold + advance += 0.3 * bold + // A spoiler squeezes the pirates' room. + room += 0.4 * spite + } else { + // A bold pirate hunts; a cautious one keeps its room to move. + bulgar += 0.15 * bold + room -= 0.3 * bold + // A spoiler pays more attention to stopping the climb. + stronghold += 0.4 * spite + advance += 0.4 * spite + } + if (profile.level == AiLevel.EASY) exposed *= 0.5 // overlooks some threats + wBulgar = (BULGAR * bulgar).roundToInt() + wStronghold = (IN_STRONGHOLD * stronghold).roundToInt() + wAdvance = (ADVANCE * advance).roundToInt() + wExposed = (EXPOSED * exposed).roundToInt() + wRoom = (PIRATE_ROOM * room).roundToInt() + } + override fun chooseMove(state: PiratesState, seat: Int, legal: List): PiratesMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } - val setting = level ?: state.options.level if (legal.size == 1) return legal.first() + val profile = profiles.of(seat) + val base = level ?: state.options.level + tune(profile, seat) - nodeBudget = NODE_BUDGET + val hard = profile.level == AiLevel.HARD + val easy = profile.level == AiLevel.EASY + val maxDepth = when (profile.level) { + AiLevel.MEDIUM -> base.depth + AiLevel.EASY -> minOf(base.depth, 2) + AiLevel.HARD -> base.depth + 2 + } + val slack = when (profile.level) { + AiLevel.MEDIUM -> base.slack() + AiLevel.EASY -> maxOf(base.slack(), 40) + AiLevel.HARD -> 0 + } + + nodeBudget = if (hard) HARD_NODE_BUDGET else NODE_BUDGET nodesUsed = 0 val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode()) @@ -32,18 +92,23 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi>() val ordered = order(legal, preferred = best) + var bestSoFar = -INFINITY for (move in ordered) { + // A hard seat only needs the exact value of moves that tie or beat + // the best so far, so everything else is searched against a window. + val floor = if (hard && results.isNotEmpty()) bestSoFar - 1 else -INFINITY val score = -negamax( state = PiratesRules.advanced(state, move), depth = depth - 1, alpha = -INFINITY, - beta = INFINITY, + beta = -floor, ply = 1, ) results += move to score + if (score > bestSoFar) bestSoFar = score if (nodesUsed >= nodeBudget) break } if (nodesUsed >= nodeBudget && results.size < ordered.size) break @@ -51,9 +116,16 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi= bestScore - setting.slack() } - return acceptable[random.nextInt(acceptable.size)].first + val acceptable = scored.filter { it.second >= bestScore - slack } + val chosen = acceptable[random.nextInt(acceptable.size)].first + return if (chosen in legal) chosen else legal.first() } private fun PiratesLevel.slack(): Int = when (this) { @@ -115,22 +187,22 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi { - enum class Difficulty( - /** Rank above which a card is hoarded rather than spent to lead. */ - val hoardAbove: Int, - ) { - /** Plays whatever is cheapest, whenever it can. */ - EASY(hoardAbove = 99), - - NORMAL(hoardAbove = 13), - - /** Sits on high cards until late, then dumps them. */ - CAUTIOUS(hoardAbove = 11), - } - override fun chooseMove( state: PresidentState, seat: Int, legal: List, ): PresidentMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) val plays = legal.filterIsInstance() if (plays.isEmpty()) return legal.first() - return if (state.setSize == 0) chooseLead(state, seat, plays) else chooseFollow(state, plays, legal) + val move: PresidentMove = when (profile.level) { + AiLevel.MEDIUM -> standard(state, seat, plays, legal, hoardAbove = HOARD_ABOVE) + AiLevel.EASY -> easy(state, seat, plays, legal, profile) + AiLevel.HARD -> hard(state, seat, plays, legal, profile) + } + return if (move in legal) move else legal.first() } + private fun standard( + state: PresidentState, + seat: Int, + plays: List, + legal: List, + hoardAbove: Int, + ): PresidentMove = + if (state.setSize == 0) { + leadRanking(state, seat, plays, hoardAbove).first() + } else { + chooseFollow(state, plays, legal) + } + + // ----------------------------------------------------------------------- + // Standard plan + // ----------------------------------------------------------------------- + /** * Leading is a chance to shed. Prefer the biggest set of the lowest rank, * which clears junk fastest without spending anything valuable. */ - private fun chooseLead( + private fun leadRanking( state: PresidentState, seat: Int, plays: List, - ): PresidentMove { + hoardAbove: Int, + ): List { val endgame = state.hands[seat].size <= 4 val candidates = plays.filter { play -> - endgame || presidentRank(play.cards.first()) <= difficulty.hoardAbove + endgame || presidentRank(play.cards.first()) <= hoardAbove }.ifEmpty { plays } - return candidates - .sortedWith( - compareBy( - { presidentRank(it.cards.first()) }, - { -it.cards.size }, - ) - ) - .first() + return candidates.sortedWith( + compareBy( + { presidentRank(it.cards.first()) }, + { -it.cards.size }, + ), + ) } /** @@ -82,4 +104,188 @@ class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) : return cheapest } + + // ----------------------------------------------------------------------- + // Easy + // ----------------------------------------------------------------------- + + private fun easy( + state: PresidentState, + seat: Int, + plays: List, + legal: List, + profile: AiProfile, + ): PresidentMove { + if (state.setSize == 0) { + // Never hoards: leads aces and twos as readily as anything else. + return profile.pick(leadRanking(state, seat, plays, hoardAbove = 99)) + } + val best = chooseFollow(state, plays, legal) + val alternatives = plays.sortedBy { presidentRank(it.cards.first()) } + val ranked = buildList { + add(best) + addAll(alternatives.filter { it != best }) + if (PassTurn in legal && best != PassTurn) add(PassTurn) + } + return profile.pick(ranked) + } + + // ----------------------------------------------------------------------- + // Hard + // ----------------------------------------------------------------------- + + /** Ranks still unaccounted for: not in this seat's hand and not played yet this round. */ + private class Counts(state: PresidentState, seat: Int) { + private val unseen = HashMap() + + init { + for (rank in 3..15) unseen[rank] = 4 + state.hands[seat].forEach { dec(presidentRank(it)) } + val log = state.log + val start = log.indexOfLast { ROUND_MARK.matches(it) } + 1 + for (i in start until log.size) { + val line = log[i] + val at = line.indexOf(" plays ") + if (!line.startsWith("Seat ") || at < 0) continue + line.substring(at + " plays ".length).removeSuffix(".").split(' ').forEach { token -> + LABELS[token]?.let { dec(presidentRank(it)) } + } + } + } + + private fun dec(rank: Int) { + unseen[rank] = maxOf(0, (unseen[rank] ?: 0) - 1) + } + + fun of(rank: Int): Int = unseen[rank] ?: 0 + + /** Could an opponent still hold a set of [size] that beats a set of [size] at [rank]? */ + fun beatable(rank: Int, size: Int, options: PresidentOptions): Boolean { + if (options.twosClear && rank != 15 && of(15) >= size) return true + if (options.fourOfAKindBomb && size == 4 && (3..15).any { it != rank && of(it) == 4 }) return true + return (rank + 1..15).any { of(it) >= size } + } + } + + private fun hard( + state: PresidentState, + seat: Int, + plays: List, + legal: List, + profile: AiProfile, + ): PresidentMove { + val hand = state.hands[seat] + // Going out beats any plan. + plays.firstOrNull { it.cards.size == hand.size }?.let { return it } + + val counts = Counts(state, seat) + val others = (0 until state.options.playerCount).filter { it != seat && !state.isOut(it) } + return if (state.setSize == 0) { + hardLead(state, seat, plays, counts, others, profile) + } else { + hardFollow(state, seat, plays, legal, counts, others, profile) + } + } + + /** An opponent who could empty their hand with a set the size of the one on top, or is the score leader close to out. */ + private fun outThreat( + state: PresidentState, + seat: Int, + others: List, + profile: AiProfile, + ): Boolean { + if (others.any { state.handCounts[it] <= maxOf(1, state.setSize) }) return true + if (profile.style.spite >= 0.5) { + val leader = others.maxByOrNull { state.scores[it] } + if (leader != null && state.handCounts[leader] <= 2) return true + } + return false + } + + private fun hardLead( + state: PresidentState, + seat: Int, + plays: List, + counts: Counts, + others: List, + profile: AiProfile, + ): PresidentMove { + val hand = state.hands[seat] + val options = state.options + val oneCardLeft = others.any { state.handCounts[it] == 1 } + + fun unbeatableAfter(play: PlayCards): Boolean { + val remaining = hand.toMutableList().also { left -> play.cards.forEach { left.remove(it) } } + return remaining.groupBy { presidentRank(it) } + .all { (rank, cards) -> !counts.beatable(rank, cards.size, options) } + } + + fun key(play: PlayCards): Double { + val rank = presidentRank(play.cards.first()) + val size = play.cards.size + var k = rank - 0.9 * size + if (unbeatableAfter(play)) return k - 50.0 + // Leading aces and twos spends control that is worth more later. + if (rank >= 14 && hand.size > 4) k += 20.0 + // Somebody is down to one card: a single would let them out if they can beat it. + if (oneCardLeft && size == 1) k += 10.0 + (15 - rank) * 2.0 + // A bold seat is happier to lead its strong sets early. + if (rank >= 11) k -= profile.style.boldness * 2.0 + return k + } + return plays.minBy { key(it) } + } + + private fun hardFollow( + state: PresidentState, + seat: Int, + plays: List, + legal: List, + counts: Counts, + others: List, + profile: AiProfile, + ): PresidentMove { + val hand = state.hands[seat] + val options = state.options + val canPass = PassTurn in legal + val threat = outThreat(state, seat, others, profile) + + val withoutTwos = plays.filter { play -> play.cards.none { isTwo(it) } } + val pool = withoutTwos.ifEmpty { plays } + val cheapest = pool.minBy { presidentRank(it.cards.first()) } + + if (threat) { + // Block them: take the pile with the cheapest set nobody can beat, + // or failing that the cheapest set there is, twos included. + val sure = plays.filter { !counts.beatable(presidentRank(it.cards.first()), it.cards.size, options) } + .minByOrNull { presidentRank(it.cards.first()) } + return sure ?: plays.minBy { presidentRank(it.cards.first()) } + } + + val spendingTwo = cheapest.cards.any { isTwo(it) } + if (spendingTwo) { + val remaining = hand.toMutableList().also { left -> cheapest.cards.forEach { left.remove(it) } } + val runOut = remaining.groupBy { presidentRank(it) } + .all { (rank, cards) -> !counts.beatable(rank, cards.size, options) } + val worthIt = state.setRank >= 13 || state.pile.size >= 4 || hand.size <= 4 || runOut + if (!worthIt && canPass) return PassTurn + return cheapest + } + + // A cheap pile is not worth a high card while the hand is still big. + val gap = profile.bolder(6.0, 2.0) + val cost = presidentRank(cheapest.cards.first()) - state.setRank + if (canPass && hand.size > 5 && cost > gap && presidentRank(cheapest.cards.first()) >= 12) { + return PassTurn + } + return cheapest + } + + private companion object { + const val HOARD_ABOVE = 13 + val ROUND_MARK = Regex("^Round \\d+\\.$") + val LABELS: Map = Suit.entries.flatMap { suit -> + Rank.standard.map { Card(it, suit) } + }.associateBy { it.label } + } } diff --git a/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt index 20f1871..10ba736 100644 --- a/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt @@ -1,5 +1,8 @@ package org.prolibertate.games.game.sequence +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -8,18 +11,39 @@ import org.prolibertate.games.game.engine.GameAi * It scores every legal placement by how much it advances one of its own runs * and how badly it spoils an opponent's, holds its jacks back until they are * worth spending, and never looks at anyone's hand. + * + * Per seat, from [profiles]: Easy mostly ignores your runs, burns its one-eyed + * jacks on anything and sometimes settles for a lesser square; Hard blocks hard, + * saves jacks for real threats, always takes a completed run and always stops + * one that is one square from done. Boldness leans towards building over + * blocking and spite the other way round. */ -class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) : +class SequenceAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi { - enum class Difficulty(val blockWeight: Int, val jackPatience: Int) { - /** Builds its own runs and mostly ignores yours. */ - EASY(blockWeight = 1, jackPatience = 0), + /** The numbers one seat plays with, worked out from its level and style. */ + private class Tuning( + val ownWeight: Double, + val blockWeight: Double, + val jackPatience: Double, + val finishing: Boolean, + ) - NORMAL(blockWeight = 3, jackPatience = 3), - - /** Blocks aggressively and saves jacks for real threats. */ - CAUTIOUS(blockWeight = 5, jackPatience = 4), + private fun tuningOf(profile: AiProfile): Tuning { + val (block, patience) = when (profile.level) { + AiLevel.EASY -> 1.0 to 0.0 + AiLevel.MEDIUM -> 3.0 to 3.0 + AiLevel.HARD -> 5.0 to 4.0 + } + val style = profile.style + val easy = profile.level == AiLevel.EASY + return Tuning( + ownWeight = 4.0 + style.boldness, + blockWeight = block - style.boldness * 0.8 + style.spite * 2.0, + // Only a seat that is paying attention changes its mind about jacks. + jackPatience = if (easy) patience else patience + style.boldness - style.spite, + finishing = profile.level == AiLevel.HARD, + ) } override fun chooseMove( @@ -28,15 +52,30 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) : legal: List, ): SequenceMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) + val tuning = tuningOf(profile) val team = state.teamOf(seat) // Clearing a dead card is free value when nothing better is on offer. val exchange = legal.filterIsInstance().firstOrNull() if (exchange != null && legal.none { it is PlaceChip }) return exchange - val best = legal + val placements = legal .filterIsInstance() - .maxByOrNull { scorePlacement(state, it, team) } + .map { it to scorePlacement(state, it, team, tuning) } + .sortedByDescending { it.second } + .map { it.first } + val best = if (placements.isEmpty()) { + null + } else if (tuning.finishing) { + // A run that completes, or a run of the opponent's that would, is + // never something to be talked out of. + placements.first() + } else { + // Easy only: the others never slip. Only the better few are in play, + // so a slip is a weaker square rather than a wasted card. + profile.pick(placements.take(4)) + } val removal = legal .filterIsInstance() @@ -45,7 +84,7 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) : // Spend a one-eyed jack only on a genuinely dangerous chip. if (removal != null) { val threat = threatAt(state, removal.cell, state.chips[removal.cell]) - if (threat >= difficulty.jackPatience) return removal + if (threat >= tuning.jackPatience && !profile.slips()) return removal } return best ?: removal ?: exchange ?: legal.first() @@ -56,14 +95,25 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) : * opponent's run it interrupts. Wild jacks are discounted so they are not * burned on an ordinary square. */ - private fun scorePlacement(state: SequenceState, move: PlaceChip, team: Int): Int { + private fun scorePlacement( + state: SequenceState, + move: PlaceChip, + team: Int, + tuning: Tuning, + ): Double { val ours = runPotential(state, move.cell, team) val theirs = (0 until state.options.teamCount) .filter { it != team } .maxOfOrNull { runPotential(state, move.cell, it) } ?: 0 - var score = ours * 4 + theirs * difficulty.blockWeight + var score = ours * tuning.ownWeight + theirs * tuning.blockWeight + if (tuning.finishing) { + // A potential of five through this square means placing here + // completes the run (for whoever places it). + if (ours >= RUN_LENGTH) score += 100.0 + if (theirs >= RUN_LENGTH) score += 60.0 else if (theirs == RUN_LENGTH - 1) score += 8.0 + } // Central squares sit on more possible lines than edge squares. val row = SequenceBoard.rowOf(move.cell) val col = SequenceBoard.colOf(move.cell) diff --git a/app/src/main/java/org/prolibertate/games/game/tayu/TayuAi.kt b/app/src/main/java/org/prolibertate/games/game/tayu/TayuAi.kt index 15e0365..23effb4 100644 --- a/app/src/main/java/org/prolibertate/games/game/tayu/TayuAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/tayu/TayuAi.kt @@ -1,5 +1,8 @@ package org.prolibertate.games.game.tayu +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -18,17 +21,53 @@ import org.prolibertate.games.game.engine.GameAi * * [level] overrides the table's own setting, which is what the tests use; * ordinarily it is left null and read from [TayuOptions.level]. + * + * Per seat, from [profiles]: the table's setting says which terms count at all + * (Gentle ignores the opponent entirely and that is respected at every level). + * Within Full strength, Easy blocks and steers less and sometimes takes one of + * its better few placements rather than the best; Hard blocks and steers harder. + * Boldness weights its own axis against the opponent's, spite weights blocking. */ -class TayuAi(private val level: TayuLevel? = null) : GameAi { +class TayuAi( + private val profiles: AiProfiles = AiProfiles.DEFAULT, + private val level: TayuLevel? = null, +) : GameAi { + + constructor(level: TayuLevel) : this(AiProfiles.DEFAULT, level) + + private class Weights(val own: Double, val block: Double, val reach: Double) + + private fun weightsOf(setting: TayuLevel, profile: AiProfile): Weights { + var own = setting.ownWeight.toDouble() + var block = setting.blockWeight.toDouble() + var reach = setting.reachWeight.toDouble() + // Gentle is a table rule, not a skill level: it stays as it is. + if (setting.blockWeight > 0) { + when (profile.level) { + AiLevel.EASY -> { block *= 0.4; reach *= 0.5 } + AiLevel.MEDIUM -> Unit + AiLevel.HARD -> { block *= 1.35; reach *= 1.5 } + } + own *= 1.0 + 0.15 * profile.style.boldness + block *= 1.0 - 0.15 * profile.style.boldness + 0.4 * profile.style.spite + } + return Weights(own, block, reach) + } override fun chooseMove(state: TayuState, seat: Int, legal: List): TayuMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) val setting = level ?: state.options.level if (legal.size == 1) return legal.first() - return legal.maxByOrNull { rate(state, seat, it, setting) } ?: legal.first() + val weights = weightsOf(setting, profile) + val rated = legal.map { it to rate(state, seat, it, weights) } + if (profile.level != AiLevel.EASY) return rated.maxByOrNull { it.second }?.first ?: legal.first() + // Easy: one of its better few placements, not always the best. + val ranked = rated.sortedByDescending { it.second }.take(EASY_POOL).map { it.first } + return profile.pick(ranked) } - private fun rate(state: TayuState, seat: Int, move: TayuMove, setting: TayuLevel): Int { + private fun rate(state: TayuState, seat: Int, move: TayuMove, weights: Weights): Double { val after = TayuRules.previewPlacement(state, seat, move) val mine = state.axisOf(seat) val theirs = if (mine == Axis.NORTH_SOUTH) Axis.EAST_WEST else Axis.NORTH_SOUTH @@ -43,15 +82,15 @@ class TayuAi(private val level: TayuLevel? = null) : GameAi // Skipped rather than multiplied by zero: the gentle setting is the whole // reason to avoid walking the open mouths at all, and this runs for every // one of a thousand-odd candidate placements. - val reach = if (setting.reachWeight == 0) { + val reach = if (weights.reach == 0.0) { 0 } else { (pull(after, mine) - pull(after, theirs)) / REACH_SCALE } - return setting.ownWeight * ours - - setting.blockWeight * opposing + - setting.reachWeight * reach + return weights.own * ours - + weights.block * opposing + + weights.reach * reach } /** @@ -82,5 +121,8 @@ class TayuAi(private val level: TayuLevel? = null) : GameAi private companion object { /** Brings the open-mouth term onto the same scale as the score term. */ const val REACH_SCALE = 8 + + /** How many top-rated placements Easy chooses among. */ + const val EASY_POOL = 6 } } diff --git a/app/src/main/java/org/prolibertate/games/game/wizard/WizardAi.kt b/app/src/main/java/org/prolibertate/games/game/wizard/WizardAi.kt index e6e65e6..e7efdb4 100644 --- a/app/src/main/java/org/prolibertate/games/game/wizard/WizardAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/wizard/WizardAi.kt @@ -3,8 +3,13 @@ package org.prolibertate.games.game.wizard import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.cards.Rank import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi import kotlin.math.abs +import kotlin.math.pow import kotlin.math.roundToInt /** @@ -15,52 +20,99 @@ import kotlin.math.roundToInt * high it is and whether it is trump. In play it chases tricks while it is * short of its bid and ducks once it has them, which is the whole game. */ -class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) : +class WizardAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi { - enum class Difficulty( - /** - * Added to the expected-trick count before rounding. Optimists overbid - * and go down; pessimists underbid and get caught taking tricks they - * did not want. - */ - val bidBias: Double, - ) { - EASY(bidBias = 0.35), - NORMAL(bidBias = 0.0), - CAUTIOUS(bidBias = -0.25), + private companion object { + /** Optimists overbid and go down; Easy leans this way. */ + const val EASY_BID_BIAS = 0.35 + val DECK: List = Decks.wizard() + } + + /** Cards this AI has watched played in the current round, per seat it plays. */ + private val memory = HashMap() + + private class SeenCards { + var key: Long = Long.MIN_VALUE + val cards = HashSet() + } + + /** Public information only: cards that have appeared on the table this round. */ + private fun observe(state: WizardState, seat: Int): Set = synchronized(memory) { + val seen = memory.getOrPut(seat) { SeenCards() } + val key = state.seed * 1_000_003L + state.round + if (key != seen.key || state.phase == WizardPhase.BIDDING) { + seen.key = key + seen.cards.clear() + } + state.trick.forEach { seen.cards.add(it.card) } + state.completedTrick.forEach { seen.cards.add(it.card) } + seen.cards.removeAll(state.hands[seat].toSet()) + seen.cards.toSet() } override fun chooseMove(state: WizardState, seat: Int, legal: List): WizardMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } - return when (state.phase) { - WizardPhase.BIDDING -> chooseBid(state, seat, legal) - WizardPhase.PLAYING -> choosePlay(state, seat, legal) - else -> legal.first() + val profile = profiles.of(seat) + val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet() + val move = try { + when (state.phase) { + WizardPhase.BIDDING -> chooseBid(state, seat, legal, profile) + WizardPhase.PLAYING -> choosePlay(state, seat, legal, profile, seen) + else -> legal.first() + } + } catch (e: RuntimeException) { + legal.first() } + return if (move in legal) move else legal.first() } // ----------------------------------------------------------------------- // Bidding // ----------------------------------------------------------------------- - private fun chooseBid(state: WizardState, seat: Int, legal: List): WizardMove { + private fun chooseBid( + state: WizardState, + seat: Int, + legal: List, + profile: AiProfile, + ): WizardMove { + val hand = state.hands[seat] + val hard = profile.level == AiLevel.HARD + val easy = profile.level == AiLevel.EASY + val naming = legal.filterIsInstance() if (naming.isNotEmpty()) { - val hand = state.hands[seat] - val best = naming.maxByOrNull { suitStrength(hand, it.suit) } - return best ?: naming.first() + return when { + hard -> naming.maxByOrNull { hardExpected(state, hand, it.suit) } + easy -> { + val noisy = naming.associateWith { suitStrength(hand, it.suit) + profile.random.nextDouble(-0.8, 0.8) } + profile.pick(naming.sortedByDescending { noisy.getValue(it) }) + } + else -> naming.maxByOrNull { suitStrength(hand, it.suit) } + } ?: naming.first() } val bids = legal.filterIsInstance() if (bids.isEmpty()) return legal.first() - val wanted = (expectedTricks(state.hands[seat], state.trump) + difficulty.bidBias) - .roundToInt() - .coerceIn(0, state.cardsThisRound) + var expected = if (hard) hardExpected(state, hand, state.trump) else expectedTricks(hand, state.trump) + if (hard) { + // The last to bid knows how many tricks the others have left on the table. + val others = state.bids.filterNotNull() + if (others.size == state.options.playerCount - 1) { + expected = expected * 0.75 + (state.cardsThisRound - others.sum()) * 0.25 + } + } + if (easy) expected += EASY_BID_BIAS + profile.random.nextDouble(-0.8, 0.8) + // A gambler bids up, a cautious seat bids down. + expected += profile.bolder(0.0, 0.3) + + val wanted = expected.roundToInt().coerceIn(0, state.cardsThisRound) // Screw-the-dealer may have taken the number we wanted away, so settle // for the nearest one still on offer. - return bids.minBy { abs(it.tricks - wanted) } + val ranked = bids.sortedBy { abs(it.tricks - wanted) } + return if (easy) profile.pick(ranked) else ranked.first() } /** How much of a hand a given trump suit would be worth. */ @@ -92,22 +144,88 @@ class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) : return expected } + /** + * The chance [card] takes a trick, from the cards that could still beat it. + * + * [pool] is every card that might be in somebody else's hand and [q] the + * chance any one of them is, and each higher card only threatens the trick + * if it lands in the same one, which is less likely the longer the round. + */ + private fun winProb(card: Card, pool: List, trump: Suit?, q: Double, tricks: Int): Double { + if (isWizardCard(card)) return 1.0 + if (isJester(card)) return 0.0 + val wizards = pool.count { isWizardCard(it) && it != card } + val ordinary = { c: Card -> !isWizardCard(c) && !isJester(c) } + val trumpCard = trump != null && card.suit == trump + val higher = pool.count { ordinary(it) && it.suit == card.suit && it.rank.order > card.rank.order } + var danger = (higher + wizards).toDouble() + if (!trumpCard && trump != null) { + val voidFactor = 1.0 / (1.0 + 0.5 * (tricks - 1)) + danger += voidFactor * pool.count { ordinary(it) && it.suit == trump } + } + val sequencing = 1.0 / (1.0 + 0.35 * (tricks - 1)) + return (1.0 - (q * sequencing).coerceIn(0.0, 1.0)).pow(danger) + } + + /** Expected tricks from the cards that could still be out, calibrated so the average hand scores its fair share. */ + private fun hardExpected(state: WizardState, hand: List, trump: Suit?): Double { + val n = state.cardsThisRound + val players = state.options.playerCount + val inPlay = DECK.filter { it != state.trumpCard } + val mine = inPlay.filter { it !in hand } + val q = ((players - 1) * n).toDouble() / mine.size.coerceAtLeast(1) + + val own = hand.sumOf { winProb(it, mine, trump, q, n) } + val average = inPlay.sumOf { winProb(it, inPlay, trump, q, n) } / inPlay.size + val scale = if (average > 0.0) (1.0 / (players * average)).coerceIn(0.5, 2.0) else 1.0 + return own * scale + } + // ----------------------------------------------------------------------- // Play // ----------------------------------------------------------------------- - private fun choosePlay(state: WizardState, seat: Int, legal: List): WizardMove { + private fun choosePlay( + state: WizardState, + seat: Int, + legal: List, + profile: AiProfile, + seen: Set, + ): WizardMove { val cards = legal.filterIsInstance().map { it.card } if (cards.size == 1) return PlayCard(cards.first()) + val hard = profile.level == AiLevel.HARD val bid = state.bids[seat] ?: 0 val stillNeeded = bid - state.tricksWon[seat] val tricksLeft = state.hands[seat].size // Chase while we are short, and only while there are enough tricks left - // to be worth chasing. - val wantsTrick = stillNeeded > 0 && stillNeeded <= tricksLeft + // to be worth chasing. (The careful player keeps chasing even when the + // bid is out of reach: every trick still cuts the penalty.) + val wantsTrick = stillNeeded > 0 && (hard || stillNeeded <= tricksLeft) + // A spoiler who has already blown their own bid takes tricks off anyone + // who needs exactly one more. + val spoiling = profile.style.spite >= 0.5 && stillNeeded < 0 - if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, state.trump, wantsTrick)) + val best = if (hard) { + hardPlay(state, seat, cards, seen, wantsTrick, spoiling, stillNeeded, tricksLeft) + } else { + standardPlay(state, cards, wantsTrick, spoiling) + } + if (profile.level != AiLevel.EASY) return PlayCard(best) + + // Easy sometimes burns a strong card at the wrong moment. + val ranked = listOf(best) + (cards - best).sortedByDescending { it.rank.order } + return PlayCard(profile.pick(ranked)) + } + + private fun standardPlay( + state: WizardState, + cards: List, + wantsTrick: Boolean, + spoiling: Boolean, + ): Card { + if (state.trick.isEmpty()) return chooseLead(cards, state.trump, wantsTrick) val ledSuit = ledSuitOf(state.trick) val order = state.trick.size @@ -122,15 +240,99 @@ class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) : return when { // Take it as cheaply as we can. - wantsTrick && winners.isNotEmpty() -> PlayCard(winners.minBy { strengthOf(it) }) + wantsTrick && winners.isNotEmpty() -> winners.minBy { strengthOf(it) } + // Spite: stop somebody from landing the bid they need. + spoiling && winners.isNotEmpty() && victimNeedsOne(state) -> winners.minBy { strengthOf(it) } // Cannot take it, or do not want it: throw the least useful card. - losers.isNotEmpty() -> PlayCard(losers.minBy { strengthOf(it) }) + losers.isNotEmpty() -> losers.minBy { strengthOf(it) } // Everything we hold wins, and we did not want the trick. Take it // with the weakest winner so the good cards stay for later. - else -> PlayCard(cards.minBy { strengthOf(it) }) + else -> cards.minBy { strengthOf(it) } } } + /** Whether whoever is winning the trick right now needs exactly this trick to make their bid. */ + private fun victimNeedsOne(state: WizardState): Boolean { + val ledSuit = ledSuitOf(state.trick) + val leading = state.trick + .withIndex() + .maxByOrNull { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) } + ?: return false + val victim = leading.value.seat + return (state.bids[victim] ?: return false) - state.tricksWon[victim] == 1 + } + + /** + * The strong player's choice. It keeps track of which cards are gone to + * judge how likely each of its own is to win, spends sure winners when it + * needs tricks, and sheds its most dangerous cards onto tricks it will + * lose anyway when it does not. + */ + private fun hardPlay( + state: WizardState, + seat: Int, + cards: List, + seen: Set, + wantsTrick: Boolean, + spoiling: Boolean, + stillNeeded: Int, + tricksLeft: Int, + ): Card { + val hand = state.hands[seat] + val unseen = DECK.filter { it != state.trumpCard && it !in seen && it !in hand } + val oppCards = state.handCounts.sum() - hand.size + val q = if (unseen.isEmpty()) 0.0 else (oppCards.toDouble() / unseen.size).coerceIn(0.0, 1.0) + val trump = state.trump + fun pWin(card: Card): Double = winProb(card, unseen, trump, q, tricksLeft) + + if (state.trick.isEmpty()) { + if (wantsTrick) { + val scored = cards.map { it to pWin(it) } + val bestOverall = scored.maxBy { it.second } + // With tricks to spare, save the wizard for when it is needed. + if (tricksLeft > stillNeeded) { + val bestPlain = scored.filter { !isWizardCard(it.first) }.maxByOrNull { it.second } + if (bestPlain != null && bestPlain.second >= 0.6) return bestPlain.first + } + return bestOverall.first + } + // Ducking: lead whatever is least likely to win, a jester best of all. + return cards.minWith(compareBy({ pWin(it) }, { it.rank.order })) + } + + val ledSuit = ledSuitOf(state.trick) + val order = state.trick.size + val remaining = state.options.playerCount - order - 1 + val bestOnTable = state.trick + .withIndex() + .maxOf { (index, played) -> trickStrength(played.card, index, trump, ledSuit) } + val strengthOf = { card: Card -> trickStrength(card, order, trump, ledSuit) } + val winners = cards.filter { strengthOf(it) > bestOnTable } + val losers = cards.filter { strengthOf(it) <= bestOnTable } + + fun sure(card: Card): Boolean = + remaining == 0 || unseen.none { trickStrength(it, order + 1, trump, ledSuit) > strengthOf(card) } + + if (wantsTrick) { + if (winners.isNotEmpty()) { + val certain = winners.filter { sure(it) } + return (certain.ifEmpty { winners }).minBy { strengthOf(it) } + } + // Cannot take this one: keep the strong cards for a trick that can be won. + return losers.minBy { strengthOf(it) } + } + if (spoiling && winners.isNotEmpty() && victimNeedsOne(state)) { + return winners.minBy { strengthOf(it) } + } + if (losers.isNotEmpty()) { + // Unload the card that would be hardest to lose later, but keep the + // jester: it is the one card that can never win. + val nonJester = losers.filter { !isJester(it) } + return (nonJester.ifEmpty { losers }).maxBy { strengthOf(it) } + } + return cards.minBy { strengthOf(it) } + } + private fun chooseLead(cards: List, trump: Suit?, wantsTrick: Boolean): Card { if (wantsTrick) { // A led wizard is an unloseable trick. diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt index 6fc9bb2..1fdcca5 100644 --- a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt @@ -1,6 +1,11 @@ package org.prolibertate.games.game.yahtzee +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi +import kotlin.math.exp +import kotlin.math.sqrt /** * Plays the dice by eye rather than by arithmetic. @@ -10,33 +15,53 @@ import org.prolibertate.games.game.engine.GameAi * enumerated the thirty-two subsets and applied them would be reading the roll * before making it — beating the player by seeing the future rather than by * playing well. Everything below decides from the dice already on the table. + * + * Per seat, from [profiles]: Easy sometimes holds a poor set of dice and + * sometimes writes a lesser box than the best on offer. Hard works out, for + * every way of holding dice, the *expected* value of what could be thrown + * (averaging over all outcomes, never over the roll that will actually come), + * valuing each box by what it scores less what that box is usually worth, with + * the upper-section bonus tracked. Boldness chases the bonus and big combos + * and rolls on with slimmer odds; caution takes a sure score sooner. */ -object YahtzeeAi : GameAi { +class YahtzeeAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : + GameAi { override fun chooseMove(state: YahtzeeState, seat: Int, legal: List): YahtzeeMove { require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val profile = profiles.of(seat) val rolls = legal.filterIsInstance() val writes = legal.filterIsInstance() // Nothing on the table yet, so there is only one thing to do. if (!state.hasRolled) return rolls.firstOrNull() ?: writes.first() + if (profile.level == AiLevel.HARD && writes.isNotEmpty()) { + return chooseExpected(state, seat, rolls, writes, profile) + } - val best = writes.maxByOrNull { scoreOf(it.category, state.dice) } + val ranked = writes.sortedByDescending { scoreOf(it.category, state.dice) } + // Easy sometimes writes the second or third best box rather than the best. + val best = if (ranked.isEmpty()) null else profile.pick(ranked.take(EASY_BOXES)) val bestScore = best?.let { scoreOf(it.category, state.dice) } ?: 0 // A hand already worth taking, or no rolls left, ends the turn. if (rolls.isEmpty()) return best ?: writes.first() - if (bestScore >= WORTH_KEEPING) return best!! + if (bestScore >= profile.bolder(WORTH_KEEPING.toDouble(), 4.0)) return best!! - val keep = whatToKeep(state.dice).toSet() + val keep = if (profile.slips()) poorKeep(state.dice) else whatToKeep(state.dice).toSet() return rolls.firstOrNull { it.keep == keep } ?: rolls.first() } - /** - * Good enough to stop throwing. Set at a full house, which is the point - * where a hand is worth more than an average reroll of it. - */ - private const val WORTH_KEEPING = 25 + /** What a distracted player holds: the high dice, or the pair they notice second. */ + private fun poorKeep(dice: List): Set { + val counts = countsOf(dice) + val second = counts.entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .drop(1) + .firstOrNull { it.value >= 2 } + if (second != null) return dice.indices.filter { dice[it] == second.key }.toSet() + return dice.indices.filter { dice[it] >= 4 }.toSet() + } /** * What to hold on to: a run if one is nearly there, otherwise the face there @@ -82,4 +107,213 @@ object YahtzeeAi : GameAi { face in wanted && taken.add(face) } } + + // -- Hard: expected values ------------------------------------------------ + + /** + * Values every box as what it scores now less what it is normally worth, so + * that writing a zero in Ones costs little and writing one in Yahtzee costs a + * lot, then looks two throws ahead by averaging over every possible outcome + * of every way of holding dice. + */ + private fun chooseExpected( + state: YahtzeeState, + seat: Int, + rolls: List, + writes: List, + profile: AiProfile, + ): YahtzeeMove { + val valuation = Valuation(state, seat, profile.style.boldness) + val dice = state.dice + val stop = writes.maxByOrNull { valuation.gain(it.category, dice) } ?: writes.first() + if (rolls.isEmpty()) return stop + val stopValue = valuation.gain(stop.category, dice) + + // terminal[i]: best write for the i-th multiset of dice with no throws left. + val terminal = DoubleArray(DiceTables.multisets.size) { i -> + val faces = DiceTables.facesOf(i) + writes.maxOf { valuation.gain(it.category, faces) } + } + // Each throw still to come after the next one, worked backwards. + var next = terminal + repeat(state.rollsLeft - 1) { + val previous = next + next = DoubleArray(terminal.size) { i -> + maxOf(terminal[i], bestKeep(i, previous).second) + } + } + val here = DiceTables.indexOf(countVector(dice)) + val (keepCounts, expected) = bestKeep(here, next) + val margin = -profile.style.boldness + if (expected <= stopValue + margin) return stop + + val remaining = keepCounts.copyOf() + val keep = dice.indices.filter { position -> + val face = dice[position] - 1 + if (remaining[face] > 0) { + remaining[face]-- + true + } else { + false + } + }.toSet() + return rolls.firstOrNull { it.keep == keep } ?: stop + } + + /** The best set of dice to hold from multiset [index], and what throwing the rest is worth. */ + private fun bestKeep(index: Int, after: DoubleArray): Pair { + var bestKeep = DiceTables.keepsOf(index).first() + var best = Double.NEGATIVE_INFINITY + for (keep in DiceTables.keepsOf(index)) { + val thrown = DICE_COUNT - keep.sum() + var key = 0 + for (face in 0 until 6) key += keep[face] * DiceTables.POWERS[face] + var total = 0.0 + for (outcome in DiceTables.rerolls[thrown]) { + total += outcome.probability * after[DiceTables.indexOfKey[key + outcome.key]] + } + if (total > best + 1e-9) { + best = total + bestKeep = keep + } + } + return bestKeep to best + } + + private fun countVector(dice: List): IntArray { + val counts = IntArray(6) + dice.forEach { counts[it - 1]++ } + return counts + } + + /** What a box is worth to this card right now, in points relative to its usual yield. */ + private class Valuation(state: YahtzeeState, seat: Int, private val boldness: Double) { + private val card = state.cards[seat] + private val yahtzeeBonus = state.options.yahtzeeBonus && card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE + private val openUpper = YahtzeeCategory.entries.filter { + it.section == YahtzeeSection.UPPER && !card.isFilled(it) + } + private val secured = card.upperSubtotal >= UPPER_BONUS_AT + private val diff = YahtzeeCategory.entries + .filter { it.section == YahtzeeSection.UPPER && card.isFilled(it) } + .sumOf { (card[it] ?: 0) - 3 * it.face!! } + private val bonusValue = UPPER_BONUS * (1.0 + 0.4 * boldness) + + fun gain(category: YahtzeeCategory, dice: List): Double { + val score = scoreOf(category, dice) + var value = score - TYPICAL[category.ordinal] + if (category.section == YahtzeeSection.UPPER && !secured) { + val face = category.face!! + val after = if (card.upperSubtotal + score >= UPPER_BONUS_AT) { + 1.0 + } else { + chance(diff + score - 3 * face, openUpper - category) + } + value += bonusValue * (after - chance(diff, openUpper)) + } + // The big combinations: a bold seat values them a little more. + if (score > 0 && (category == YahtzeeCategory.LARGE_STRAIGHT || + category == YahtzeeCategory.YAHTZEE || category == YahtzeeCategory.FOUR_OF_A_KIND) + ) { + value += boldness * 2.0 + } + if (yahtzeeBonus && isYahtzee(dice)) value += YAHTZEE_BONUS + return value + } + + /** Likelihood the upper section still reaches the bonus from [difference] with [open] to fill. */ + private fun chance(difference: Int, open: List): Double { + if (open.isEmpty()) return if (difference >= 0) 1.0 else 0.0 + val expected = open.sumOf { TYPICAL[it.ordinal] - 3 * it.face!! } + val spread = 2.2 * sqrt(open.size.toDouble()) + 0.3 + return 1.0 / (1.0 + exp(-(difference + expected + 1.5) / spread)) + } + } + + /** Every multiset of five dice and every throw of n dice, worked out once. */ + private object DiceTables { + class Outcome(val key: Int, val probability: Double) + + val POWERS = intArrayOf(1, 6, 36, 216, 1296, 7776) + val indexOfKey = IntArray(46656) { -1 } + val multisets: List = vectors(DICE_COUNT) + val rerolls: Array> = Array(DICE_COUNT + 1) { n -> + val factorial = IntArray(6) { f -> (1..f).fold(1) { a, b -> a * b } } + val total = Math.pow(6.0, n.toDouble()) + vectors(n).map { counts -> + val ways = (1..n).fold(1) { a, b -> a * b } / counts.fold(1) { a, c -> a * factorial[c] } + Outcome(keyOf(counts), ways / total) + } + } + private val keeps: Array?> = arrayOfNulls(multisets.size) + + init { + multisets.forEachIndexed { i, counts -> indexOfKey[keyOf(counts)] = i } + } + + fun indexOf(counts: IntArray): Int = indexOfKey[keyOf(counts)] + + fun facesOf(index: Int): List { + val faces = ArrayList(DICE_COUNT) + multisets[index].forEachIndexed { f, c -> repeat(c) { faces += f + 1 } } + return faces + } + + /** Every distinct set of dice that can be held back from multiset [index]. */ + fun keepsOf(index: Int): List { + keeps[index]?.let { return it } + val counts = multisets[index] + var all = listOf(IntArray(6)) + for (face in 0 until 6) { + all = all.flatMap { partial -> + (0..counts[face]).map { n -> partial.copyOf().also { it[face] = n } } + } + } + keeps[index] = all + return all + } + + private fun keyOf(counts: IntArray): Int { + var key = 0 + for (face in 0 until 6) key += counts[face] * POWERS[face] + return key + } + + /** Every way to split [size] dice over six faces. */ + private fun vectors(size: Int): List { + val out = ArrayList() + fun build(face: Int, left: Int, current: IntArray) { + if (face == 5) { + current[5] = left + out += current.copyOf() + return + } + for (n in 0..left) { + current[face] = n + build(face + 1, left - n, current) + } + } + build(0, size, IntArray(6)) + return out + } + } + + private companion object { + /** + * Good enough to stop throwing. Set at a full house, which is the point + * where a hand is worth more than an average reroll of it. + */ + const val WORTH_KEEPING = 25 + + /** How many of the best boxes Easy chooses among when it writes a score. */ + const val EASY_BOXES = 3 + + /** + * What each box yields on average under strong play, in the order of + * [YahtzeeCategory]: the cost of spending it. + */ + val TYPICAL = doubleArrayOf( + 2.11, 5.28, 8.57, 12.16, 15.69, 19.19, 21.66, 13.10, 22.59, 29.46, 32.71, 16.87, 22.01, + ) + } } diff --git a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt index c1e1f2c..c1eeaf4 100644 --- a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt +++ b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.prolibertate.games.game.engine.AiDifficulty import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerSlot import org.prolibertate.games.game.engine.TableConfig @@ -33,6 +34,7 @@ class LobbyController( val connected: Boolean = false, val gameId: String? = null, val optionsJson: String = "{}", + val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM, val hostName: String = "", val seats: List = emptyList(), val discovered: List = emptyList(), @@ -88,6 +90,7 @@ class LobbyController( hostName: String, seatCount: Int, teamOf: (Int) -> Int, + aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM, ) { stop() val seats = (0 until seatCount).map { seat -> @@ -102,6 +105,7 @@ class LobbyController( hosting = true, gameId = gameId, optionsJson = optionsJson, + aiDifficulty = aiDifficulty, hostName = hostName, seats = seats, message = "Waiting for players…", @@ -237,6 +241,7 @@ class LobbyController( seats = current.seats, optionsJson = current.optionsJson, seed = seed, + aiDifficulty = current.aiDifficulty, ) scope.launch { connections.values.forEach { it.send(StartGame(config)) } } _state.value = current.copy(started = config) diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index e8544e2..3674a23 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -57,6 +57,7 @@ import org.prolibertate.games.update.UpdateChannel import org.prolibertate.games.update.Updater import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.prolibertate.games.game.engine.AiDifficulty import org.prolibertate.games.game.engine.SavedGameStore import org.prolibertate.games.game.engine.SavedMatch @@ -92,7 +93,12 @@ sealed interface Route { /** Every saved game, to carry on from or delete. */ data object Resume : Route data class Setup(val gameId: String) : Route - data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route + data class Lobby( + val gameId: String, + val optionsJson: String, + val hosting: Boolean, + val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM, + ) : Route data class Play( val gameId: String, val config: TableConfig, @@ -250,8 +256,8 @@ private fun AppContent( playerName = settings.displayName, onBack = { pop() }, onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) }, - onHostOnline = { optionsJson -> - push(Route.Lobby(route.gameId, optionsJson, hosting = true)) + onHostOnline = { optionsJson, aiDifficulty -> + push(Route.Lobby(route.gameId, optionsJson, hosting = true, aiDifficulty = aiDifficulty)) }, saved = saved, onResume = { match -> 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 7c43ad7..5c61086 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -30,15 +30,13 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameDescriptor +import org.prolibertate.games.game.engine.AiDifficulty import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerSlot import org.prolibertate.games.game.engine.SavedMatch import org.prolibertate.games.game.engine.TableConfig -import org.prolibertate.games.game.backgammon.BackgammonLevel import org.prolibertate.games.game.backgammon.BackgammonOptions -import org.prolibertate.games.game.checkers.CheckersLevel import org.prolibertate.games.game.checkers.CheckersOptions -import org.prolibertate.games.game.chess.ChessLevel import org.prolibertate.games.game.chess.ChessOptions import org.prolibertate.games.game.crazyeights.CrazyEightsOptions import org.prolibertate.games.game.cribbage.CribbageOptions @@ -56,15 +54,14 @@ import org.prolibertate.games.game.pegsolitaire.PegBoard import org.prolibertate.games.game.pegsolitaire.PegGoal import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions import org.prolibertate.games.game.kaiser.KaiserOptions -import org.prolibertate.games.game.mastermind.MastermindLevel import org.prolibertate.games.game.mastermind.MastermindOptions -import org.prolibertate.games.game.morris.MorrisLevel import org.prolibertate.games.game.morris.MorrisOptions -import org.prolibertate.games.game.pirates.PiratesLevel import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.catan.CatanOptions -import org.prolibertate.games.game.catan.CatanRuleSet +import org.prolibertate.games.game.catan.CatanExpansion +import org.prolibertate.games.game.catan.SeaScenario +import org.prolibertate.games.game.catan.BaseScenario import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuOptions @@ -88,7 +85,7 @@ fun GameSetupScreen( playerName: String, onBack: () -> Unit, onPlayOffline: (TableConfig, Int) -> Unit, - onHostOnline: (String) -> Unit, + onHostOnline: (String, AiDifficulty) -> Unit, /** A game of this kind left half-played, if there is one. */ saved: SavedMatch? = null, onResume: (SavedMatch) -> Unit = {}, @@ -124,6 +121,7 @@ fun GameSetupScreen( // Pirates and Bulgars is not symmetrical, so which side you take is the // first thing about the game rather than a detail of it. var playPirates by remember { mutableStateOf(false) } + var aiDifficulty by remember { mutableStateOf(AiDifficulty.MEDIUM) } val optionsJson = when (descriptor.id) { GameCatalog.EUCHRE -> setupJson.encodeToString(euchre) @@ -243,6 +241,22 @@ fun GameSetupScreen( style = MaterialTheme.typography.bodySmall, ) + if (!solo) { + ChipRow( + label = "Computer difficulty", + values = AiDifficulty.entries.toList(), + selected = aiDifficulty, + display = { it.label }, + onSelect = { aiDifficulty = it }, + ) + if (aiDifficulty == AiDifficulty.MIXED) { + Text( + "Each computer player gets its own level.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + // Two games let the local player pick a seat rather than always // taking the first one: chess by colour, and Pirates and Bulgars by // which side of a very lopsided fight they fancy. @@ -259,13 +273,13 @@ fun GameSetupScreen( } PrimaryAction(text = if (solo) "Play" else "Play against the computer") { onPlayOffline( - offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat), + offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat, aiDifficulty), localSeat, ) } if (!solo) { PrimaryAction(text = "Host a game for others to join") { - onHostOnline(optionsJson) + onHostOnline(optionsJson, aiDifficulty) } } } @@ -279,6 +293,7 @@ private fun offlineConfig( seatCount: Int, playerName: String, localSeat: Int = 0, + aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM, ): TableConfig = TableConfig( gameId = gameId, seats = (0 until seatCount).map { seat -> @@ -292,6 +307,7 @@ private fun offlineConfig( optionsJson = optionsJson, // Seeded from the clock so consecutive games are not identical. seed = System.currentTimeMillis(), + aiDifficulty = aiDifficulty, ) /** @@ -359,47 +375,50 @@ 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 targets the setup screen offers for a Katan game with [expansions] on. */ +internal fun catanPointsChoices(expansions: Set): List = when { + // Both: Cities & Knights' long game plus the new-island points. + expansions.size > 1 -> listOf(10, 12, 14, 16) // Seafarers adds two points for each new island, so the target is higher. - CatanRuleSet.SEAFARERS -> listOf(8, 10, 12, 14) + CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14) + // Cities & Knights is played to thirteen, which is also why it runs longer. + CatanExpansion.CITIES_AND_KNIGHTS in expansions -> listOf(8, 10, 13, 15) + // Five is a short game for trying things out; ten is the published target. + else -> listOf(5, 8, 10, 12) } -/** The published target for a rule set. */ -internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) { - CatanRuleSet.BASE -> 10 - CatanRuleSet.CITIES_AND_KNIGHTS -> 13 - CatanRuleSet.SEAFARERS -> 12 +/** The published target for a game with [expansions] on. */ +internal fun catanDefaultPoints(expansions: Set): Int = when { + expansions.size > 1 -> 14 + CatanExpansion.SEAFARERS in expansions -> 12 + CatanExpansion.CITIES_AND_KNIGHTS in expansions -> 13 + else -> 10 } /** - * [options] switched to [ruleSet]. The target moves to the new rule set's own + * [options] with [expansion] switched on or off. The target moves to the new combination'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), +internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions { + val expansions = if (on) options.expansions + expansion else options.expansions - expansion + return options.copy( + expansions = expansions, + pointsToWin = options.pointsToWin.takeIf { + it in catanPointsChoices(expansions) && it != catanDefaultPoints(options.expansions) + } ?: catanDefaultPoints(expansions), ) - -private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) { - CatanRuleSet.BASE -> "Base game" - CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights" - CatanRuleSet.SEAFARERS -> "Seafarers" } -private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) { - CatanRuleSet.BASE -> - "Settle the island, trade, and play development cards. Longest Road and Largest Army score." - CatanRuleSet.CITIES_AND_KNIGHTS -> +private fun catanExpansionName(expansion: CatanExpansion): String = when (expansion) { + CatanExpansion.CITIES_AND_KNIGHTS -> "Cities & Knights" + CatanExpansion.SEAFARERS -> "Seafarers" +} + +private fun catanExpansionBlurb(expansion: CatanExpansion): String = when (expansion) { + CatanExpansion.CITIES_AND_KNIGHTS -> "Cities make commodities, knights hold off the barbarians, and improvements earn progress " + "cards and metropolises. No development cards; the robber sleeps until the first attack." - CatanRuleSet.SEAFARERS -> + CatanExpansion.SEAFARERS -> "Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " + "and the first settlement on each new island scores two extra points." } @@ -407,14 +426,33 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) { @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) + CatanExpansion.entries.forEach { expansion -> + ToggleRow( + title = catanExpansionName(expansion), + subtitle = catanExpansionBlurb(expansion), + checked = expansion in options.expansions, + onChange = { onChange(catanToggleExpansion(options, expansion, it)) }, + ) + } + if (options.seafarers) { + ChipRow( + label = "Map", + values = SeaScenario.entries, + selected = options.scenario, + display = { it.title }, + onSelect = { onChange(options.copy(scenario = it)) }, + ) + Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall) + } else { + ChipRow( + label = "Island", + values = BaseScenario.entries, + selected = options.baseScenario, + display = { it.title }, + onSelect = { onChange(options.copy(baseScenario = it)) }, + ) + Text(options.baseScenario.blurb, style = MaterialTheme.typography.bodySmall) + } ChipRow( label = "Players", values = listOf(3, 4), @@ -424,7 +462,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) - ) ChipRow( label = "Game to", - values = catanPointsChoices(options.ruleSet), + values = catanPointsChoices(options.expansions), selected = options.pointsToWin, display = { "$it points" }, onSelect = { onChange(options.copy(pointsToWin = it)) }, @@ -765,13 +803,6 @@ private fun ChessOptionsEditor( display = { if (it) "White" else "Black" }, onSelect = onColour, ) - ChipRow( - label = "Opponent", - values = ChessLevel.entries.toList(), - selected = options.level, - display = { it.label }, - onSelect = { onChange(options.copy(level = it)) }, - ) ToggleRow( title = "Fifty-move rule", subtitle = "Fifty moves each with no capture and no pawn move is a draw", @@ -795,13 +826,6 @@ private fun ChessOptionsEditor( @Composable private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ChipRow( - label = "Opponent", - values = CheckersLevel.entries.toList(), - selected = options.level, - display = { it.label }, - onSelect = { onChange(options.copy(level = it)) }, - ) ToggleRow( title = "Flying kings", subtitle = "A king slides the length of a diagonal and takes from a distance, " + @@ -836,13 +860,6 @@ private fun BackgammonOptionsEditor( onChange: (BackgammonOptions) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ChipRow( - label = "Opponent", - values = BackgammonLevel.entries.toList(), - selected = options.level, - display = { it.label }, - onSelect = { onChange(options.copy(level = it)) }, - ) ToggleRow( title = "Count gammons", subtitle = "A loser who bore nothing off has lost double, and one still on the " + @@ -864,13 +881,6 @@ private fun MastermindOptionsEditor( onChange: (MastermindOptions) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ChipRow( - label = "Opponent", - values = MastermindLevel.entries.toList(), - selected = options.level, - display = { it.label }, - onSelect = { onChange(options.copy(level = it)) }, - ) ChipRow( label = "Colours", values = listOf(6, 8), @@ -935,13 +945,6 @@ private fun PiratesOptionsEditor( display = { if (it) "the two pirates" else "the twenty-four Bulgars" }, onSelect = onSide, ) - ChipRow( - label = "Opponent", - values = PiratesLevel.entries.toList(), - selected = options.level, - display = { it.label }, - onSelect = { onChange(options.copy(level = it)) }, - ) ToggleRow( title = "A pirate who can take must take", subtitle = "The rule that huffing exists to enforce, applied directly", @@ -967,13 +970,6 @@ private fun PiratesOptionsEditor( @Composable private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ChipRow( - label = "Opponent", - values = MorrisLevel.entries.toList(), - selected = options.level, - display = { it.label }, - onSelect = { onChange(options.copy(level = it)) }, - ) ChipRow( label = "Pieces each", values = listOf(3, 6, 9), diff --git a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt index ee28ecc..f9baa60 100644 --- a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt @@ -60,6 +60,7 @@ fun LobbyScreen( hostName = playerName, seatCount = seatCount, teamOf = { seat -> teamForSeat(route.gameId, seat, seatCount) }, + aiDifficulty = route.aiDifficulty, ) } else { env.lobby.startDiscovery() diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index ff3039b..0f69aa7 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -120,6 +120,7 @@ import org.prolibertate.games.game.wizard.WizardRules import org.prolibertate.games.game.wizard.WizardState import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchSaver +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.settings.Settings import org.prolibertate.games.ui.game.BackgammonScreen @@ -174,7 +175,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = EuchreRules, - ai = EuchreAi(), + ai = EuchreAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -213,7 +214,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = SequenceRules, - ai = SequenceAi(), + ai = SequenceAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -230,7 +231,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = PresidentRules, - ai = PresidentAi(), + ai = PresidentAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -254,7 +255,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = GolfRules, - ai = GolfAi(), + ai = GolfAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -276,7 +277,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = KaiserRules, - ai = KaiserAi(), + ai = KaiserAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -312,7 +313,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = WizardRules, - ai = WizardAi(), + ai = WizardAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -350,7 +351,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = CrazyEightsRules, - ai = CrazyEightsAi(), + ai = CrazyEightsAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -377,7 +378,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = CribbageRules, - ai = CribbageAi(), + ai = CribbageAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -406,7 +407,7 @@ fun PlayScreen( rules = TayuRules, // As with chess, the strength is part of the table's own // options, so this does not have to decode anything. - ai = TayuAi(), + ai = TayuAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -423,7 +424,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = CheckersRules, - ai = CheckersAi(), + ai = CheckersAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -440,7 +441,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = BackgammonRules, - ai = BackgammonAi(), + ai = BackgammonAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -459,7 +460,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = MastermindRules, - ai = MastermindAi(), + ai = MastermindAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -476,7 +477,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = PiratesRules, - ai = PiratesAi(), + ai = PiratesAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -493,7 +494,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = HeartsRules, - ai = HeartsAi, + ai = HeartsAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -582,7 +583,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = YahtzeeRules, - ai = YahtzeeAi, + ai = YahtzeeAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -684,7 +685,7 @@ fun PlayScreen( rules = MorrisRules, // As with chess, the strength is part of the table's own // options, so this does not have to decode anything. - ai = MorrisAi(), + ai = MorrisAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -705,7 +706,7 @@ fun PlayScreen( rules = ChessRules, // Strength comes from the table's own options, so the setup // screen sets it without this having to decode anything. - ai = ChessAi(), + ai = ChessAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, @@ -724,7 +725,7 @@ fun PlayScreen( val controller = remember(route) { MatchController( rules = CatanRules, - ai = CatanAi(), + ai = CatanAi(AiProfiles(route.config)), config = route.config, scope = scope, role = role, 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 85397b0..33ae55c 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 @@ -33,7 +33,9 @@ import org.prolibertate.games.game.catan.CatanBoard import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.NO_PLAYER import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.SeaRules import org.prolibertate.games.game.catan.Terrain +import org.prolibertate.games.game.catan.TribeChip /** * The island, drawn and tappable. @@ -156,6 +158,39 @@ private fun DrawScope.drawHexes( val number = state.numbers[hex] if (number != 0) drawToken(number, centre, board, style, measurer) + if (state.terrains[hex] == Terrain.FOG) { + drawCentered( + measurer = measurer, + text = "?", + at = centre, + fontPx = board.radius * 0.9f, + color = style.hexEdge.copy(alpha = 0.7f), + weight = FontWeight.Bold, + ) + } + val sea = state.sea + val note = when { + state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth" + sea != null && sea.canalNumbers.getOrElse(hex) { 0 } != 0 && state.terrains[hex] == Terrain.DESERT -> + "dug ${sea.canalUnits.getOrElse(hex) { 0 }}/${SeaRules.CANAL_TILE_UNITS}" + state.terrains[hex] == Terrain.FORTRESS && sea != null -> "fort ${sea.fortress.getOrElse(hex) { 0 }}" + else -> when (sea?.chips?.getOrNull(hex)) { + TribeChip.POINT -> "+1 VP" + TribeChip.HARBOUR -> "3:1" + TribeChip.GOLD -> "gold" + else -> null + } + } + if (note != null) { + drawCentered( + measurer = measurer, + text = note, + at = Pt(centre.x, centre.y + board.radius * 0.62f), + fontPx = board.radius * 0.26f, + color = style.tokenInk, + weight = FontWeight.Bold, + ) + } } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt index 933980c..f6fe760 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt @@ -173,6 +173,9 @@ object CatanHudGlyphs { BuildKind.WALL -> "▦" BuildKind.KNIGHT -> "♞" BuildKind.SHIP -> "⛵" + BuildKind.RAID -> "⚔" + BuildKind.DIG -> "⛏" + BuildKind.WONDER -> "✦" } fun label(kind: BuildKind): String = when (kind) { @@ -182,6 +185,9 @@ object CatanHudGlyphs { BuildKind.WALL -> CatanText.WALL BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP + BuildKind.RAID -> CatanText.RAID + BuildKind.DIG -> CatanText.DIG + BuildKind.WONDER -> CatanText.WONDER } } @@ -208,7 +214,7 @@ fun HudConfirmButtons( fun ZoomPanBox( modifier: Modifier = Modifier, minScale: Float = 1f, - maxScale: Float = 3f, + maxScale: Float = 6f, content: @Composable () -> Unit, ) { var scale by remember { mutableFloatStateOf(1f) } 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 80b486e..8b676fc 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 @@ -6,7 +6,10 @@ import org.prolibertate.games.game.catan.ActivateKnight import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildRoad import org.prolibertate.games.game.catan.BuildShip +import org.prolibertate.games.game.catan.BuildWonder +import org.prolibertate.games.game.catan.DigCanal import org.prolibertate.games.game.catan.MovePirate +import org.prolibertate.games.game.catan.RaidFortress import org.prolibertate.games.game.catan.MoveShip import org.prolibertate.games.game.catan.Topology import org.prolibertate.games.game.catan.BuildSettlement @@ -67,13 +70,38 @@ enum class BuildKind { ROAD, SETTLEMENT, CITY, WALL, KNIGHT, /** Seafarers: a ship, laid on a sea edge. */ - SHIP; + SHIP, + + /** Pirate Islands: raid a fortress; the target is the fortress hex. */ + RAID, + + /** Great Canal: dig a canal tile; the target is the tile. */ + DIG, + + /** Wonders of Catan: the next stage of the wonder, which needs no tap on the board. */ + WONDER; companion object { val BASE: List = listOf(ROAD, SETTLEMENT, CITY) /** Seafarers adds the ship, listed after the road it sails beside. */ val SEAFARERS: List = listOf(ROAD, SHIP, SETTLEMENT, CITY) + + /** The kinds a table's build menu lists: the scenario's own raid or wonder follows the usual ones. */ + fun of(state: CatanState): List { + val sea = state.sea + val extras = buildList { + if (sea != null && sea.fortress.isNotEmpty()) add(RAID) + if (sea != null && sea.canalUnits.isNotEmpty()) add(DIG) + if (sea != null && sea.wonders.isNotEmpty()) add(WONDER) + } + val usual = when { + state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, DIG, WONDER) + state.isSea -> SEAFARERS + else -> BASE + } + return usual + extras + } } } @@ -252,6 +280,13 @@ object CatanInteraction { return when (building) { BuildKind.ROAD -> BoardTargets(edges = roads) BuildKind.SHIP -> BoardTargets(edges = ships) + BuildKind.RAID -> BoardTargets( + hexes = legal.filterIsInstance().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(), + ) + BuildKind.DIG -> BoardTargets( + hexes = legal.filterIsInstance().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(), + ) + BuildKind.WONDER -> BoardTargets() BuildKind.SETTLEMENT -> BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) @@ -519,6 +554,7 @@ object CatanInteraction { val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored val moves = legal.filterIsInstance().filter { it.hex == coord } val pirates = legal.filterIsInstance().filter { it.hex == coord } + legal.firstOrNull { (it is RaidFortress && it.hex == coord) || (it is DigCanal && it.hex == coord) }?.let { return TapOutcome.Submit(it) } return when { moves.isNotEmpty() -> if (moves.size == 1) { TapOutcome.Submit(moves.first()) @@ -586,6 +622,9 @@ object CatanInteraction { if (legal.any { it is BuildShip }) add(BuildKind.SHIP) if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT) if (legal.any { it is BuildCity }) add(BuildKind.CITY) + if (legal.any { it is RaidFortress }) add(BuildKind.RAID) + if (legal.any { it is DigCanal }) add(BuildKind.DIG) + if (legal.any { it is BuildWonder }) add(BuildKind.WONDER) if (legal.any { it is BuildWall }) add(BuildKind.WALL) if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT) } 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 00f79e2..6560fe4 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 @@ -256,6 +256,8 @@ fun CatanPlayerCard( if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis") } if (ck.merchant?.seat == seat) add("Merchant") + if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster") + addAll(seaAwards(state, seat, compact)) } CardLine( text = ckAwards.joinToString(" · ").ifEmpty { BLANK }, @@ -280,6 +282,8 @@ fun CatanPlayerCard( val awards = buildList { if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road") if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army") + if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster") + addAll(seaAwards(state, seat, compact)) } // Always drawn, so the card does not grow when somebody takes an award. CardLine( @@ -598,6 +602,9 @@ private fun buildLabel(kind: BuildKind): String = when (kind) { BuildKind.WALL -> CatanText.WALL BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP + BuildKind.RAID -> CatanText.RAID + BuildKind.DIG -> CatanText.DIG + BuildKind.WONDER -> CatanText.WONDER } private fun buildCost(kind: BuildKind): String = when (kind) { @@ -607,6 +614,9 @@ private fun buildCost(kind: BuildKind): String = when (kind) { BuildKind.WALL -> CatanText.WALL_COST BuildKind.KNIGHT -> CatanText.KNIGHT_COST BuildKind.SHIP -> CatanText.SHIP_COST_TEXT + BuildKind.RAID -> CatanText.RAID_COST + BuildKind.DIG -> CatanText.DIG_COST + BuildKind.WONDER -> CatanText.WONDER_COST } @Composable @@ -634,3 +644,14 @@ fun CatanLog(state: CatanState, modifier: Modifier = Modifier) { } } } + +/** Seafarers scenario holdings worth a word on a player's card: cloth, and a tribe harbour. */ +private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List { + val sea = state.sea ?: return emptyList() + return buildList { + if (sea.cloth.isNotEmpty()) add(if (compact) "${sea.cloth[seat]}c" else "Cloth ${sea.cloth[seat]}") + if (sea.canalDug.isNotEmpty()) add(if (compact) "${sea.canalDug[seat]}⛏" else "Dug ${sea.canalDug[seat]}") + if (sea.wonders.isNotEmpty()) add(if (compact) "W${sea.wonders[seat]}" else "Wonder ${sea.wonders[seat]}/4") + if (seat in sea.harbourSeats) add(if (compact) "3:1" else "Harbour 3:1") + } +} 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 b421fec..c083776 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 @@ -39,6 +39,7 @@ 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.BuildWonder import org.prolibertate.games.game.catan.BuyDevCard import org.prolibertate.games.game.catan.CatanCk import org.prolibertate.games.game.catan.CatanMove @@ -159,6 +160,14 @@ fun CatanScreen( } } + // A wonder stage needs no tap on the board: choosing it from the menu builds it. + LaunchedEffect(building) { + if (building == BuildKind.WONDER) { + legal.firstOrNull { it is BuildWonder }?.let { controller.submit(it) } + building = null + } + } + val submit: (CatanMove) -> Unit = { move -> controller.submit(move) building = null @@ -197,11 +206,7 @@ fun CatanScreen( val progressHand = CatanInteraction.progressHand(current, localSeat, legal) val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val actions = CatanActions( - buildKinds = when { - current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP } - current.isSea -> BuildKind.SEAFARERS - else -> BuildKind.BASE - }, + buildKinds = BuildKind.of(current), ck = current.isCk, canImprove = CatanInteraction.improvable(legal).isNotEmpty(), canPlayProgress = progressHand.any { it.playable }, @@ -374,7 +379,8 @@ fun CatanScreen( players(false) if (current.isCk) CatanBarbarians(current, style = style) promptRow() - Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + // Pinch to zoom and drag to pan, as in landscape: a wide Seafarers map is small at fit. + ZoomPanBox(Modifier.weight(1f).fillMaxWidth()) { board(Modifier) } handAndActions() 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 50c1df0..273df93 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 @@ -124,6 +124,12 @@ private val LightStyle = CatanStyle( // Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow. Terrain.SEA to Color(0xFF3A7CA5), Terrain.GOLD to Color(0xFFF2B01E), + // Land not yet found: a pale grey bank of fog with a question mark. + Terrain.FOG to Color(0xFFB9C2C9), + // Cloth villages: a dyed-cloth purple. + Terrain.VILLAGE to Color(0xFF9B6BB5), + // A pirate fortress: dark stone. + Terrain.FORTRESS to Color(0xFF4A4F57), ), hexEdge = Color(0x55000000), token = Color(0xFFF7EFD9), 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 8e00cda..1d85fff 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 @@ -74,6 +74,9 @@ object CatanText { Terrain.DESERT -> "Desert" Terrain.SEA -> "Sea" Terrain.GOLD -> "Gold field" + Terrain.FOG -> "Fog" + Terrain.VILLAGE -> "Village" + Terrain.FORTRESS -> "Pirate fortress" } fun devCard(type: DevCardType): String = when (type) { @@ -113,6 +116,12 @@ object CatanText { const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)." const val SHIP = "Ship" const val SHIP_COST_TEXT = "1 lumber + 1 wool" + const val RAID = "Raid" + const val RAID_COST = "1 ore + 1 wool + 1 grain" + const val DIG = "Dig" + const val DIG_COST = "1 brick + 1 ore" + const val WONDER = "Wonder" + const val WONDER_COST = "next stage" const val GOLD_TITLE = "Gold field" const val GOLD_TAKE = "Take" const val ROLL = "Roll dice" @@ -193,7 +202,7 @@ object CatanText { /** Seafarers: a ship has been picked to sail, and its destination is awaited. */ movingShip: Boolean = false, ): String { - if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed) + if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed, movingShip) val me = seats.me val onClock = CatanRules.currentSeat(state) val mine = onClock == me @@ -246,6 +255,8 @@ object CatanText { !mine -> "$turn is playing…" movingShip -> "Tap the sea edge to sail the ship to." building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship." + building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it." + building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit." 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." @@ -400,6 +411,7 @@ object CatanText { building: BuildKind?, movingKnight: Boolean, armed: ProgressType?, + movingShip: Boolean, ): String { val me = seats.me val onClock = CatanRules.currentSeat(state) @@ -408,7 +420,12 @@ object CatanText { val ck = state.ck!! return when (state.phase) { - CatanPhase.GOLD -> "" + CatanPhase.GOLD -> + if (mine) { + "Gold! Choose ${state.sea?.gold?.firstOrNull()?.count ?: 1} resource cards." + } else { + "${seats.label(onClock ?: state.turn)} is choosing from the gold…" + } CatanPhase.GAME_OVER -> { val winner = state.winner @@ -434,6 +451,7 @@ object CatanText { CatanPhase.ROBBER -> when { !mine -> "$turn is moving the robber…" ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card." + state.isSea -> SEA_ROBBER else -> "Move the robber. Tap a hex." } @@ -441,6 +459,10 @@ object CatanText { !mine -> "$turn is playing…" armed != null -> armedPrompt(armed) movingKnight -> "Tap where the knight should go." + movingShip -> "Tap the sea edge to sail the ship to." + building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship." + building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it." + building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit." 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." diff --git a/app/src/test/java/org/prolibertate/games/game/backgammon/BackgammonAiProfileTest.kt b/app/src/test/java/org/prolibertate/games/game/backgammon/BackgammonAiProfileTest.kt new file mode 100644 index 0000000..0131130 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/backgammon/BackgammonAiProfileTest.kt @@ -0,0 +1,43 @@ +package org.prolibertate.games.game.backgammon + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles + +class BackgammonAiProfileTest { + + private fun playout(ai: BackgammonAi, seed: Long, steps: Int): List { + var state = BackgammonRules.initialState(BackgammonOptions(), seed) + val chosen = mutableListOf() + repeat(steps) { + if (BackgammonRules.isFinished(state)) return chosen + val seat = BackgammonRules.currentSeat(state) ?: return chosen + val legal = BackgammonRules.legalMoves(state, seat) + if (legal.isEmpty()) return chosen + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", move in legal) + chosen += move + state = BackgammonRules.applyMove(state, seat, move) + } + return chosen + } + + @Test + fun everyLevelAndStyleReturnsLegalMoves() { + for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD, AiDifficulty.MIXED)) { + for (seed in 0L..5L) { + val moves = playout(BackgammonAi(AiProfiles(difficulty, seed)), seed, 14) + assertTrue(moves.isNotEmpty()) + } + } + } + + @Test + fun defaultProfilePlaysAsBefore() { + val plain = playout(BackgammonAi(), 3L, 20) + val medium = playout(BackgammonAi(AiProfiles(AiDifficulty.MEDIUM, 77L)), 3L, 20) + assertEquals(plain, medium) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanAiProfilesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiProfilesTest.kt new file mode 100644 index 0000000..14d0d2b --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiProfilesTest.kt @@ -0,0 +1,105 @@ +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.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.AiStyle +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** Computer seats at every level and style play legal, forward-moving games in each Katan rule set. */ +class CatanAiProfilesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: CatanOptions, seed: Long) = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + /** Plays up to [cap] moves, checking every one; returns the last state. */ + private fun play(options: CatanOptions, seed: Long, profiles: AiProfiles, cap: Int): CatanState { + val ai = CatanAi(profiles) + var state = CatanRules.initialState(config(options, seed)) + var moves = 0 + while (!CatanRules.isFinished(state) && 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: chose $move outside the legal list in ${state.phase}", move in legal || CatanRules.isLegalMove(state, seat, move)) + assertTrue( + "seed $seed: seat $seat chose the illegal move $move in ${state.phase}", + CatanRules.isLegalMove(state, seat, move), + ) + state = CatanRules.applyMove(state, seat, move) + } + return state + } + + /** A table seed on which some seat among the first three has [style] at [difficulty]. */ + private fun tableSeedFor(difficulty: AiDifficulty, style: AiStyle): Long = + (1L..2_000L).first { s -> + val p = AiProfiles(difficulty, s) + (0 until 3).any { p.of(it).style == style } + } + + private fun forEveryProfile(rules: Set, cap: Int) { + val options = CatanOptions( + playerCount = 3, + pointsToWin = when { + CatanExpansion.CITIES_AND_KNIGHTS in rules -> 13 + CatanExpansion.SEAFARERS in rules -> 12 + else -> 10 + }, + expansions = rules, + ) + for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) { + for (style in AiStyle.entries) { + val tableSeed = tableSeedFor(difficulty, style) + val state = play(options, 40L + style.ordinal, AiProfiles(difficulty, tableSeed), cap) + assertTrue( + "$rules $difficulty $style: the game never got past setup", + state.turnNumber > 3 || CatanRules.isFinished(state), + ) + } + } + } + + @Test + fun `easy and hard seats of every style play the base game`() = forEveryProfile(emptySet(), 1_500) + + @Test + fun `easy and hard seats of every style play Cities and Knights`() = + forEveryProfile(setOf(CatanExpansion.CITIES_AND_KNIGHTS), 1_500) + + @Test + fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(setOf(CatanExpansion.SEAFARERS), 1_500) + + @Test + fun `a mixed table of four finishes a base game`() { + val state = play(CatanOptions(playerCount = 4), 77L, AiProfiles(AiDifficulty.MIXED, 5L), 12_000) + assertEquals(CatanPhase.GAME_OVER, state.phase) + } + + @Test + fun `the default profile plays exactly as an explicit medium table does`() { + for (rules in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) { + val options = CatanOptions(playerCount = 3, pointsToWin = 10, expansions = rules) + val plain = play(options, 9L, AiProfiles.DEFAULT, 600) + val medium = play(options, 9L, AiProfiles(AiDifficulty.MEDIUM, 1234L), 600) + assertEquals("$rules", plain, medium) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanBaseScenarioTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanBaseScenarioTest.kt new file mode 100644 index 0000000..bb9be3a --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanBaseScenarioTest.kt @@ -0,0 +1,115 @@ +package org.prolibertate.games.game.catan + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +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 base-island scenarios: First Island's fixed board and the Harbormaster. */ +class CatanBaseScenarioTest { + + 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, + ) + + private fun game( + scenario: BaseScenario, + seed: Long = 5L, + expansions: Set = emptySet(), + players: Int = 3, + ) = CatanRules.initialState(config(CatanOptions(players, 10, expansions, baseScenario = scenario), seed)) + + @Test + fun `first island is the same board for every seed and the random map is not`() { + val a = game(BaseScenario.FIRST_ISLAND, 1L) + val b = game(BaseScenario.FIRST_ISLAND, 99L) + assertEquals(a.terrains, b.terrains) + assertEquals(a.numbers, b.numbers) + assertEquals(a.ports, b.ports) + assertNotEquals(game(BaseScenario.RANDOM_MAP, 1L).terrains, game(BaseScenario.RANDOM_MAP, 99L).terrains) + // The deck and the seating still follow the table's own seed. + assertNotEquals(a.devDeck, b.devDeck) + } + + @Test + fun `a save from before base scenarios reads as a random map`() { + val old = """{"playerCount":3,"pointsToWin":10,"expansions":[],"scenario":"NEW_SHORES"}""" + assertEquals(BaseScenario.RANDOM_MAP, json.decodeFromString(old).baseScenario) + val options = CatanOptions(3, 10, baseScenario = BaseScenario.HARBORMASTER) + assertEquals(options, json.decodeFromString(json.encodeToString(options))) + } + + /** Vertices of the harbours, one per harbour, in board order. */ + private fun CatanState.harbourVertices(): List = ports.map { topo.EDGE_VERTICES[it.edge].first } + + private fun CatanState.with(seat: Int, vertex: Int, type: BuildingType) = + copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) }) + + @Test + fun `three harbour points make a harbormaster and more takes the title`() { + var s = game(BaseScenario.HARBORMASTER).copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1) + val v = s.harbourVertices() + s = s.with(0, v[0], BuildingType.SETTLEMENT).with(0, v[1], BuildingType.SETTLEMENT) + assertEquals(2, CatanRules.harbourPoints(s, 0)) + assertNull(CatanRules.refreshHarbormaster(s).harbormaster) + + // A city counts two: 1 + 2 = 3. + s = CatanRules.refreshHarbormaster(s.with(0, v[1], BuildingType.CITY)) + assertEquals(3, CatanRules.harbourPoints(s, 0)) + assertEquals(0, s.harbormaster) + assertEquals(CatanRules.publicVictoryPoints(s.copy(harbormaster = null))[0] + 2, CatanRules.publicVictoryPoints(s)[0]) + + // Equal points do not take it; strictly more does. + s = CatanRules.refreshHarbormaster( + s.with(1, v[2], BuildingType.SETTLEMENT).with(1, v[3], BuildingType.SETTLEMENT).with(1, v[4], BuildingType.SETTLEMENT), + ) + assertEquals(0, s.harbormaster) + s = CatanRules.refreshHarbormaster(s.with(1, v[5], BuildingType.SETTLEMENT)) + assertEquals(1, s.harbormaster) + assertTrue(s.log.any { "taken the title harbormaster from seat 0" in it }) + } + + @Test + fun `the title does not exist in other scenarios or in a Seafarers game`() { + var s = game(BaseScenario.RANDOM_MAP).copy(phase = CatanPhase.PLAYING) + val v = s.harbourVertices() + for (i in 0..3) s = s.with(0, v[i], BuildingType.SETTLEMENT) + assertNull(CatanRules.refreshHarbormaster(s).harbormaster) + } + + @Test + fun `engines finish games on the base scenarios, with and without Cities and Knights`() { + var titles = 0 + for (scenario in listOf(BaseScenario.FIRST_ISLAND, BaseScenario.HARBORMASTER)) { + for (expansions in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) { + for ((players, seed) in listOf(3 to 31L, 4 to 32L)) { + val opts = CatanOptions(players, if (expansions.isEmpty()) 10 else 13, expansions, baseScenario = scenario) + var state = CatanRules.initialState(config(opts, seed)) + val ai = CatanAi() + var moves = 0 + while (!CatanRules.isFinished(state)) { + assertTrue("$scenario $expansions seed $seed ran long", moves++ < 20_000) + val seat = CatanRules.currentSeat(state)!! + val legal = CatanRules.legalMoves(state, seat) + state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal)) + } + if (state.harbormaster != null) titles++ + assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state))) + } + } + } + assertTrue("nobody ever became harbormaster", titles > 0) + } +} 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 index 9fca90a..4241174 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt @@ -25,7 +25,7 @@ class CatanCkRulesTest { CatanOptions( playerCount = players, pointsToWin = 13, - ruleSet = if (ck) CatanRuleSet.CITIES_AND_KNIGHTS else CatanRuleSet.BASE, + expansions = if (ck) setOf(CatanExpansion.CITIES_AND_KNIGHTS) else emptySet(), ), ), seed = seed, @@ -138,7 +138,7 @@ class CatanCkRulesTest { val base = CatanRules.initialState(config(ck = false)) assertNull(base.ck) assertEquals(25, base.devDeck.size) - assertEquals(CatanRuleSet.BASE, base.options.ruleSet) + assertEquals(emptySet(), base.options.expansions) } @Test @@ -157,7 +157,21 @@ class CatanCkRulesTest { 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) + assertEquals(emptySet(), options.expansions) + } + + @Test + fun `options saved with a rule set still read as the expansion they named`() { + val read = { text: String -> Json { ignoreUnknownKeys = true }.decodeFromString(text) } + assertEquals( + setOf(CatanExpansion.CITIES_AND_KNIGHTS), + read("""{"playerCount":3,"pointsToWin":13,"ruleSet":"CITIES_AND_KNIGHTS"}""").expansions, + ) + assertEquals( + setOf(CatanExpansion.SEAFARERS), + read("""{"playerCount":3,"pointsToWin":12,"ruleSet":"SEAFARERS"}""").expansions, + ) + assertEquals(emptySet(), read("""{"ruleSet":"BASE"}""").expansions) } @Test 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 index b11e954..cb17d36 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanCkSelfPlayTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkSelfPlayTest.kt @@ -152,7 +152,7 @@ class CatanCkSelfPlayTest { 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), + CatanOptions(playerCount = 3, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)), seed, ) assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase) @@ -171,7 +171,7 @@ class CatanCkSelfPlayTest { 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), + CatanOptions(playerCount = 4, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)), seed, ) assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase) diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt index f31b86f..44eed0b 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt @@ -115,8 +115,9 @@ class CatanRulesTest { // -- Setup --------------------------------------------------------------- @Test - fun `setup runs in snake order and then seat zero leads`() { + fun `setup runs in snake order from the first seat and then that seat leads`() { var state = CatanRules.initialState(config()) + val first = state.firstSeat val order = mutableListOf() while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) { val seat = CatanRules.currentSeat(state)!! @@ -126,12 +127,21 @@ class CatanRulesTest { CatanRules.legalMoves(state, seat).filterIsInstance().first(), ) } - assertEquals(listOf(0, 1, 2, 2, 1, 0), order) + val forward = (0 until 3).map { (first + it) % 3 } + assertEquals(forward + forward.reversed(), order) assertEquals(CatanPhase.AWAIT_ROLL, state.phase) - assertEquals(0, state.turn) + assertEquals(first, state.turn) assertEquals(1, state.turnNumber) } + @Test + fun `the first seat comes from the seed and varies between tables`() { + val firsts = (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat } + assertEquals(firsts, (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat }) + assertEquals(setOf(0, 1, 2), firsts.toSet()) + assertEquals(firsts[0], CatanRules.initialState(config(seed = 1L)).turn) + } + @Test fun `the second settlement earns one card from each adjacent land`() { var state = CatanRules.initialState(config()) diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaCkTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaCkTest.kt new file mode 100644 index 0000000..3525ce2 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaCkTest.kt @@ -0,0 +1,154 @@ +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.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 + +/** Cities & Knights played on a Seafarers map. */ +class CatanSeaCkTest { + + private val json = Json { encodeDefaults = true } + + private fun options(scenario: SeaScenario = SeaScenario.NEW_SHORES, players: Int = 3) = CatanOptions( + playerCount = players, + pointsToWin = 14, + expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), + scenario = scenario, + ) + + private fun config(options: CatanOptions, seed: Long) = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun start(scenario: SeaScenario = SeaScenario.NEW_SHORES, seed: Long = 7L) = + CatanRules.initialState(config(options(scenario), seed)) + + private fun CatanState.city(vertex: Int, seat: Int) = + copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(BuildingType.CITY, seat) }) + + @Test + fun `both expansions switch on together and the state carries both`() { + val s = start() + assertNotNull(s.ck) + assertNotNull(s.sea) + assertTrue(s.devDeck.isEmpty()) + assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), s.options.expansions) + assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s))) + } + + @Test + fun `a ship is on offer in play, and a knight can stand at the end of a ship route on land`() { + val s0 = start() + val topo = s0.topo + // A coastal home town with a sea edge leaving it. + val town = (0 until topo.VERTEX_COUNT).first { v -> + topo.vertexIsland[v] == s0.sea!!.home && + topo.VERTEX_EDGES[v].any { topo.edgeSea[it] && !topo.edgeLand[it] } + } + val s = s0.city(town, 0).copy( + phase = CatanPhase.PLAYING, turn = 0, turnNumber = 3, + resources = List(3) { Resource.entries.associateWith { 5 } }, + ) + val moves = CatanRules.legalMoves(s, 0) + assertTrue("no ship on offer", moves.any { it is BuildShip }) + assertTrue("sea vertices are never knight posts", moves.filterIsInstance().all { topo.vertexLand[it.vertex] }) + } + + @Test + fun `a gold field pays its owner a choice after the roll, then play goes on`() { + val s0 = start() + val topo = s0.topo + val gold = s0.terrains.indexOf(Terrain.GOLD) + val number = s0.numbers[gold] + val vertex = topo.HEX_CORNERS[gold].first { topo.vertexLand[it] } + val dice = (1..6).flatMap { a -> (1..6).map { b -> listOf(a, b) } }.first { it.sum() == number } + val s = s0.city(vertex, 0).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 3, robber = 0) + val rolled = CatanRules.applyMove(s, 0, RollDice(forced = dice, event = EventFace.TRADE)) + assertEquals(CatanPhase.GOLD, rolled.phase) + assertEquals(listOf(GoldOwed(0, 2)), rolled.sea!!.gold) + val pick = CatanRules.legalMoves(rolled, 0).filterIsInstance().first { it.cards == listOf(Resource.ORE, Resource.ORE) } + val after = CatanRules.applyMove(rolled, 0, pick) + assertEquals(CatanPhase.PLAYING, after.phase) + assertEquals(2, after.resources[0].getValue(Resource.ORE) - s.resources[0].getValue(Resource.ORE)) + } + + @Test + fun `once the barbarians have landed a seven may send the pirate, which robs goods`() { + val s0 = start() + val topo = s0.topo + val seaHex = (0 until topo.HEX_COUNT).first { !topo.land[it] && it != s0.sea!!.pirate && + (0 until topo.EDGE_COUNT).any { e -> it in topo.EDGE_HEXES[e] && topo.edgeLand[e] } } + val edge = (0 until topo.EDGE_COUNT).first { seaHex in topo.EDGE_HEXES[it] } + val s = s0.copy( + phase = CatanPhase.ROBBER, turn = 0, turnNumber = 3, + ck = s0.ck!!.copy(attacked = true), + sea = s0.sea!!.copy(ships = s0.sea!!.ships.toMutableList().also { it[edge] = 1 }), + resources = s0.resources.toMutableList().also { it[1] = it[1] + (Resource.GRAIN to 2) }, + ) + val pirate = CatanRules.legalMoves(s, 0).filterIsInstance() + .first { it.hex == topo.HEXES[seaHex] && it.victim == 1 } + val after = CatanRules.applyMove(s, 0, pirate) + assertEquals(seaHex, after.sea!!.pirate) + assertEquals(CatanPhase.PLAYING, after.phase) + assertEquals(CatanCk.goodsCount(s, 0) + 1, CatanCk.goodsCount(after, 0)) + assertEquals(CatanCk.goodsCount(s, 1) - 1, CatanCk.goodsCount(after, 1)) + // The robber itself is kept off the sea and the fog. + val robber = CatanRules.legalMoves(s, 0).filterIsInstance() + assertTrue(robber.all { topo.land[topo.hexIndex.getValue(it.hex)] }) + } + + @Test + fun `first landings count toward the target`() { + val s0 = start() + val sea = s0.sea!! + val islet = (0 until s0.topo.islandCount).first { !sea.isStart(it) } + val withBonus = s0.copy(sea = sea.copy(islandBonus = sea.islandBonus.toMutableList().also { it[islet] = 2 })) + assertEquals(CatanCk.publicVictoryPoints(s0)[2] + SeaRules.ISLAND_BONUS, CatanCk.publicVictoryPoints(withBonus)[2]) + } + + @Test + fun `engines finish a Cities and Knights game on three maps`() { + var ships = 0 + var knights = 0 + var barbarianAttacks = 0 + for ((scenario, players, seed) in listOf( + Triple(SeaScenario.NEW_SHORES, 3, 21L), + Triple(SeaScenario.FOUR_ISLANDS, 4, 22L), + Triple(SeaScenario.FOG_ISLANDS, 3, 23L), + )) { + val ai = CatanAi() + var state = CatanRules.initialState(config(options(scenario, players), seed)) + var moves = 0 + while (!CatanRules.isFinished(state)) { + assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 40_000) + val seat = CatanRules.currentSeat(state) ?: error("$scenario: nobody to move in ${state.phase}; ${state.log.takeLast(4)}") + val legal = CatanRules.legalMoves(state, seat) + assertTrue("$scenario: no legal move for $seat in ${state.phase}", legal.isNotEmpty()) + val move = ai.chooseMove(state, seat, legal) + assertTrue("$scenario: illegal $move in ${state.phase}", CatanRules.isLegalMove(state, seat, move)) + state = CatanRules.applyMove(state, seat, move) + if (moves % 40 == 0) assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state))) + } + assertFalse(state.winner == null) + ships += state.sea!!.ships.count { it != NO_PLAYER } + knights += state.ck!!.knights.size + if (state.ck!!.attacked) barbarianAttacks++ + } + assertTrue("no ships were built", ships > 0) + assertTrue("no knights were hired", knights > 0) + assertTrue("the barbarians never landed", barbarianAttacks > 0) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt new file mode 100644 index 0000000..23862d2 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt @@ -0,0 +1,336 @@ +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 + +/** The scenarios with rules of their own: cloth villages and tribe chips. */ +class CatanSeaExtrasTest { + + private val json = Json { encodeDefaults = true } + + private fun start(scenario: SeaScenario, seed: Long = 5L, players: Int = 3): CatanState { + val options = CatanOptions( + playerCount = players, + pointsToWin = 14, + expansions = setOf(CatanExpansion.SEAFARERS), + scenario = scenario, + ) + val config = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + return CatanRules.initialState(config).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6) + } + + private fun dice(total: Int): List = if (total <= 7) listOf(1, total - 1) else listOf(total - 6, 6) + + /** A cloth game with seat 0 and seat 1 both settled beside the first village, the robber elsewhere. */ + private fun clothGame(): Pair { + val s = start(SeaScenario.CLOTH_FOR_CATAN) + val village = s.terrains.indexOf(Terrain.VILLAGE) + val corners = s.topo.HEX_CORNERS[village] + val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE } + val staged = s.copy( + robber = elsewhere, + buildings = s.buildings.toMutableList().also { + it[corners[0]] = CatanBuilding(BuildingType.SETTLEMENT, 0) + it[corners[3]] = CatanBuilding(BuildingType.CITY, 1) + }, + ) + return staged to village + } + + private fun roll(s: CatanState, number: Int) = CatanRules.applyMove(s, s.turn, RollDice(dice(number))) + + @Test + fun `a village pays one cloth to every settlement beside it, a city no more`() { + val (s, village) = clothGame() + val number = s.numbers[village] + assertTrue(number in 2..12 && number != 7) + val after = roll(s, number) + assertEquals(listOf(1, 1, 0), after.sea!!.cloth) + assertEquals(SeaBoards.VILLAGE_CLOTH - 2, after.sea!!.villageCloth[village]) + assertTrue(after.log.any { "village" in it && "cloth" in it }) + } + + @Test + fun `an empty village pays nothing and the last token goes to the seat nearest the roller`() { + val (s, village) = clothGame() + val low = s.copy(sea = s.sea!!.copy(villageCloth = s.sea!!.villageCloth.toMutableList().also { it[village] = 1 })) + val after = roll(low.copy(turn = 1), low.numbers[village]) + assertEquals(listOf(0, 1, 0), after.sea!!.cloth) + assertEquals(0, after.sea!!.villageCloth[village]) + val again = roll(after.copy(phase = CatanPhase.AWAIT_ROLL), after.numbers[village]) + assertEquals(listOf(0, 1, 0), again.sea!!.cloth) + } + + @Test + fun `the robber on a village stops it`() { + val (s, village) = clothGame() + val after = roll(s.copy(robber = village), s.numbers[village]) + assertEquals(listOf(0, 0, 0), after.sea!!.cloth) + } + + @Test + fun `every two cloth are a victory point, and the whole thing survives the wire format`() { + val (s, _) = clothGame() + val rich = s.copy(sea = s.sea!!.copy(cloth = listOf(5, 1, 0))) + assertEquals(CatanRules.publicVictoryPoints(s)[0] + 2, CatanRules.publicVictoryPoints(rich)[0]) + assertEquals(CatanRules.publicVictoryPoints(s)[1], CatanRules.publicVictoryPoints(rich)[1]) + assertEquals(rich, CatanRules.decodeState(CatanRules.encodeState(rich))) + } + + @Test + fun `villages pay cloth with Cities and Knights too`() { + val options = CatanOptions( + playerCount = 3, + pointsToWin = 14, + expansions = setOf(CatanExpansion.SEAFARERS, CatanExpansion.CITIES_AND_KNIGHTS), + scenario = SeaScenario.CLOTH_FOR_CATAN, + ) + 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(options), + seed = 5L, + ) + val s = CatanRules.initialState(config) + .copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6) + val village = s.terrains.indexOf(Terrain.VILLAGE) + val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE } + val staged = s.copy( + robber = elsewhere, + buildings = s.buildings.toMutableList().also { + it[s.topo.HEX_CORNERS[village][0]] = CatanBuilding(BuildingType.SETTLEMENT, 2) + }, + ) + val after = roll(staged, staged.numbers[village]) + assertEquals(1, after.sea!!.cloth[2]) + } + + // ---- The Forgotten Tribe ---- + + /** A tribe game, seat 0 with a road reaching a settlement site beside [chipHex], and plenty of cards. */ + private fun tribeGame(chip: TribeChip): Triple { + val s = start(SeaScenario.FORGOTTEN_TRIBE) + val hex = s.sea!!.chips.indexOfFirst { it != TribeChip.NONE } + val chips = s.sea!!.chips.toMutableList().also { it[hex] = chip } + val topo = s.topo + val vertex = topo.HEX_CORNERS[hex].first { v -> topo.VERTEX_NEIGHBORS[v].all { s.buildings[it] == null } } + val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeLand[it] } + val staged = s.copy( + phase = CatanPhase.PLAYING, + roads = s.roads.toMutableList().also { it[edge] = 0 }, + resources = List(3) { Resource.entries.associateWith { 4 } }, + sea = s.sea!!.copy(chips = chips), + ) + return Triple(staged, hex, vertex) + } + + @Test + fun `the first settler beside a point chip takes the point`() { + val (s, hex, vertex) = tribeGame(TribeChip.POINT) + val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex)) + assertEquals(1, after.sea!!.chipPoints[0]) + assertEquals(TribeChip.NONE, after.sea!!.chips[hex]) + assertEquals(CatanRules.publicVictoryPoints(s)[0] + 1 + 1 + 2, CatanRules.publicVictoryPoints(after)[0]) + } + + @Test + fun `a harbour chip trades three to one anywhere`() { + val (s, _, vertex) = tribeGame(TribeChip.HARBOUR) + assertEquals(4, CatanRules.bestPortRatio(s, 0, Resource.WOOL)) + val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex)) + assertEquals(listOf(0), after.sea!!.harbourSeats) + assertEquals(3, CatanRules.bestPortRatio(after, 0, Resource.WOOL)) + assertEquals(4, CatanRules.bestPortRatio(after, 1, Resource.WOOL)) + assertTrue(CatanRules.legalMoves(after, 0).any { it is MaritimeTrade }) + } + + @Test + fun `a gold chip asks for two resources of the settler's choice`() { + val (s, _, vertex) = tribeGame(TribeChip.GOLD) + val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex)) + assertEquals(CatanPhase.GOLD, after.phase) + assertEquals(listOf(GoldOwed(0, 2)), after.sea!!.gold) + val done = CatanRules.applyMove(after, 0, CatanRules.legalMoves(after, 0).first()) + assertEquals(CatanPhase.PLAYING, done.phase) + } + + // ---- Pirate Islands ---- + + private fun fortGame(): Triple { + val s = start(SeaScenario.PIRATE_ISLANDS).copy( + phase = CatanPhase.PLAYING, + resources = List(3) { Resource.entries.associateWith { 6 } }, + ) + val fort = s.terrains.indexOf(Terrain.FORTRESS) + val edge = (0 until s.topo.EDGE_COUNT).first { s.topo.edgeSea[it] && fort in s.topo.EDGE_HEXES[it] } + return Triple(s, fort, edge) + } + + @Test + fun `nobody settles a fortress island until the fortress falls`() { + val (s, fort, _) = fortGame() + val corner = s.topo.HEX_CORNERS[fort].first() + assertTrue(SeaRules.fortressHolds(s, corner)) + assertTrue(!CatanRules.vertexOpenForSettlement(s, corner)) + val fort2 = s.terrains.indices.first { s.terrains[it] == Terrain.FORTRESS && it != fort } + assertTrue(s.topo.islandOf[fort] != s.topo.islandOf[fort2]) + } + + @Test + fun `a raid needs a ship beside the fortress and three cards, and three raids take it`() { + val (s, fort, edge) = fortGame() + val coord = s.topo.HEXES[fort] + assertTrue(CatanRules.legalMoves(s, 0).none { it is RaidFortress }) + var state = s.copy(sea = s.sea!!.copy(ships = s.sea!!.ships.toMutableList().also { it[edge] = 0 })) + assertTrue(RaidFortress(coord) in CatanRules.legalMoves(state, 0)) + val before = state.resources[0].getValue(Resource.ORE) + repeat(3) { state = CatanRules.applyMove(state, 0, RaidFortress(coord)) } + assertEquals(before - 3, state.resources[0].getValue(Resource.ORE)) + assertEquals(Terrain.DESERT, state.terrains[fort]) + assertEquals(2, state.sea!!.raidPoints[0]) + assertTrue(!SeaRules.fortressHolds(state, s.topo.HEX_CORNERS[fort].first())) + assertTrue(CatanRules.legalMoves(state, 0).none { it is RaidFortress }) + // Seat 1 has no ship there and cannot raid. + assertTrue(CatanRules.legalMoves(state.copy(turn = 1), 1).none { it is RaidFortress }) + } + + // ---- Wonders of Catan ---- + + private fun wonderGame(city: Boolean = true): CatanState { + val s = start(SeaScenario.WONDERS_OF_CATAN).copy( + phase = CatanPhase.PLAYING, + resources = List(3) { Resource.entries.associateWith { 9 } }, + ) + return if (!city) s else s.copy( + buildings = s.buildings.toMutableList().also { it[0] = CatanBuilding(BuildingType.CITY, 0) }, + ) + } + + @Test + fun `a wonder needs a city, each stage is a point, and the fourth stage wins`() { + assertTrue(CatanRules.legalMoves(wonderGame(city = false), 0).none { it is BuildWonder }) + var s = wonderGame() + val base = CatanRules.publicVictoryPoints(s)[0] + for (stage in 1..3) { + assertTrue(BuildWonder in CatanRules.legalMoves(s, 0)) + s = CatanRules.applyMove(s, 0, BuildWonder) + assertEquals(stage, s.sea!!.wonders[0]) + assertEquals(base + stage, CatanRules.publicVictoryPoints(s)[0]) + assertEquals(CatanPhase.PLAYING, s.phase) + } + s = CatanRules.applyMove(s, 0, BuildWonder) + assertEquals(CatanPhase.GAME_OVER, s.phase) + assertEquals(0, s.winner) + assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s))) + } + + @Test + fun `a wonder stage is not offered without the cards`() { + val s = wonderGame().copy(resources = List(3) { Resource.entries.associateWith { 0 } }) + assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildWonder }) + } + + // ---- Treasure Islands, Oases ---- + + @Test + fun `chests lie under the fog and are hidden from other views`() { + for (scenario in listOf(SeaScenario.TREASURE_ISLANDS, SeaScenario.OASES)) { + val s = start(scenario) + val chests = s.sea!!.treasures.indices.filter { s.sea!!.treasures[it] != Treasure.NONE } + assertEquals(minOf(5, s.terrains.count { it == Terrain.FOG }), chests.size) + assertTrue(chests.all { s.terrains[it] == Terrain.FOG }) + assertTrue(CatanRules.viewFor(s, 1).sea!!.treasures.isEmpty()) + } + } + + @Test + fun `an oasis map starts everyone on the desert island, never beside an oasis`() { + val s = start(SeaScenario.OASES) + assertEquals(37, s.topo.islandOf.count { it == s.sea!!.home }) + val moves = CatanRules.legalMoves(s.copy(phase = CatanPhase.SETUP_FIRST), s.firstSeat).filterIsInstance() + assertTrue(moves.isNotEmpty()) + assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } }) + } + + /** A chest of [kind] under the first fog hex, with a road of seat 0's laid up to the edge that will reveal it. */ + private fun chestGame(kind: Treasure): Triple { + val s = start(SeaScenario.OASES).copy(phase = CatanPhase.PLAYING) + val topo = s.topo + val hex = s.terrains.indexOf(Terrain.FOG) + val chests = List(topo.HEX_COUNT) { if (it == hex) kind else Treasure.NONE } + // Seat 0 owns the town beside a road that reaches the oasis; building the road finds it. + val edge = topo.EDGE_VERTICES.indices.first { e -> hex in topo.EDGE_HEXES[e] && topo.edgeLand[e] } + val staged = s.copy( + resources = List(3) { Resource.entries.associateWith { 5 } }, + sea = s.sea!!.copy(treasures = chests), + ) + return Triple(staged, hex, edge) + } + + @Test + fun `opening a chest pays its reward`() { + val kinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.ROADS, Treasure.DEV_CARD, Treasure.CHOICE) + for (kind in kinds) { + val (s, _, _) = chestGame(kind) + // Stage the find directly: reveal as if a road had just reached the hex. + val topo = s.topo + val hex = s.terrains.indexOf(Terrain.FOG) + val edge = (0 until topo.EDGE_COUNT).first { hex in topo.EDGE_HEXES[it] && topo.edgeLand[it] } + val after = SeaRules.reveal(s, 0, edge) + assertTrue("$kind: the chest is announced", after.log.any { "treasure chest" in it }) + when (kind) { + Treasure.DEV_CARD -> assertEquals(1, after.devHands[0].size) + Treasure.CHOICE -> assertTrue(after.phase == CatanPhase.GOLD || after.log.any { "uncovers" in it }) + Treasure.ROADS -> assertTrue(after.resources[0].getValue(Resource.LUMBER) >= 7) + else -> assertTrue(after.resources[0].getValue(Resource.WOOL) >= 6) + } + } + } + + // ---- The Great Canal ---- + + private fun canalGame(): Triple { + val s = start(SeaScenario.GREAT_CANAL).copy( + phase = CatanPhase.PLAYING, + resources = List(3) { Resource.entries.associateWith { 9 } }, + ) + val tile = s.sea!!.canalNumbers.indexOfFirst { it != 0 } + val staged = s.copy( + buildings = s.buildings.toMutableList().also { + it[s.topo.HEX_CORNERS[tile][0]] = CatanBuilding(BuildingType.SETTLEMENT, 0) + }, + ) + return Triple(staged, tile, s) + } + + @Test + fun `a canal tile hides its number, and three digs turn it into fields`() { + var (s, tile, _) = canalGame() + assertEquals(0, s.numbers[tile]) + assertEquals(Terrain.DESERT, s.terrains[tile]) + val number = s.sea!!.canalNumbers[tile] + val dig = DigCanal(s.topo.HEXES[tile]) + assertTrue(dig in CatanRules.legalMoves(s, 0)) + assertTrue(CatanRules.legalMoves(s.copy(turn = 1), 1).none { it is DigCanal }) + val before = CatanRules.publicVictoryPoints(s)[0] + repeat(2) { s = CatanRules.applyMove(s, 0, dig) } + assertEquals(Terrain.DESERT, s.terrains[tile]) + assertEquals(before + 1, CatanRules.publicVictoryPoints(s)[0]) + s = CatanRules.applyMove(s, 0, dig) + assertEquals(Terrain.FIELDS, s.terrains[tile]) + assertEquals(number, s.numbers[tile]) + assertTrue(CatanRules.legalMoves(s, 0).none { it is DigCanal && it.hex == dig.hex }) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt new file mode 100644 index 0000000..04a1cc5 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -0,0 +1,266 @@ +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.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 + +/** What every Seafarers map owes the engine, and the shape of each one. */ +class CatanSeaScenariosTest { + + private val json = Json { encodeDefaults = true } + + private fun options(scenario: SeaScenario, players: Int = 3) = CatanOptions( + playerCount = players, + pointsToWin = 12, + expansions = setOf(CatanExpansion.SEAFARERS), + scenario = scenario, + ) + + private fun config(options: CatanOptions, seed: Long) = TableConfig( + gameId = GameCatalog.CATAN, + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun sizes(scenario: SeaScenario): List { + val topo = SeaBoards.topology(scenario) + return (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted() + } + + @Test + fun `each map has the islands it is named for`() { + assertEquals(listOf(2, 3, 3, 21), sizes(SeaScenario.NEW_SHORES)) + assertEquals(listOf(1, 1, 6, 6, 6, 6), sizes(SeaScenario.FOUR_ISLANDS)) + assertEquals(listOf(1, 1, 1, 2, 25), sizes(SeaScenario.THROUGH_THE_DESERT)) + assertEquals(listOf(2, 13, 14), sizes(SeaScenario.FOG_ISLANDS)) + assertEquals(listOf(1, 3, 4, 15), sizes(SeaScenario.TREASURE_ISLANDS)) + assertEquals(listOf(37), sizes(SeaScenario.OASES)) + assertEquals(listOf(2, 25), sizes(SeaScenario.GREAT_CANAL)) + assertEquals(listOf(4, 5, 18), sizes(SeaScenario.PIRATE_ISLANDS)) + assertEquals(listOf(1, 3, 12, 13), sizes(SeaScenario.WONDERS_OF_CATAN)) + assertEquals(listOf(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN)) + assertEquals(listOf(2, 3, 5, 17), sizes(SeaScenario.FORGOTTEN_TRIBE)) + } + + @Test + fun `every map deals numbers, harbours and a pirate at sea, with the reds apart`() { + for (scenario in SeaScenario.entries) { + repeat(5) { i -> + val s = CatanRules.initialState(config(options(scenario), 300L + i)) + val topo = s.topo + val deserts = s.terrains.count { it == Terrain.DESERT } + for (h in 0 until topo.HEX_COUNT) { + val land = s.terrains[h] != Terrain.SEA + assertEquals("$scenario hex $h", topo.land[h], land) + val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG || + s.terrains[h] == Terrain.FORTRESS + assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0) + } + val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN && + scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS && + scenario != SeaScenario.TREASURE_ISLANDS + assertTrue("$scenario has no desert", deserts > 0 || !deserted) + // Under fog every hex is face down with no number; the tiles are in the pile. + val fog = s.terrains.indices.filter { s.terrains[it] == Terrain.FOG } + assertEquals(fog.size, s.sea!!.fogDeck.size) + fog.forEach { assertEquals(0, s.numbers[it]) } + assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate]) + assertEquals("$scenario harbours", 9, s.ports.size) + val reds = (0 until topo.HEX_COUNT).filter { s.numbers[it] == 6 || s.numbers[it] == 8 } + for (a in reds) for (b in reds) if (a < b) assertFalse("$scenario reds touch", topo.hexesAdjacent(a, b)) + assertEquals(scenario, s.sea!!.scenario) + } + } + } + + @Test + fun `four islands start on any main island and the islets earn the bonus`() { + val s = CatanRules.initialState(config(options(SeaScenario.FOUR_ISLANDS), 5L)) + val sea = s.sea!! + assertEquals(4, sea.starts.size) + val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance() + assertEquals(sea.starts.toSet(), moves.map { s.topo.vertexIsland[it.vertex] }.toSet()) + for (island in 0 until s.topo.islandCount) assertEquals(island in sea.starts, sea.isStart(island)) + // A settlement on a starting island scores no first-landing bonus. + val onMain = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] == sea.starts.first() } + assertEquals(s, SeaRules.claimIsland(s, 0, onMain)) + val islet = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] >= 0 && s.topo.vertexIsland[it] !in sea.starts } + assertEquals(0, SeaRules.bonusPoints(s, 0)) + assertEquals(0, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, onMain), 0)) + assertEquals(SeaRules.ISLAND_BONUS, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, islet), 0)) + } + + @Test + fun `through the desert starts everyone on the one big island`() { + val s = CatanRules.initialState(config(options(SeaScenario.THROUGH_THE_DESERT), 5L)) + assertEquals(listOf(s.sea!!.home), s.sea!!.starts) + assertEquals(25, s.topo.islandOf.count { it == s.sea!!.home }) + assertTrue(s.terrains.count { it == Terrain.DESERT } >= 5) + } + + @Test + fun `a save from before there were several starts still starts on the home island`() { + val s = CatanRules.initialState(config(options(SeaScenario.NEW_SHORES), 5L)) + val old = s.copy(sea = s.sea!!.copy(starts = emptyList())) + assertTrue(old.sea!!.isStart(old.sea!!.home)) + val moves = CatanRules.legalMoves(old, old.firstSeat).filterIsInstance() + assertTrue(moves.all { old.topo.vertexIsland[it.vertex] == old.sea!!.home }) + } + + private fun fogGame(seed: Long = 5L) = CatanRules.initialState(config(options(SeaScenario.FOG_ISLANDS), seed)).let { + it.copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1, setupIndex = 6) + } + + /** A town site on the home island, ships already laid toward the fog, and the one edge that will reach it. */ + private data class FogRoute(val town: Int, val laid: List, val last: Int) + + private fun CatanState.fogRoute(): FogRoute { + val topo = topo + fun touchesFog(v: Int) = topo.VERTEX_HEXES[v].any { terrains[it] == Terrain.FOG } + val seen = mutableSetOf() + for (start in 0 until topo.VERTEX_COUNT) { + if (topo.vertexIsland[start] != sea!!.home || touchesFog(start)) continue + if (topo.VERTEX_EDGES[start].none { topo.edgeSea[it] && !topo.edgeLand[it] }) continue + val queue = ArrayDeque>>().apply { add(start to emptyList()) } + seen.clear() + while (queue.isNotEmpty()) { + val (v, path) = queue.removeFirst() + if (!seen.add(v)) continue + for (e in topo.VERTEX_EDGES[v]) { + if (!topo.edgeSea[e] || topo.edgeLand[e]) continue + val far = topo.otherEnd(e, v) + if (touchesFog(far)) return FogRoute(start, path, e) + if (path.size < 6) queue.add(far to path + e) + } + } + } + error("no ship route from home reaches the fog") + } + + /** [fogRoute] laid on the board: the town and all but the last ship. */ + private fun CatanState.onTheShore(route: FogRoute): CatanState = copy( + buildings = buildings.toMutableList().also { it[route.town] = CatanBuilding(BuildingType.SETTLEMENT, 0) }, + sea = sea!!.copy(ships = sea!!.ships.toMutableList().also { l -> route.laid.forEach { l[it] = 0 } }), + resources = List(3) { Resource.entries.associateWith { 3 } }, + ) + + @Test + fun `fog is hidden at the start, never a place to begin, and keeps the robber off`() { + val s = CatanRules.initialState(config(options(SeaScenario.FOG_ISLANDS), 5L)) + assertFalse(s.sea!!.isStart(s.topo.islandOf.indices.first { s.terrains[it] == Terrain.FOG }.let { s.topo.islandOf[it] })) + val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance() + assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } }) + val robbing = fogGame().copy(phase = CatanPhase.ROBBER) + val targets = CatanRules.legalMoves(robbing, 0).filterIsInstance() + assertTrue(targets.isNotEmpty()) + assertTrue(targets.none { robbing.terrains[robbing.topo.hexIndex.getValue(it.hex)] == Terrain.FOG }) + } + + @Test + fun `a ship that reaches the fog turns the hex over and the finder takes a card`() { + val base = fogGame() + val route = base.fogRoute() + val edge = route.last + val s = base.onTheShore(route) + val tile = s.sea!!.fogDeck.first() + val after = CatanRules.applyMove(s, 0, BuildShip(edge)) + val found = s.topo.EDGE_VERTICES[edge].toList().flatMap { s.topo.VERTEX_HEXES[it] } + .filter { s.terrains[it] == Terrain.FOG }.distinct().sorted() + val hex = found.first() + assertEquals(tile.terrain, after.terrains[hex]) + assertEquals(tile.number, after.numbers[hex]) + assertEquals(s.sea!!.fogDeck.size - found.size, after.sea!!.fogDeck.size) + val produced = tile.terrain.produces + if (produced != null) assertEquals(s.resources[0].getValue(produced) - (if (produced in SeaRules.SHIP_COST) 1 else 0) + 1, + after.resources[0].getValue(produced) - (found.drop(1).count { after.terrains[it].produces == produced })) + if (tile.terrain == Terrain.GOLD) assertEquals(CatanPhase.GOLD, after.phase) + // The find is public: the log names it. + assertTrue(after.log.last { "uncovers" in it }.isNotBlank()) + } + + @Test + fun `a gold find during road building asks for the choice and then goes back to the free roads`() { + val base = fogGame() + val route = base.fogRoute() + val edge = route.last + val gold = FogTile(Terrain.GOLD, 6) + val shore = base.onTheShore(route) + val s = shore.copy( + phase = CatanPhase.FREE_ROADS, + pendingFreeRoads = 2, + sea = shore.sea!!.copy(fogDeck = List(shore.sea!!.fogDeck.size) { gold }), + ) + val after = CatanRules.applyMove(s, 0, BuildShip(edge)) + assertEquals(CatanPhase.GOLD, after.phase) + val pick = CatanRules.legalMoves(after, 0).filterIsInstance().first() + val resumed = CatanRules.applyMove(after, 0, pick) + assertEquals(CatanPhase.FREE_ROADS, resumed.phase) + assertEquals(1, resumed.pendingFreeRoads) + } + + @Test + fun `other seats do not see the face-down tiles and the state survives its wire format`() { + val s = fogGame() + assertTrue(s.sea!!.fogDeck.isNotEmpty()) + assertTrue(CatanRules.viewFor(s, 1).sea!!.fogDeck.isEmpty()) + assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s))) + } + + @Test + fun `engines finish a game on every map at three and four players`() { + var fogFound = 0 + var clothPaid = 0 + var chipsTaken = 0 + var fortsTaken = 0 + var wonderStages = 0 + var chestsOpened = 0 + var canalDug = 0 + for (scenario in SeaScenario.entries) { + for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) { + val opts = options(scenario, players) + val ai = CatanAi() + var state = CatanRules.initialState(config(opts, seed)) + var moves = 0 + while (!CatanRules.isFinished(state)) { + assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 20_000) + val seat = CatanRules.currentSeat(state) ?: error("$scenario seed $seed: nobody to move in ${state.phase}") + val legal = CatanRules.legalMoves(state, seat) + assertTrue("$scenario seed $seed: no legal move for $seat in ${state.phase}", legal.isNotEmpty()) + val move = ai.chooseMove(state, seat, legal) + assertTrue("$scenario seed $seed: illegal $move", CatanRules.isLegalMove(state, seat, move)) + state = CatanRules.applyMove(state, seat, move) + } + assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase) + if (scenario == SeaScenario.FOG_ISLANDS) fogFound += 14 - state.sea!!.fogDeck.size + if (scenario == SeaScenario.CLOTH_FOR_CATAN) clothPaid += state.sea!!.cloth.sum() + if (scenario == SeaScenario.PIRATE_ISLANDS) fortsTaken += state.sea!!.raidPoints.sum() / 2 + if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) { + chestsOpened += state.log.count { "treasure chest" in it } + } + if (scenario == SeaScenario.GREAT_CANAL) canalDug += state.sea!!.canalDug.sum() + if (scenario == SeaScenario.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum() + if (scenario == SeaScenario.FORGOTTEN_TRIBE) { + chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE } + } + assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state))) + } + } + assertTrue("no engine ever uncovered a fog hex", fogFound > 0) + assertTrue("no village ever paid cloth", clothPaid > 0) + assertTrue("no tribe chip was ever taken", chipsTaken > 0) + assertTrue("no fortress ever fell", fortsTaken > 0) + assertTrue("no chest was ever opened", chestsOpened > 0) + assertTrue("no canal unit was ever dug", canalDug > 0) + assertTrue("no wonder stage was ever built", wonderStages > 0) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt index 93e496e..afcbfeb 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt @@ -17,7 +17,7 @@ class CatanSeafarersTest { private val json = Json { encodeDefaults = true } private fun options(players: Int = 3) = - CatanOptions(playerCount = players, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS) + CatanOptions(playerCount = players, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS)) private fun config(options: CatanOptions, seed: Long) = TableConfig( gameId = GameCatalog.CATAN, @@ -96,7 +96,7 @@ class CatanSeafarersTest { @Test fun `setup places both settlements on the home island, with roads rather than ships`() { val s = start() - val moves = CatanRules.legalMoves(s, 0).filterIsInstance() + val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance() assertTrue(moves.isNotEmpty()) assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home }) assertTrue(moves.all { s.topo.edgeLand[it.edge] }) diff --git a/app/src/test/java/org/prolibertate/games/game/checkers/CheckersAiProfileTest.kt b/app/src/test/java/org/prolibertate/games/game/checkers/CheckersAiProfileTest.kt new file mode 100644 index 0000000..7340c15 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/checkers/CheckersAiProfileTest.kt @@ -0,0 +1,40 @@ +package org.prolibertate.games.game.checkers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles + +class CheckersAiProfileTest { + + @Test + fun `every difficulty and seeded style plays only legal moves`() { + val seen = mutableSetOf>() + for (difficulty in AiDifficulty.entries) for (seed in 0L until 12L) { + val profiles = AiProfiles(difficulty, seed) + for (seat in 0..1) profiles.of(seat).let { seen += it.level to it.style } + val ai = CheckersAi(profiles, CheckersLevel.CASUAL) + var state = CheckersRules.initialState(CheckersOptions(level = CheckersLevel.CASUAL)) + var plies = 0 + while (!CheckersRules.isFinished(state) && plies < 10) { + val legal = CheckersRules.legalMoves(state) + val move = ai.chooseMove(state, state.turn, legal) + assertTrue("$difficulty/$seed illegal move $move", move in legal) + state = CheckersRules.applyMove(state, state.turn, move) + plies++ + } + } + assertTrue("expected a spread of level/style pairs, saw ${seen.size}", seen.size >= 8) + } + + @Test + fun `default profile plays the same as an explicit medium table`() { + val state = CheckersRules.initialState(CheckersOptions(level = CheckersLevel.CASUAL)) + val legal = CheckersRules.legalMoves(state) + val a = CheckersAi(level = CheckersLevel.CASUAL).chooseMove(state, state.turn, legal) + val b = CheckersAi(AiProfiles(AiDifficulty.MEDIUM, 5L), CheckersLevel.CASUAL) + .chooseMove(state, state.turn, legal) + assertEquals(a, b) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt b/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt index be86777..c775007 100644 --- a/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt @@ -3,6 +3,11 @@ package org.prolibertate.games.game.chess import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import org.prolibertate.games.game.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiLevel +import org.prolibertate.games.game.engine.AiProfile +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.AiStyle class ChessAiTest { @@ -128,4 +133,52 @@ class ChessAiTest { assertTrue("$level took ${millis}ms", millis < 15_000) } } + + @Test + fun `every level and style returns a legal move over a few playouts`() { + val opening = "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4" + for (diff in listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD, AiDifficulty.MIXED)) { + for (seed in 1L..4L) { + val ai = ChessAi(AiProfiles(diff, seed)) + var state = ChessRules.fromPosition(ChessOptions(level = ChessLevel.CASUAL), opening) + repeat(6) { + if (ChessRules.isFinished(state)) return@repeat + val legal = ChessRules.legalMoves(state) + val move = ai.moveIn(state) + assertTrue("$diff/$seed: $move illegal", legal.contains(move)) + state = ChessRules.applyMove(state, state.turnSeat, move) + } + } + } + } + + @Test + fun `easy and every style still take a hanging queen`() { + val state = position("3r3k/8/8/3q4/8/8/8/3R2K1 w - - 0 1", ChessLevel.CLUB) + for (style in AiStyle.entries) { + val ai = ChessAi(AiProfilesFixed(AiLevel.HARD, style)) + assertEquals("d1d5", ai.moveIn(state).toString()) + } + } + + @Test + fun `the default profile matches the no-argument ai`() { + val state = position("r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4") + assertEquals(ChessAi().moveIn(state), ChessAi(AiProfiles.DEFAULT).moveIn(state)) + } + + /** Profiles with one fixed level and style for every seat. */ + private fun AiProfilesFixed(level: AiLevel, style: AiStyle): AiProfiles { + val diff = when (level) { + AiLevel.EASY -> AiDifficulty.EASY + AiLevel.MEDIUM -> AiDifficulty.MEDIUM + AiLevel.HARD -> AiDifficulty.HARD + } + // Search seeds until the derived style matches; the style space is tiny. + for (seed in 0L..500L) { + val profiles = AiProfiles(diff, seed) + if (profiles.of(0).style == style && profiles.of(1).style == style) return profiles + } + return AiProfiles(diff, 0L) + } } diff --git a/app/src/test/java/org/prolibertate/games/game/crazyeights/CrazyEightsAiProfileTest.kt b/app/src/test/java/org/prolibertate/games/game/crazyeights/CrazyEightsAiProfileTest.kt new file mode 100644 index 0000000..84103d3 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/crazyeights/CrazyEightsAiProfileTest.kt @@ -0,0 +1,71 @@ +package org.prolibertate.games.game.crazyeights + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class CrazyEightsAiProfileTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: CrazyEightsOptions, seed: Long) = TableConfig( + gameId = "crazy8s", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun play(ai: CrazyEightsAi, options: CrazyEightsOptions, seed: Long): List { + var state = CrazyEightsRules.initialState(config(options, seed)) + val moves = mutableListOf() + var guard = 0 + while (!CrazyEightsRules.isFinished(state) && guard++ < 6000) { + val seat = CrazyEightsRules.currentSeat(state) + if (seat == null) { + state = CrazyEightsRules.nextRound(state) + continue + } + val legal = CrazyEightsRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", legal.contains(move)) + moves += move + state = CrazyEightsRules.applyMove(state, seat, move) + } + assertTrue("game did not finish", CrazyEightsRules.isFinished(state)) + return moves + } + + @Test + fun `every difficulty and style plays complete games legally`() { + val variants = listOf( + CrazyEightsOptions(playerCount = 4, roundsToPlay = 2), + CrazyEightsOptions(playerCount = 2, roundsToPlay = 2, drawUntilPlayable = false), + CrazyEightsOptions(playerCount = 6, roundsToPlay = 2), + ) + for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD, AiDifficulty.MIXED)) { + for (seed in 0L until 9L) { + val options = variants[(seed % variants.size).toInt()] + play(CrazyEightsAi(AiProfiles(difficulty, seed)), options, 300L + seed) + } + } + } + + @Test + fun `the default profile plays like a medium balanced seat`() { + val options = CrazyEightsOptions(playerCount = 4, roundsToPlay = 2) + for (seed in 1L..3L) { + val default = play(CrazyEightsAi(), options, seed) + val medium = play(CrazyEightsAi(AiProfiles(AiDifficulty.MEDIUM, 31L + seed)), options, seed) + assertEquals(default, medium) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageAiLevelsTest.kt b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageAiLevelsTest.kt new file mode 100644 index 0000000..f97e211 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/cribbage/CribbageAiLevelsTest.kt @@ -0,0 +1,101 @@ +package org.prolibertate.games.game.cribbage + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.AiStyle +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class CribbageAiLevelsTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: CribbageOptions, seed: Long) = TableConfig( + gameId = "cribbage", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = options.teamOf(it)) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + /** Seeds that between them give some seat every style at this difficulty. */ + private fun seedsCoveringStyles(difficulty: AiDifficulty, seats: Int): List { + // The standard opponent is balanced by definition, so there is no style to cover. + if (difficulty == AiDifficulty.MEDIUM) return listOf(0L, 1L, 2L) + val missing = AiStyle.entries.toMutableSet() + val seeds = mutableListOf() + var seed = 0L + while (missing.isNotEmpty() && seed < 500) { + val styles = (0 until seats).map { AiProfiles(difficulty, seed).of(it).style } + if (styles.any { it in missing }) { + seeds += seed + missing.removeAll(styles.toSet()) + } + seed++ + } + assertTrue("styles never covered: $missing", missing.isEmpty()) + return seeds + } + + /** Plays a whole game with [ai] in every seat; returns the moves chosen. */ + private fun play(ai: CribbageAi, options: CribbageOptions, seed: Long): List { + var state = CribbageRules.initialState(config(options, seed)) + val moves = mutableListOf() + var guard = 0 + while (!CribbageRules.isFinished(state) && guard++ < 20000) { + val seat = CribbageRules.currentSeat(state) + if (seat == null) { + state = CribbageRules.nextHand(state) + continue + } + val legal = CribbageRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", legal.contains(move)) + moves += move + state = CribbageRules.applyMove(state, seat, move) + } + assertTrue("game ran away", guard < 20000) + return moves + } + + @Test + fun `every level and style plays a whole game legally`() { + val options = CribbageOptions(pointsToWin = 61) + listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD).forEach { difficulty -> + seedsCoveringStyles(difficulty, options.playerCount).forEach { seed -> + play(CribbageAi(AiProfiles(difficulty, seed)), options, 100L + seed) + } + } + } + + @Test + fun `a four handed table plays legally at every level`() { + val options = CribbageOptions(playerCount = 4, pointsToWin = 61) + listOf(AiDifficulty.EASY, AiDifficulty.HARD, AiDifficulty.MIXED).forEach { difficulty -> + play(CribbageAi(AiProfiles(difficulty, 3L)), options, 7L) + } + } + + @Test + fun `the default profile plays as the standard opponent`() { + val options = CribbageOptions(pointsToWin = 61) + val standard = play(CribbageAi(), options, 55L) + val medium = play(CribbageAi(AiProfiles(AiDifficulty.MEDIUM, 9L)), options, 55L) + assertEquals(standard, medium) + } + + @Test + fun `easy does not always play the best card`() { + val options = CribbageOptions(pointsToWin = 61) + val standard = play(CribbageAi(), options, 55L) + val easy = play(CribbageAi(AiProfiles(AiDifficulty.EASY, 9L)), options, 55L) + assertTrue("easy played exactly like medium", standard != easy) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/engine/AiProfilesTest.kt b/app/src/test/java/org/prolibertate/games/game/engine/AiProfilesTest.kt new file mode 100644 index 0000000..c830226 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/engine/AiProfilesTest.kt @@ -0,0 +1,77 @@ +package org.prolibertate.games.game.engine + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AiProfilesTest { + + @Test + fun `fixed difficulties give every seat that level`() { + for ((difficulty, level) in listOf( + AiDifficulty.EASY to AiLevel.EASY, + AiDifficulty.MEDIUM to AiLevel.MEDIUM, + AiDifficulty.HARD to AiLevel.HARD, + )) { + val profiles = AiProfiles(difficulty, seed = 42) + for (seat in 0..5) assertEquals(level, profiles.of(seat).level) + } + } + + @Test + fun `medium is the balanced standard opponent and never slips`() { + val profiles = AiProfiles(AiDifficulty.MEDIUM, seed = 7) + for (seat in 0..5) { + val profile = profiles.of(seat) + assertEquals(AiStyle.BALANCED, profile.style) + repeat(200) { assertFalse(profile.slips()) } + } + } + + @Test + fun `the same table always gives a seat the same personality`() { + val a = AiProfiles(AiDifficulty.MIXED, seed = 1234) + val b = AiProfiles(AiDifficulty.MIXED, seed = 1234) + for (seat in 0..6) { + assertEquals(a.of(seat).level, b.of(seat).level) + assertEquals(a.of(seat).style, b.of(seat).style) + } + } + + @Test + fun `mixed tables vary by seat and across seeds`() { + val seen = (0L..200L).flatMap { seed -> + val profiles = AiProfiles(AiDifficulty.MIXED, seed) + (0..3).map { profiles.of(it).level to profiles.of(it).style } + }.toSet() + assertTrue(seen.map { it.first }.toSet() == AiLevel.entries.toSet()) + assertTrue(seen.map { it.second }.toSet() == AiStyle.entries.toSet()) + } + + @Test + fun `only easy slips, and not every time`() { + val easy = AiProfiles(AiDifficulty.EASY, 1).of(0) + val slips = (1..1000).count { easy.slips() } + assertTrue("slipped $slips of 1000", slips in 200..400) + val hard = AiProfiles(AiDifficulty.HARD, 1).of(0) + repeat(200) { assertFalse(hard.slips()) } + } + + @Test + fun `pick takes the best unless easy slips and then stays in the better half`() { + val ranked = (1..10).toList() + assertEquals(1, AiProfiles(AiDifficulty.HARD, 3).of(0).pick(ranked)) + val easy = AiProfiles(AiDifficulty.EASY, 3).of(0) + val picks = (1..500).map { easy.pick(ranked) } + assertTrue(picks.all { it <= 5 }) + assertTrue(picks.toSet().size > 1) + } + + @Test + fun `a config without a difficulty reads as medium`() { + val config = TableConfig("chess", emptyList(), "{}", 1L) + assertEquals(AiDifficulty.MEDIUM, config.aiDifficulty) + assertEquals(AiLevel.MEDIUM, AiProfiles(config).of(0).level) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreAiProfilesTest.kt b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreAiProfilesTest.kt new file mode 100644 index 0000000..4e89ba3 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreAiProfilesTest.kt @@ -0,0 +1,65 @@ +package org.prolibertate.games.game.euchre + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class EuchreAiProfilesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(seed: Long) = TableConfig( + gameId = "euchre", + seats = (0 until 4).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2) + }, + optionsJson = json.encodeToString(EuchreOptions(pointsToWin = 4)), + seed = seed, + ) + + /** Plays a whole game with every seat on [ai], returning every move chosen. */ + private fun playGame(ai: EuchreAi, seed: Long): List { + val moves = mutableListOf() + var state = EuchreRules.initialState(config(seed)) + var guard = 0 + while (!EuchreRules.isFinished(state) && guard++ < 3000) { + val seat = EuchreRules.currentSeat(state) + if (seat == null) { + state = EuchreRules.nextHand(state) + continue + } + val legal = EuchreRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move in ${state.phase}", legal.contains(move)) + moves += move + state = EuchreRules.applyMove(state, seat, move) + } + assertTrue("game with seed $seed did not finish", EuchreRules.isFinished(state)) + return moves + } + + @Test + fun `every difficulty and style plays whole games legally`() { + for (difficulty in AiDifficulty.entries) { + for (seed in 1L..8L) { + playGame(EuchreAi(AiProfiles(difficulty, seed)), seed) + } + } + } + + @Test + fun `the default profile plays as a medium balanced seat`() { + for (seed in 1L..3L) { + val standard = playGame(EuchreAi(), seed) + val explicit = playGame(EuchreAi(AiProfiles(AiDifficulty.MEDIUM, 99L)), seed) + assertEquals(standard, explicit) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/golf/GolfAiProfileTest.kt b/app/src/test/java/org/prolibertate/games/game/golf/GolfAiProfileTest.kt new file mode 100644 index 0000000..baf42b2 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/golf/GolfAiProfileTest.kt @@ -0,0 +1,72 @@ +package org.prolibertate.games.game.golf + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class GolfAiProfileTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: GolfOptions, seed: Long) = TableConfig( + gameId = "golf", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun play(ai: GolfAi, options: GolfOptions, seed: Long): List { + var state = GolfRules.initialState(config(options, seed)) + val moves = mutableListOf() + var guard = 0 + while (!GolfRules.isFinished(state) && guard++ < 8000) { + val seat = GolfRules.currentSeat(state) + if (seat == null) { + state = GolfRules.nextHole(state) + continue + } + val legal = GolfRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", legal.contains(move)) + moves += move + state = GolfRules.applyMove(state, seat, move) + } + assertTrue("game did not finish", GolfRules.isFinished(state)) + return moves + } + + @Test + fun `every difficulty and style plays complete games legally`() { + val variants = listOf( + GolfOptions(playerCount = 2, gridSize = 6, holes = 2), + GolfOptions(playerCount = 4, gridSize = 9, holes = 2), + GolfOptions(playerCount = 3, gridSize = 4, holes = 2, lineUpFinalPutt = true), + GolfOptions(playerCount = 6, gridSize = 8, holes = 1), + ) + for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD, AiDifficulty.MIXED)) { + for (seed in 0L until 8L) { + val options = variants[(seed % variants.size).toInt()] + play(GolfAi(AiProfiles(difficulty, seed)), options, 400L + seed) + } + } + } + + @Test + fun `the default profile plays like a medium balanced seat`() { + val options = GolfOptions(playerCount = 3, gridSize = 6, holes = 2) + for (seed in 1L..3L) { + val default = play(GolfAi(), options, seed) + val medium = play(GolfAi(AiProfiles(AiDifficulty.MEDIUM, 13L + seed)), options, seed) + assertEquals(default, medium) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/hearts/HeartsAiProfileTest.kt b/app/src/test/java/org/prolibertate/games/game/hearts/HeartsAiProfileTest.kt new file mode 100644 index 0000000..443a094 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/hearts/HeartsAiProfileTest.kt @@ -0,0 +1,63 @@ +package org.prolibertate.games.game.hearts + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class HeartsAiProfileTest { + + private val json = Json { encodeDefaults = true } + + private fun config(seed: Long) = TableConfig( + gameId = "hearts", + seats = (0 until 4).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) }, + optionsJson = json.encodeToString(HeartsOptions(targetScore = 50)), + seed = seed, + ) + + /** Plays a whole game and returns every move made, asserting each was legal. */ + private fun play(ai: HeartsAi, seed: Long): List { + var state = HeartsRules.initialState(config(seed)) + val moves = mutableListOf() + var guard = 0 + while (!HeartsRules.isFinished(state) && guard++ < 5000) { + val seat = HeartsRules.currentSeat(state) + if (seat == null) { + state = HeartsRules.startNextRound(state) + continue + } + val legal = HeartsRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", legal.contains(move)) + moves += move + state = HeartsRules.applyMove(state, seat, move) + } + assertTrue("game did not finish", HeartsRules.isFinished(state)) + return moves + } + + @Test + fun `every difficulty and style plays complete games legally`() { + for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD, AiDifficulty.MIXED)) { + for (seed in 0L until 8L) { + play(HeartsAi(AiProfiles(difficulty, seed)), 100L + seed) + } + } + } + + @Test + fun `the default profile plays like a medium balanced seat`() { + for (seed in 1L..3L) { + val default = play(HeartsAi(), seed) + val medium = play(HeartsAi(AiProfiles(AiDifficulty.MEDIUM, 77L + seed)), seed) + assertEquals(default, medium) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserAiProfilesTest.kt b/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserAiProfilesTest.kt new file mode 100644 index 0000000..5e7959d --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/kaiser/KaiserAiProfilesTest.kt @@ -0,0 +1,68 @@ +package org.prolibertate.games.game.kaiser + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class KaiserAiProfilesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(seed: Long) = TableConfig( + gameId = "kaiser", + seats = (0 until 4).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2) + }, + optionsJson = json.encodeToString(KaiserOptions()), + seed = seed, + ) + + /** Plays up to [hands] hands with every seat on [ai], returning every move chosen. */ + private fun playHands(ai: KaiserAi, seed: Long, hands: Int): List { + val moves = mutableListOf() + var state = KaiserRules.initialState(config(seed)) + var dealt = 1 + var guard = 0 + while (!KaiserRules.isFinished(state) && guard++ < 4000) { + val seat = KaiserRules.currentSeat(state) + if (seat == null) { + if (dealt >= hands) break + dealt++ + state = KaiserRules.nextHand(state) + continue + } + val legal = KaiserRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move in ${state.phase}", legal.contains(move)) + moves += move + state = KaiserRules.applyMove(state, seat, move) + } + return moves + } + + @Test + fun `every difficulty and style plays hands legally`() { + for (difficulty in AiDifficulty.entries) { + for (seed in 1L..8L) { + val moves = playHands(KaiserAi(AiProfiles(difficulty, seed)), seed, hands = 4) + assertTrue(moves.isNotEmpty()) + } + } + } + + @Test + fun `the default profile plays as a medium balanced seat`() { + for (seed in 1L..3L) { + val standard = playHands(KaiserAi(), seed, hands = 3) + val explicit = playHands(KaiserAi(AiProfiles(AiDifficulty.MEDIUM, 99L)), seed, hands = 3) + assertEquals(standard, explicit) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiProfileTest.kt b/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiProfileTest.kt new file mode 100644 index 0000000..2d77f95 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiProfileTest.kt @@ -0,0 +1,40 @@ +package org.prolibertate.games.game.morris + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles + +class MorrisAiProfileTest { + + @Test + fun `every difficulty and seeded style plays only legal moves`() { + val seen = mutableSetOf>() + for (difficulty in AiDifficulty.entries) for (seed in 0L until 12L) { + val profiles = AiProfiles(difficulty, seed) + for (seat in 0..1) profiles.of(seat).let { seen += it.level to it.style } + val ai = MorrisAi(profiles, MorrisLevel.CASUAL) + var state = MorrisRules.initialState(MorrisOptions(level = MorrisLevel.CASUAL)) + var plies = 0 + while (!MorrisRules.isFinished(state) && plies < 8) { + val legal = MorrisRules.legalMoves(state) + val move = ai.chooseMove(state, state.turn, legal) + assertTrue("$difficulty/$seed illegal move $move", move in legal) + state = MorrisRules.applyMove(state, state.turn, move) + plies++ + } + } + assertTrue("expected a spread of level/style pairs, saw ${seen.size}", seen.size >= 8) + } + + @Test + fun `default profile plays the same as an explicit medium table`() { + val state = MorrisRules.initialState(MorrisOptions(level = MorrisLevel.CASUAL)) + val legal = MorrisRules.legalMoves(state) + val a = MorrisAi(level = MorrisLevel.CASUAL).chooseMove(state, state.turn, legal) + val b = MorrisAi(AiProfiles(AiDifficulty.MEDIUM, 5L), MorrisLevel.CASUAL) + .chooseMove(state, state.turn, legal) + assertEquals(a, b) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiTest.kt b/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiTest.kt index ae99e2b..0ea13ff 100644 --- a/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/morris/MorrisAiTest.kt @@ -29,7 +29,7 @@ class MorrisAiTest { ) private fun choose(state: MorrisState, level: MorrisLevel = MorrisLevel.CLUB): MorrisMove = - MorrisAi(level).chooseMove(state, state.turn, MorrisRules.legalMoves(state)) + MorrisAi(level = level).chooseMove(state, state.turn, MorrisRules.legalMoves(state)) @Test fun `it always returns a move it is allowed to play`() { diff --git a/app/src/test/java/org/prolibertate/games/game/pirates/PiratesAiProfileTest.kt b/app/src/test/java/org/prolibertate/games/game/pirates/PiratesAiProfileTest.kt new file mode 100644 index 0000000..6c69ba6 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/pirates/PiratesAiProfileTest.kt @@ -0,0 +1,45 @@ +package org.prolibertate.games.game.pirates + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles + +class PiratesAiProfileTest { + + private fun playout(ai: PiratesAi, steps: Int): List { + var state = PiratesRules.initialState(PiratesOptions(level = PiratesLevel.CASUAL)) + val chosen = mutableListOf() + repeat(steps) { + if (PiratesRules.isFinished(state)) return chosen + val seat = PiratesRules.currentSeat(state) ?: return chosen + val legal = PiratesRules.legalMoves(state, seat) + if (legal.isEmpty()) return chosen + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", move in legal) + chosen += move + state = PiratesRules.applyMove(state, seat, move) + } + return chosen + } + + @Test + fun everyLevelAndStyleReturnsLegalMoves() { + for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.MIXED)) { + for (seed in 0L..5L) { + assertTrue(playout(PiratesAi(AiProfiles(difficulty, seed)), 12).isNotEmpty()) + } + } + for (seed in 0L..3L) { + assertTrue(playout(PiratesAi(AiProfiles(AiDifficulty.HARD, seed)), 4).isNotEmpty()) + } + } + + @Test + fun defaultProfilePlaysAsBefore() { + val plain = playout(PiratesAi(), 10) + val medium = playout(PiratesAi(AiProfiles(AiDifficulty.MEDIUM, 77L)), 10) + assertEquals(plain, medium) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/president/PresidentAiProfileTest.kt b/app/src/test/java/org/prolibertate/games/game/president/PresidentAiProfileTest.kt new file mode 100644 index 0000000..2fd979c --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/president/PresidentAiProfileTest.kt @@ -0,0 +1,71 @@ +package org.prolibertate.games.game.president + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class PresidentAiProfileTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: PresidentOptions, seed: Long) = TableConfig( + gameId = "president", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + private fun play(ai: PresidentAi, options: PresidentOptions, seed: Long): List { + var state = PresidentRules.initialState(config(options, seed)) + val moves = mutableListOf() + var guard = 0 + while (!PresidentRules.isFinished(state) && guard++ < 6000) { + val seat = PresidentRules.currentSeat(state) + if (seat == null) { + state = PresidentRules.nextRound(state) + continue + } + val legal = PresidentRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", legal.contains(move)) + moves += move + state = PresidentRules.applyMove(state, seat, move) + } + assertTrue("game did not finish", PresidentRules.isFinished(state)) + return moves + } + + @Test + fun `every difficulty and style plays complete games legally`() { + val variants = listOf( + PresidentOptions(playerCount = 4, roundsToPlay = 3), + PresidentOptions(playerCount = 5, roundsToPlay = 2, fourOfAKindBomb = true), + PresidentOptions(playerCount = 3, roundsToPlay = 2, twosClear = false), + ) + for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD, AiDifficulty.MIXED)) { + for (seed in 0L until 6L) { + val options = variants[(seed % variants.size).toInt()] + play(PresidentAi(AiProfiles(difficulty, seed)), options, 200L + seed) + } + } + } + + @Test + fun `the default profile plays like a medium balanced seat`() { + val options = PresidentOptions(playerCount = 4, roundsToPlay = 2) + for (seed in 1L..3L) { + val default = play(PresidentAi(), options, seed) + val medium = play(PresidentAi(AiProfiles(AiDifficulty.MEDIUM, 55L + seed)), options, seed) + assertEquals(default, medium) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/sequence/SequenceAiLevelsTest.kt b/app/src/test/java/org/prolibertate/games/game/sequence/SequenceAiLevelsTest.kt new file mode 100644 index 0000000..51535d5 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/sequence/SequenceAiLevelsTest.kt @@ -0,0 +1,89 @@ +package org.prolibertate.games.game.sequence + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.AiStyle +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class SequenceAiLevelsTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: SequenceOptions, seed: Long) = TableConfig( + gameId = "sequence", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % options.teamCount) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + /** Seeds that between them give some seat every style at this difficulty. */ + private fun seedsCoveringStyles(difficulty: AiDifficulty, seats: Int): List { + // The standard opponent is balanced by definition, so there is no style to cover. + if (difficulty == AiDifficulty.MEDIUM) return listOf(0L, 1L, 2L) + val missing = AiStyle.entries.toMutableSet() + val seeds = mutableListOf() + var seed = 0L + while (missing.isNotEmpty() && seed < 500) { + val styles = (0 until seats).map { AiProfiles(difficulty, seed).of(it).style } + if (styles.any { it in missing }) { + seeds += seed + missing.removeAll(styles.toSet()) + } + seed++ + } + assertTrue("styles never covered: $missing", missing.isEmpty()) + return seeds + } + + private fun play(ai: SequenceAi, options: SequenceOptions, seed: Long): List { + var state = SequenceRules.initialState(config(options, seed)) + val moves = mutableListOf() + var guard = 0 + while (!SequenceRules.isFinished(state) && guard++ < 4000) { + val seat = SequenceRules.currentSeat(state) ?: break + val legal = SequenceRules.legalMoves(state, seat) + if (legal.isEmpty()) break + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", legal.contains(move)) + moves += move + state = SequenceRules.applyMove(state, seat, move) + } + assertTrue("game ran away", guard < 4000) + return moves + } + + @Test + fun `every level and style plays a whole game legally`() { + val options = SequenceOptions(teamCount = 2, playersPerTeam = 1) + listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD).forEach { difficulty -> + seedsCoveringStyles(difficulty, options.playerCount).forEach { seed -> + play(SequenceAi(AiProfiles(difficulty, seed)), options, 300L + seed) + } + } + } + + @Test + fun `three teams play legally at every level`() { + val options = SequenceOptions(teamCount = 3, playersPerTeam = 1, sequencesToWin = 1) + listOf(AiDifficulty.EASY, AiDifficulty.HARD, AiDifficulty.MIXED).forEach { difficulty -> + play(SequenceAi(AiProfiles(difficulty, 4L)), options, 11L) + } + } + + @Test + fun `the default profile plays as the standard opponent`() { + val options = SequenceOptions() + val standard = play(SequenceAi(), options, 21L) + val medium = play(SequenceAi(AiProfiles(AiDifficulty.MEDIUM, 6L)), options, 21L) + assertEquals(standard, medium) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/tayu/TayuAiLevelsTest.kt b/app/src/test/java/org/prolibertate/games/game/tayu/TayuAiLevelsTest.kt new file mode 100644 index 0000000..8979e44 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/tayu/TayuAiLevelsTest.kt @@ -0,0 +1,88 @@ +package org.prolibertate.games.game.tayu + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.AiStyle +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class TayuAiLevelsTest { + + private val json = Json { encodeDefaults = true } + + private val options = TayuOptions(tileCopies = 2) + + private fun config(seed: Long) = TableConfig( + gameId = "tayu", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + /** Seeds that between them give some seat every style at this difficulty. */ + private fun seedsCoveringStyles(difficulty: AiDifficulty, seats: Int): List { + // The standard opponent is balanced by definition, so there is no style to cover. + if (difficulty == AiDifficulty.MEDIUM) return listOf(0L, 1L, 2L) + val missing = AiStyle.entries.toMutableSet() + val seeds = mutableListOf() + var seed = 0L + while (missing.isNotEmpty() && seed < 500) { + val styles = (0 until seats).map { AiProfiles(difficulty, seed).of(it).style } + if (styles.any { it in missing }) { + seeds += seed + missing.removeAll(styles.toSet()) + } + seed++ + } + assertTrue("styles never covered: $missing", missing.isEmpty()) + return seeds + } + + private fun play(ai: TayuAi, seed: Long): List { + var state = TayuRules.initialState(config(seed)) + val moves = mutableListOf() + var guard = 0 + while (!TayuRules.isFinished(state) && guard++ < 500) { + val seat = TayuRules.currentSeat(state) ?: break + val legal = TayuRules.legalMoves(state, seat) + if (legal.isEmpty()) break + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move", legal.contains(move)) + moves += move + state = TayuRules.applyMove(state, seat, move) + } + assertTrue("game ran away", guard < 500) + return moves + } + + @Test + fun `every level and style plays a whole game legally`() { + listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD).forEach { difficulty -> + seedsCoveringStyles(difficulty, options.playerCount).forEach { seed -> + play(TayuAi(AiProfiles(difficulty, seed)), 500L + seed) + } + } + } + + @Test + fun `the gentle table setting is respected at every level`() { + listOf(AiDifficulty.EASY, AiDifficulty.HARD).forEach { difficulty -> + play(TayuAi(AiProfiles(difficulty, 2L), TayuLevel.GENTLE), 9L) + } + } + + @Test + fun `the default profile plays as the standard opponent`() { + val standard = play(TayuAi(), 31L) + val medium = play(TayuAi(AiProfiles(AiDifficulty.MEDIUM, 8L)), 31L) + assertEquals(standard, medium) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/wizard/WizardAiProfilesTest.kt b/app/src/test/java/org/prolibertate/games/game/wizard/WizardAiProfilesTest.kt new file mode 100644 index 0000000..7ef23ae --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/wizard/WizardAiProfilesTest.kt @@ -0,0 +1,71 @@ +package org.prolibertate.games.game.wizard + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class WizardAiProfilesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(seed: Long, options: WizardOptions) = TableConfig( + gameId = "wizard", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + /** Plays a whole game with every seat on [ai], returning every move chosen. */ + private fun playGame(ai: WizardAi, seed: Long, options: WizardOptions): List { + val moves = mutableListOf() + var state = WizardRules.initialState(config(seed, options)) + var guard = 0 + while (!WizardRules.isFinished(state) && guard++ < 5000) { + val seat = WizardRules.currentSeat(state) + if (seat == null) { + state = WizardRules.nextRound(state) + continue + } + val legal = WizardRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("illegal move $move in ${state.phase}", legal.contains(move)) + moves += move + state = WizardRules.applyMove(state, seat, move) + } + assertTrue("game with seed $seed did not finish", WizardRules.isFinished(state)) + return moves + } + + @Test + fun `every difficulty and style plays whole games legally`() { + for (difficulty in AiDifficulty.entries) { + for (seed in 1L..6L) { + val options = WizardOptions( + playerCount = 3 + (seed % 4).toInt(), + rounds = 6, + screwTheDealer = seed % 2L == 0L, + ) + playGame(WizardAi(AiProfiles(difficulty, seed)), seed, options) + } + } + } + + @Test + fun `the default profile plays as a medium balanced seat`() { + val options = WizardOptions(playerCount = 4, rounds = 6) + for (seed in 1L..3L) { + val standard = playGame(WizardAi(), seed, options) + val explicit = playGame(WizardAi(AiProfiles(AiDifficulty.MEDIUM, 99L)), seed, options) + assertEquals(standard, explicit) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeAiLevelsTest.kt b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeAiLevelsTest.kt new file mode 100644 index 0000000..1dab4ef --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeAiLevelsTest.kt @@ -0,0 +1,95 @@ +package org.prolibertate.games.game.yahtzee + +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.engine.AiDifficulty +import org.prolibertate.games.game.engine.AiProfiles +import org.prolibertate.games.game.engine.AiStyle +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class YahtzeeAiLevelsTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: YahtzeeOptions, seed: Long) = TableConfig( + gameId = "yahtzee", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + /** Seeds that between them give some seat every style at this difficulty. */ + private fun seedsCoveringStyles(difficulty: AiDifficulty, seats: Int): List { + // The standard opponent is balanced by definition, so there is no style to cover. + if (difficulty == AiDifficulty.MEDIUM) return listOf(0L, 1L, 2L) + val missing = AiStyle.entries.toMutableSet() + val seeds = mutableListOf() + var seed = 0L + while (missing.isNotEmpty() && seed < 500) { + val styles = (0 until seats).map { AiProfiles(difficulty, seed).of(it).style } + if (styles.any { it in missing }) { + seeds += seed + missing.removeAll(styles.toSet()) + } + seed++ + } + assertTrue("styles never covered: $missing", missing.isEmpty()) + return seeds + } + + /** Plays a card out, returning the final state. */ + private fun play(ai: YahtzeeAi, options: YahtzeeOptions, seed: Long): YahtzeeState { + var state = YahtzeeRules.initialState(config(options, seed)) + var guard = 0 + while (!YahtzeeRules.isFinished(state) && guard++ < 1000) { + val legal = YahtzeeRules.legalMoves(state, state.turn) + val move = ai.chooseMove(state, state.turn, legal) + assertTrue("illegal move $move", legal.contains(move)) + state = YahtzeeRules.applyMove(state, state.turn, move) + } + assertTrue("game ran away", guard < 1000) + assertTrue(state.cards.all { it.isComplete }) + return state + } + + @Test + fun `every level and style fills a card legally`() { + val options = YahtzeeOptions(playerCount = 2) + listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD).forEach { difficulty -> + seedsCoveringStyles(difficulty, options.playerCount).forEach { seed -> + play(YahtzeeAi(AiProfiles(difficulty, seed)), options, 40L + seed) + } + } + } + + @Test + fun `the default profile plays as the standard opponent`() { + val options = YahtzeeOptions(playerCount = 2) + repeat(3) { i -> + val standard = play(YahtzeeAi(), options, 70L + i) + val medium = play(YahtzeeAi(AiProfiles(AiDifficulty.MEDIUM, 3L + i)), options, 70L + i) + assertEquals(standard.cards, medium.cards) + } + } + + @Test + fun `hard scores clearly more than easy over a run of solo games`() { + val options = YahtzeeOptions(playerCount = 1) + fun average(difficulty: AiDifficulty): Double = + (1..12).map { i -> + play(YahtzeeAi(AiProfiles(difficulty, i.toLong())), options, 900L + i).totalFor(0) + }.average() + + val easy = average(AiDifficulty.EASY) + val hard = average(AiDifficulty.HARD) + println("yahtzee solo average: easy $easy, hard $hard") + assertTrue("hard $hard did not beat easy $easy", hard > easy + 20) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt index 03d5938..52d0446 100644 --- a/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt @@ -272,7 +272,7 @@ class YahtzeeRulesTest { var guard = 200 while (!YahtzeeRules.isFinished(state) && guard-- > 0) { val legal = YahtzeeRules.legalMoves(state, state.turn) - state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi.chooseMove(state, state.turn, legal)) + state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi().chooseMove(state, state.turn, legal)) } assertTrue("the game should end on its own", guard > 0) assertTrue(YahtzeeRules.isFinished(state)) @@ -288,7 +288,7 @@ class YahtzeeRulesTest { var guard = 400 while (!YahtzeeRules.isFinished(state) && guard-- > 0) { val legal = YahtzeeRules.legalMoves(state, state.turn) - state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi.chooseMove(state, state.turn, legal)) + state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi().chooseMove(state, state.turn, legal)) } assertTrue(guard > 0) assertTrue(state.cards.all { it.isComplete }) diff --git a/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt b/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt index fe09ef8..72db3b4 100644 --- a/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt +++ b/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt @@ -155,9 +155,9 @@ class SaveRoundTripAllGamesTest { @Test fun backgammon() = exercise(GameCatalog.BACKGAMMON, BackgammonRules, BackgammonAi(), opts(BackgammonOptions())) @Test fun mastermind() = exercise(GameCatalog.MASTERMIND, MastermindRules, MastermindAi(), opts(MastermindOptions())) @Test fun pirates() = exercise(GameCatalog.PIRATES, PiratesRules, PiratesAi(), opts(PiratesOptions())) - @Test fun hearts() = exercise(GameCatalog.HEARTS, HeartsRules, HeartsAi, opts(HeartsOptions())) + @Test fun hearts() = exercise(GameCatalog.HEARTS, HeartsRules, HeartsAi(), opts(HeartsOptions())) @Test fun pegSolitaire() = exercise(GameCatalog.PEG_SOLITAIRE, PegSolitaireRules, PegSolitaireAi, opts(PegSolitaireOptions())) - @Test fun yahtzee() = exercise(GameCatalog.YAHTZEE, YahtzeeRules, YahtzeeAi, opts(YahtzeeOptions())) + @Test fun yahtzee() = exercise(GameCatalog.YAHTZEE, YahtzeeRules, YahtzeeAi(), opts(YahtzeeOptions())) @Test fun klondike() = exercise(GameCatalog.KLONDIKE, KlondikeRules, KlondikeAi, opts(KlondikeOptions())) @Test fun freecell() = exercise(GameCatalog.FREECELL, FreeCellRules, FirstLegalAi(), opts(FreeCellOptions())) @Test fun spider() = exercise(GameCatalog.SPIDER, SpiderRules, FirstLegalAi(), opts(SpiderOptions())) @@ -171,7 +171,7 @@ class SaveRoundTripAllGamesTest { GameCatalog.CATAN, CatanRules, CatanAi(), - opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)), + opts(CatanOptions(3, 13, setOf(org.prolibertate.games.game.catan.CatanExpansion.CITIES_AND_KNIGHTS))), steps = 600, ) } 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 index 085736f..78a2373 100644 --- 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 @@ -21,7 +21,7 @@ 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.CatanExpansion import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.ChaseRobber @@ -69,7 +69,7 @@ class CatanCkInteractionTest { 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), + CatanOptions(players, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)), ), seed = seed, ) 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 index 8a8d6f7..bdfe691 100644 --- 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 @@ -8,7 +8,7 @@ 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.CatanExpansion import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.ProgressType import org.prolibertate.games.game.engine.PlayerKind @@ -16,7 +16,7 @@ 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 +import org.prolibertate.games.ui.catanToggleExpansion class CatanCkTextTest { @@ -28,7 +28,7 @@ class CatanCkTextTest { 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), + CatanOptions(3, 13, setOf(CatanExpansion.CITIES_AND_KNIGHTS)), ), seed = 31L, ) @@ -56,20 +56,27 @@ class CatanCkTextTest { @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)) + assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(emptySet())) + assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(setOf(CatanExpansion.CITIES_AND_KNIGHTS))) + assertEquals(10, catanDefaultPoints(emptySet())) + assertEquals(13, catanDefaultPoints(setOf(CatanExpansion.CITIES_AND_KNIGHTS))) val base = CatanOptions() - val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS) - assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet) + val ck = catanToggleExpansion(base, CatanExpansion.CITIES_AND_KNIGHTS, true) + assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS), ck.expansions) assertEquals("the published target for the new rules", 13, ck.pointsToWin) - assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin) + assertEquals(10, catanToggleExpansion(ck, CatanExpansion.CITIES_AND_KNIGHTS, false).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) + val short = catanToggleExpansion(base.copy(pointsToWin = 8), CatanExpansion.CITIES_AND_KNIGHTS, true) assertEquals(8, short.pointsToWin) assertEquals(3, ck.playerCount) + + // Expansions stack: both on has its own menu, and turning one off leaves the other. + val both = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true) + assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), both.expansions) + assertEquals(listOf(10, 12, 14, 16), catanPointsChoices(both.expansions)) + assertEquals(14, both.pointsToWin) + assertEquals(setOf(CatanExpansion.SEAFARERS), catanToggleExpansion(both, CatanExpansion.CITIES_AND_KNIGHTS, false).expansions) } } diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt index af25535..c5e3e55 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt @@ -28,7 +28,8 @@ internal object CatanFixtures { /** A fresh table, on the first setup placement. */ fun start(players: Int = 3, seed: Long = 7L): CatanState = - CatanRules.initialState(config(players, seed)) + // These tests are about the screen, not who starts: pin seat 0 first. + CatanRules.initialState(config(players, seed)).let { it.copy(firstSeat = 0, turn = 0) } /** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */ fun playing( diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanSeafarersInteractionTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanSeafarersInteractionTest.kt index bca9a79..b2bfc3b 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanSeafarersInteractionTest.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanSeafarersInteractionTest.kt @@ -12,7 +12,7 @@ import org.prolibertate.games.game.catan.CatanAi import org.prolibertate.games.game.catan.CatanBuilding import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.catan.CatanPhase -import org.prolibertate.games.game.catan.CatanRuleSet +import org.prolibertate.games.game.catan.CatanExpansion import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.MovePirate @@ -29,7 +29,7 @@ import org.prolibertate.games.game.engine.TableConfig class CatanSeafarersInteractionTest { private fun fresh(): CatanState { - val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS) + val options = CatanOptions(playerCount = 3, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS)) return CatanRules.initialState( TableConfig( gameId = GameCatalog.CATAN, diff --git a/docs/RE-catan-universe.md b/docs/RE-catan-universe.md index 01d09d7..0bb0591 100644 --- a/docs/RE-catan-universe.md +++ b/docs/RE-catan-universe.md @@ -66,7 +66,13 @@ A full-width panel that slides up over the bottom of the screen (`Animator`, col * **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`. * **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are - **not** part of the physical Seafarers rules; they are out of scope here. + **not** part of the physical Seafarers rules. Names and texts only (rule values are compiled code): + `TreasureRule` with chest outcomes (bricks, wool, grain; a resource; a development card; two roads), + `SeafarerOasis` (no rule class of its own, base-game rules), and `GreatCanalRule` with `BuildCanal`, + `CanalVictoryPoints`, "The canal was completely dug. Now grain can be grown." and a target of 21 / 18 points. + The base-game scenario `baseharbormaster`: "2 victory points. This card goes to the first player to acquire at + least 3 harbors. If another player acquires more, he will immediately receive this card." + Implemented as inferred rules; see `docs/RULES-catan-seafarers.md`. * **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip` (number tokens placed per scenario), `Shuffle_Harbors`. * **Island bonus:** special victory points for the first settlement on a new island diff --git a/docs/RULES-catan-ck.md b/docs/RULES-catan-ck.md index f34fc8c..0f2fac9 100644 --- a/docs/RULES-catan-ck.md +++ b/docs/RULES-catan-ck.md @@ -220,3 +220,10 @@ game in `CatanCkSelfPlayTest` ends in a win at thirteen points. - 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. + +## On a Seafarers map + +Cities & Knights can be played on a Seafarers map; the rules for how the two meet are in +[RULES-catan-seafarers.md](RULES-catan-seafarers.md#with-cities--knights). The code reads the board from +`state.topo` throughout, so nothing here assumes the 19-hex island. + diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index e6ea2ac..2682130 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -1,20 +1,47 @@ # Katan: Seafarers -The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. It is the base +Seafarers is an expansion switched on at setup (`CatanOptions.expansions`; the old single `ruleSet` in saves is still read) and its map is chosen beside it (`CatanOptions.scenario`). It is the base game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map), `CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea` -(null in the other rule sets). It cannot be combined with Cities & Knights. +(null in the other rule sets). It can be combined with Cities & Knights; see *With Cities & Knights* below. Where the published rules leave room, or where this game differs, it says so below. -## The map +## The maps -One map, *Heading for New Shores*: a large **home island** (21 hexes, one desert) and three small -islands (3, 3 and 2 hexes) across the water, inside a ring of sea. Which land hexes are gold, which -are the desert and where the sea lies is fixed; the **resources on the other hexes, the number tokens +Each map is a fixed arrangement of land and sea; the **resources on the plain hexes, the number tokens and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14). +Pick the map at setup; each scenario names where the starting settlements may go (`SeaStart`). + +* **Heading for New Shores** (default). A large home island (21 hexes, one desert) and three small islands (3, 3 + and 2 hexes) across the water, with gold fields on two of the small ones. Start on the home island. +* **Four Islands.** Four equal islands (six hexes each) and two one-hex gold islets between them. Each player + may start on **any** of the four main islands, so a table can spread out or crowd one; the islets are the + prize for the first ship to arrive. +* **Through the Desert.** One 25-hex island cut across by a broad desert (seven hexes that pay nothing and + hold the robber), with three islets and gold to sail to. Everyone starts on the island. The desert is + land, so a road can cross it, but nothing beside it pays, which is what makes the far side a race. + +* **The Fog Islands.** A 13-hex home island (one desert), a two-hex islet and a 14-hex bank of fog. + See *Fog* below. + +* **Cloth for Catan.** A 17-hex main island, an eastern isle, two islets, and four **villages** on the coast. + See *Cloth* below. +* **The Forgotten Tribe.** A 17-hex home island and three isles carrying four tribe chips. See *The Forgotten + Tribe* below. + +* **Pirate Islands.** An 18-hex home island and two isles (five and four hexes) each held by a pirate + fortress. See *Pirate Islands* below. +* **Wonders of Catan.** A 13-hex island with a desert, a 12-hex island, and gold islets. See *Wonders* below. + +* **The Treasure Islands** *(Catan Universe extra)*. A 15-hex home island and four fog isles, eight hexes of fog in + all. See *Catan Universe extras* below. +* **The Oases** *(Catan Universe extra)*. One 37-hex island that is mostly desert, with three oases hidden in it. +* **The Great Canal** *(Catan Universe extra)*. A 25-hex island split by a row of five canal tiles, and gold islets. + +A *new island* for the bonus is any island that is not a starting island for that map. The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea, a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching @@ -56,16 +83,124 @@ through an opponent's town and changes between road and ship only at a town of y Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game. +## Fog + +Fog hexes (`Terrain.FOG`) are land that is face down. Under them is a shuffled pile of tiles +(`SeaState.fogDeck`): resources, with one gold field in every five, each with its number token. The +pile is hidden from every seat but the host's, like the development deck. + +* **Finding.** When a road or ship is built (or a ship sails) and an end of it touches a fog hex, the hex is + turned over, taking the next tile and its number. Several hexes can be found by one piece. +* **The find pays.** The finder takes one card of what lies there, supply permitting; a gold field lets the + finder choose any resource (the gold choice, resuming Road Building if that is what was under way). +* **Until then** nothing produces there, nobody may start beside it, and the robber cannot go on it. +* **Differences from the published scenario.** The fog here is always land: the physical tiles can turn out to + be sea, but the board's land and sea are fixed in the geometry, so a found hex never turns to water. Fog is + also on one bank rather than between several islands, and the number tokens are dealt with the tiles, so two + red numbers can end up side by side once found. + +## Cloth + +The published scenario puts cloth villages between the islands and pays cloth when a village's number is rolled. +This implementation is the author's reading of that, not a transcription of the printed rules: + +* A **village** is a land hex with a number token and a stock of cloth (6). It produces no cards and nothing may + be built on it, but its corners can hold settlements like any other hex's. +* When its number rolls, the village pays **one cloth token** to each settlement or city beside it (a city is + worth no more than a settlement here), until the stock is gone. If the stock is short, the seats nearest the + roller, in turn order, take what is left. The robber on a village stops it; the pirate does not. +* **Two cloth are a victory point** (an odd token is worth nothing alone). Tokens are scored, not spent, and not + tradeable. +* Works with Cities & Knights: the village pays after the dice, before the aqueduct and gold. + +## The Forgotten Tribe + +Also the author's reading. A chip lies face up on four hexes of the small isles (the hexes are marked on the +board). The chips are dealt at the start of the game, one of each kind in turn: + +* **The first settlement** built on a crossing beside a chip hex takes that chip; it is gone for everyone else. + (The setup settlements are never beside one, since the home island is the only start.) +* A **point** chip is one victory point. A **harbour** chip lets that seat trade 3:1 at the bank, wherever it is. + A **gold** chip pays two resources of the settler's choice, taken at once like gold. +* The chips come on top of the island bonus for being first to a new island. + +## Pirate Islands + +The author's reading of the scenario; the printed one uses knights and a roaming pirate fleet, which this does not. + +* A **fortress** is a land hex with no number and a strength of 3. Nobody may build a settlement on any crossing of + an island that still has a standing fortress. Roads and ships may go there. +* A seat with a **ship beside** a fortress may **raid** it in its turn for 1 ore, 1 wool and 1 grain, as many times as + it can pay. Each raid takes one point of strength. +* The raid that takes the last point **takes the fortress**: the hex becomes desert (no number, robber may sit there), + the island is open to settle for everyone, and the taker gets **2 victory points**. The robber never sits on a + standing fortress. +* The island still counts as a new island for the first-landing bonus. + +## Wonders + +Also the author's reading. Every seat may build one wonder in four stages, once it has a **city** anywhere on the +board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain; 2: 1 brick, 2 ore, 1 wool; 3: 3 ore, +1 grain, 1 lumber; 4: 3 ore, 2 grain, 1 brick). Each stage built is **one victory point**, and the seat that +builds the fourth stage **wins at once**, whatever the point target. Wonders are not shared: each seat has its own. + +## Catan Universe extras (inferred) + +These three exist in the Catan Universe app but not in the physical set. Only their names, menu texts and log +lines could be read (`docs/RE-catan-universe.md`); the rule values are compiled code that was not recovered. **Every +rule below is inferred from those names and texts**, not copied. + +* **Treasure Islands.** The app's text: "Treasures await the Catanians on neighboring, fog-covered islands. + Discoveries are rewarded!" Its chest rewards (log texts and action names) are bricks, wool and grain; a resource of + choice; a development card; two roads. Here: up to five chests are dealt face down to random fog hexes at the + start. A seat that uncovers the hex (the fog rules) opens the chest at once and takes: *a brick, a wool and a grain*; + *one resource of its choice* (a gold pick); *a development card* (a resource of choice if the deck is empty or it is + Cities & Knights, which has no deck); or *two lumber and two brick* standing in for two free roads (the free-roads + phase would have to nest inside a ship move). The app's Cities & Knights version gives a progress card; that is + not modelled. The app's fixed treasure bonus (`SEAFARER_TREASURE_BONUS`) is not modelled either. +* **The Oases.** The app's text: an enormous desert with rumoured oases and treasures, base-game rules. Here: the + oases are fog hexes inside the desert, uncovered by a road reaching them, and hold the same chests as above. + There is no ship rule on the map itself. +* **The Great Canal.** The app's text, for Cities & Knights plus Seafarers: build a canal; first to 21 (3 players) or + 18 (4 players) wins; the canal's parts earn victory points; when a tile is dug out, grain grows there. Its knights dig + in the app; here digging is cheaper to implement and play without Cities & Knights: a canal tile is a desert hex with + its number token hidden. A seat with a settlement or city on a corner of the tile digs one **unit** for 1 brick and + 1 ore; **three units** finish the tile, which becomes **fields** with the hidden number. Every **two units** a seat + has dug are a **victory point**. The app's gold river, harbour changes and target scores are not modelled. + +## With Cities & Knights + +Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map +(`CatanState.ck` and `CatanState.sea` both set; the target menu is 10, 12, 14 or 16, default 14). +Where the two meet, this game decides as follows: + +* **Ships** cost 1 lumber and 1 wool as before and are built and sailed in a turn like a road, including + with the Road Building progress card (roads and ships, as in Seafarers). +* **Knights** stand on land crossings at the end of one of your roads *or ships*, and walk along that same + network. A crossing that touches only sea is never a knight post. +* **The pirate** sleeps with the robber: only after the first barbarian attack does a seven let the player + move the robber (to land) *or* the pirate (to sea). The pirate robs one good, resource or commodity, from a + seat with a ship beside it. The bishop moves the robber only. +* **Gold** pays after the roll and before the aqueduct and any other chore: a settlement beside it chooses one + resource, a city two. (A city on gold takes two resources, not a resource and a commodity.) +* **Scoring.** First-landing bonus points are added to a seat's points like any other; the barbarians count + cities wherever they stand, island or not. +* **Merchant** goes only on a land hex; **Fog** works as in Seafarers (a find pays a resource card). + ## Not included The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**: -fog islands, treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations, -the scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map -is a new text map and a new enum entry. +the Explorers & Pirates combination, +the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships. +Drought and the app's other scenarios (Six Islands, Oceania, Great Catan, Enchanted Land, the Inka and Big Game sets) +are not built. +A map-only scenario is a new text map and a new `SeaScenario` entry. ## How the AI plays it It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that -bonus is in play. It chooses the resource it values most from gold, always moves the robber (never the pirate) and does +bonus is in play. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is +worth more (`CatanAi.scenarioLure`), a ship that heads for a standing fortress is worth building, and it raids and builds +wonder stages whenever it can pay (`CatanAi.FORT_LURE`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does not sail ships it has already launched. See `CatanAi.shipValue`. diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index 3e8df72..3403f3b 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -23,6 +23,26 @@ A table plays one of two rule sets, chosen on the setup screen building, robber and trade rules and changes or replaces the rest; its state lives behind `CatanState.ck`, which is `null` in the base game. +## Base-island scenarios + +Without Seafarers the setup screen offers an **Island** choice (`CatanOptions.baseScenario`; it works with Cities & +Knights too). Both extras are the author's reading, not transcriptions of the printed scenarios: + +- **Random map** (default) — the usual shuffled island. +- **First Island** — the same island every game: one fixed shuffle (`CatanBoard.FIRST_ISLAND_SEED`) of the standard + tiles, numbers and harbours, so a first game or a rematch has no surprise set-up. It is not the printed beginner + layout. The development deck and the seating still follow the table's own seed. +- **The Harbormaster** — the Catan Universe menu text is "Because trade becomes more and more important, harbours + have an ever increasing significance." Its card: 2 victory points to the first player with at least three harbour + points; another player with more takes it at once. What a harbour point is was not in the app's texts, so it is + inferred: a settlement on a harbour counts **1**, a city **2**, for each harbour it touches. The title is checked after + every settlement, city and set-up placement; **equal points do not take it**, only strictly more (like Longest + Road); it is **+2 victory points** to the holder (`CatanState.harbormaster`). The AI values a harbour spot a + little more here (`CatanAi.HARBOR_LURE`). + +Seafarers games choose a map instead (see [RULES-catan-seafarers.md](RULES-catan-seafarers.md)); the Harbormaster does not apply there. +The app's other base scenario, Ore for Wool, is not built: its rules are not in the texts that could be read. + ## Table - **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).