From e1c325825f6658a77edf6b1ea04c440518b2b8ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 05:14:49 +0000 Subject: [PATCH 01/11] Computer difficulty levels and hidden per-seat strategies Every game with computer opponents gains a setup choice (Easy, Medium, Hard, Mixed), carried in TableConfig.aiDifficulty and through the host lobby. Each computer seat gets a level and a hidden style (balanced, aggressive, cautious, spoiler) derived from the table seed, so saves and hosted tables reproduce it. Medium plays exactly as each AI did before. Easy plays weaker and slips plausibly; Hard searches deeper or counts cards, never using hidden information. Covers Chess, Checkers, Morris, Backgammon, Pirates, Katan (base, Cities & Knights, Seafarers), Euchre, Kaiser, Wizard, Hearts, President, Crazy Eights, Golf, Cribbage, Sequence, Tayu and Yahtzee. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../games/game/backgammon/BackgammonAi.kt | 124 ++++-- .../prolibertate/games/game/catan/CatanAi.kt | 406 +++++++++++++++--- .../games/game/catan/CatanCkAi.kt | 203 +++++++-- .../games/game/checkers/CheckersAi.kt | 68 ++- .../prolibertate/games/game/chess/ChessAi.kt | 81 +++- .../games/game/crazyeights/CrazyEightsAi.kt | 127 +++++- .../games/game/cribbage/CribbageAi.kt | 127 +++++- .../games/game/engine/AiProfile.kt | 123 ++++++ .../prolibertate/games/game/engine/Engine.kt | 2 + .../games/game/euchre/EuchreAi.kt | 290 +++++++++++-- .../prolibertate/games/game/golf/GolfAi.kt | 344 ++++++++++++++- .../games/game/hearts/HeartsAi.kt | 313 ++++++++++++-- .../games/game/kaiser/KaiserAi.kt | 293 +++++++++++-- .../games/game/mastermind/MastermindAi.kt | 16 +- .../games/game/morris/MorrisAi.kt | 73 +++- .../games/game/pirates/PiratesAi.kt | 97 ++++- .../games/game/president/PresidentAi.kt | 264 ++++++++++-- .../games/game/sequence/SequenceAi.kt | 76 +++- .../prolibertate/games/game/tayu/TayuAi.kt | 56 ++- .../games/game/wizard/WizardAi.kt | 264 ++++++++++-- .../games/game/yahtzee/YahtzeeAi.kt | 252 ++++++++++- .../prolibertate/games/net/LobbyController.kt | 5 + .../java/org/prolibertate/games/ui/AppRoot.kt | 12 +- .../prolibertate/games/ui/GameSetupScreen.kt | 26 +- .../org/prolibertate/games/ui/LobbyScreen.kt | 1 + .../org/prolibertate/games/ui/PlayScreen.kt | 37 +- .../backgammon/BackgammonAiProfileTest.kt | 43 ++ .../games/game/catan/CatanAiProfilesTest.kt | 105 +++++ .../game/checkers/CheckersAiProfileTest.kt | 40 ++ .../games/game/chess/ChessAiTest.kt | 53 +++ .../crazyeights/CrazyEightsAiProfileTest.kt | 71 +++ .../game/cribbage/CribbageAiLevelsTest.kt | 101 +++++ .../games/game/engine/AiProfilesTest.kt | 77 ++++ .../games/game/euchre/EuchreAiProfilesTest.kt | 65 +++ .../games/game/golf/GolfAiProfileTest.kt | 72 ++++ .../games/game/hearts/HeartsAiProfileTest.kt | 63 +++ .../games/game/kaiser/KaiserAiProfilesTest.kt | 68 +++ .../games/game/morris/MorrisAiProfileTest.kt | 40 ++ .../games/game/morris/MorrisAiTest.kt | 2 +- .../game/pirates/PiratesAiProfileTest.kt | 45 ++ .../game/president/PresidentAiProfileTest.kt | 71 +++ .../game/sequence/SequenceAiLevelsTest.kt | 89 ++++ .../games/game/tayu/TayuAiLevelsTest.kt | 88 ++++ .../games/game/wizard/WizardAiProfilesTest.kt | 71 +++ .../games/game/yahtzee/YahtzeeAiLevelsTest.kt | 95 ++++ .../games/game/yahtzee/YahtzeeRulesTest.kt | 4 +- .../games/net/SaveRoundTripAllGamesTest.kt | 4 +- 47 files changed, 4495 insertions(+), 452 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/engine/AiProfile.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/backgammon/BackgammonAiProfileTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanAiProfilesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/checkers/CheckersAiProfileTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/crazyeights/CrazyEightsAiProfileTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/cribbage/CribbageAiLevelsTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/engine/AiProfilesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/euchre/EuchreAiProfilesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/golf/GolfAiProfileTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/hearts/HeartsAiProfileTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/kaiser/KaiserAiProfilesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/morris/MorrisAiProfileTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/pirates/PiratesAiProfileTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/president/PresidentAiProfileTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/sequence/SequenceAiLevelsTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/tayu/TayuAiLevelsTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/wizard/WizardAiProfilesTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeAiLevelsTest.kt 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..3d0a95c 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,127 @@ 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 + } + + private 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 } + } // Seafarers: a ship toward an island nobody has landed on is worth more than a card. legal.filterIsInstance() @@ -138,11 +285,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 +303,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 +321,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 +332,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 +471,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 +484,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 +516,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 } } @@ -392,4 +669,9 @@ 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 + } } 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..e81869f 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,26 +18,40 @@ 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() ?: error("Nothing to choose in phase ${state.phase}") @@ -43,18 +61,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,32 +123,57 @@ 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 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 = CatanBoard.hexIndex.getValue(move.hex) var score = 0.0 for (v in CatanBoard.HEX_CORNERS[hex]) { val building = state.buildings[v] ?: continue val worth = if (building.type == BuildingType.CITY) 2 else 1 val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0 - score += if (building.owner == seat) -worth * odds else worth * odds + 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) { @@ -138,11 +188,29 @@ 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) } - ?.let { return it } + settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it } + } // Improvements spend cards nothing else needs. Favour the track furthest along. legal.filterIsInstance() @@ -150,16 +218,16 @@ 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.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 +236,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. @@ -289,26 +382,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 +431,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/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..fc2561a 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,7 @@ package org.prolibertate.games.game.mastermind import kotlin.random.Random +import org.prolibertate.games.game.engine.AiProfiles import org.prolibertate.games.game.engine.GameAi /** @@ -16,9 +17,20 @@ 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 table already carries its own three-step level ([MastermindOptions.level]), + * chosen at setup and shown to the player, and it means exactly what an + * [org.prolibertate.games.game.engine.AiLevel] would here. Layering a second + * knob on top would make the two disagree, so the shared [profiles] are + * accepted for a uniform constructor but this game keeps playing at the level + * the table names. [level] overrides that, which is what the tests use. */ -class MastermindAi(private val level: MastermindLevel? = null) : - GameAi { +class MastermindAi( + @Suppress("unused") private val profiles: AiProfiles = AiProfiles.DEFAULT, + private val level: MastermindLevel? = null, +) : GameAi { + + constructor(level: MastermindLevel?) : this(AiProfiles.DEFAULT, level) override fun chooseMove( state: MastermindState, 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..148671d 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -30,6 +30,7 @@ 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 @@ -88,7 +89,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 +125,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 +245,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 +277,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 +297,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 +311,7 @@ private fun offlineConfig( optionsJson = optionsJson, // Seeded from the clock so consecutive games are not identical. seed = System.currentTimeMillis(), + aiDifficulty = aiDifficulty, ) /** 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/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..c270dfa --- /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: CatanRuleSet, cap: Int) { + val options = CatanOptions( + playerCount = 3, + pointsToWin = when (rules) { + CatanRuleSet.BASE -> 10 + CatanRuleSet.CITIES_AND_KNIGHTS -> 13 + else -> 12 + }, + ruleSet = 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(CatanRuleSet.BASE, 1_500) + + @Test + fun `easy and hard seats of every style play Cities and Knights`() = + forEveryProfile(CatanRuleSet.CITIES_AND_KNIGHTS, 1_500) + + @Test + fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(CatanRuleSet.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(CatanRuleSet.BASE, CatanRuleSet.CITIES_AND_KNIGHTS)) { + val options = CatanOptions(playerCount = 3, pointsToWin = 10, ruleSet = 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/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..80af138 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())) -- 2.47.3 From 97728daf0adc01f58a0fee380cf06ead85052c66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:22:24 +0000 Subject: [PATCH 02/11] Fold per-game opponent levels into computer difficulty; randomise Katan first seat The per-game Casual/Club/Strong rows duplicated the new Computer difficulty setting, so they are gone from setup (Tayu's Gentle stays: it is a table rule). The saved level is still the Medium baseline, so old saves load unchanged. Mastermind now takes its level from the difficulty. Katan's first seat is drawn from the table seed instead of always being seat 0. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../games/game/catan/CatanModel.kt | 2 + .../games/game/catan/CatanRules.kt | 16 +++++-- .../games/game/mastermind/MastermindAi.kt | 20 ++++---- .../prolibertate/games/ui/GameSetupScreen.kt | 48 ------------------- .../games/game/catan/CatanRulesTest.kt | 16 +++++-- .../games/game/catan/CatanSeafarersTest.kt | 2 +- .../games/ui/game/catan/CatanFixtures.kt | 3 +- 7 files changed, 41 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt index a38d599..1aeb977 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 @@ -198,6 +198,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]. */ 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..5c45806 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 @@ -73,12 +73,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, @@ -121,8 +126,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 } @@ -512,9 +518,9 @@ object CatanRules : GameRules { 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) 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 fc2561a..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,7 @@ 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 @@ -18,15 +19,13 @@ import org.prolibertate.games.game.engine.GameAi * 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 table already carries its own three-step level ([MastermindOptions.level]), - * chosen at setup and shown to the player, and it means exactly what an - * [org.prolibertate.games.game.engine.AiLevel] would here. Layering a second - * knob on top would make the two disagree, so the shared [profiles] are - * accepted for a uniform constructor but this game keeps playing at the level - * the table names. [level] overrides that, which is what the tests use. + * 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( - @Suppress("unused") private val profiles: AiProfiles = AiProfiles.DEFAULT, + private val profiles: AiProfiles = AiProfiles.DEFAULT, private val level: MastermindLevel? = null, ) : GameAi { @@ -38,7 +37,12 @@ class MastermindAi( 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/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 148671d..5fcbb2c 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -35,11 +35,8 @@ 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 @@ -57,11 +54,8 @@ 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 @@ -785,13 +779,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", @@ -815,13 +802,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, " + @@ -856,13 +836,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 " + @@ -884,13 +857,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), @@ -955,13 +921,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", @@ -987,13 +946,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/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/CatanSeafarersTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt index 93e496e..770f3af 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 @@ -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/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( -- 2.47.3 From 6c311ad3047700bad5198b91bff4bd8236e35a82 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:50:24 +0000 Subject: [PATCH 03/11] Katan: expansions as toggles, scenario chosen in options Replace the single CatanRuleSet with a set of expansions and add a scenario field; saves with the old ruleSet still decode. Setup shows a toggle per expansion and a map picker for Seafarers. Combining expansions is rejected for now (lifted in a later phase). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- CLAUDE.md | 4 +- .../games/game/catan/CatanModel.kt | 63 +++++++++++-- .../games/game/catan/CatanRules.kt | 6 +- .../prolibertate/games/ui/GameSetupScreen.kt | 90 +++++++++++-------- .../games/game/catan/CatanAiProfilesTest.kt | 22 ++--- .../games/game/catan/CatanCkRulesTest.kt | 20 ++++- .../games/game/catan/CatanCkSelfPlayTest.kt | 4 +- .../games/game/catan/CatanSeafarersTest.kt | 2 +- .../games/net/SaveRoundTripAllGamesTest.kt | 2 +- .../ui/game/catan/CatanCkInteractionTest.kt | 4 +- .../games/ui/game/catan/CatanCkTextTest.kt | 26 +++--- .../catan/CatanSeafarersInteractionTest.kt | 4 +- docs/RULES-catan-seafarers.md | 4 +- 13 files changed, 165 insertions(+), 86 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bbee649..c28dd54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,8 +51,8 @@ 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 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 1aeb977..023275f 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 @@ -151,12 +160,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,15 +170,58 @@ 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, ) { init { require(playerCount in 3..4) { "The island seats three or four players" } require(pointsToWin >= 2) { "pointsToWin must be at least 2" } + // Lifted once Cities & Knights runs on a Seafarers map. + require(expansions.size <= 1) { "Combining expansions is not supported yet" } + } + + 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, +) + +/** 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), + ) + + 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) } } 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 5c45806..018c966 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,15 @@ 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) // 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) { 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 5fcbb2c..15a8dbb 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -59,7 +59,8 @@ import org.prolibertate.games.game.morris.MorrisOptions 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.sequence.SequenceOptions import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuOptions @@ -373,47 +374,48 @@ 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 { // 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 { + 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 - * default unless the one already chosen is on its menu too. + * [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. Expansions cannot be combined yet, + * so switching one on switches the other off. */ -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) setOf(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." } @@ -421,14 +423,24 @@ 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) + } ChipRow( label = "Players", values = listOf(3, 4), @@ -438,7 +450,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)) }, 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 index c270dfa..14d0d2b 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanAiProfilesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiProfilesTest.kt @@ -55,15 +55,15 @@ class CatanAiProfilesTest { (0 until 3).any { p.of(it).style == style } } - private fun forEveryProfile(rules: CatanRuleSet, cap: Int) { + private fun forEveryProfile(rules: Set, cap: Int) { val options = CatanOptions( playerCount = 3, - pointsToWin = when (rules) { - CatanRuleSet.BASE -> 10 - CatanRuleSet.CITIES_AND_KNIGHTS -> 13 - else -> 12 + pointsToWin = when { + CatanExpansion.CITIES_AND_KNIGHTS in rules -> 13 + CatanExpansion.SEAFARERS in rules -> 12 + else -> 10 }, - ruleSet = rules, + expansions = rules, ) for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) { for (style in AiStyle.entries) { @@ -78,14 +78,14 @@ class CatanAiProfilesTest { } @Test - fun `easy and hard seats of every style play the base game`() = forEveryProfile(CatanRuleSet.BASE, 1_500) + 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(CatanRuleSet.CITIES_AND_KNIGHTS, 1_500) + forEveryProfile(setOf(CatanExpansion.CITIES_AND_KNIGHTS), 1_500) @Test - fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(CatanRuleSet.SEAFARERS, 1_500) + 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`() { @@ -95,8 +95,8 @@ class CatanAiProfilesTest { @Test fun `the default profile plays exactly as an explicit medium table does`() { - for (rules in listOf(CatanRuleSet.BASE, CatanRuleSet.CITIES_AND_KNIGHTS)) { - val options = CatanOptions(playerCount = 3, pointsToWin = 10, ruleSet = rules) + 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/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/CatanSeafarersTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt index 770f3af..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, 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 80af138..72db3b4 100644 --- a/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt +++ b/app/src/test/java/org/prolibertate/games/net/SaveRoundTripAllGamesTest.kt @@ -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..3ed5400 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,24 @@ 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 cannot be combined yet: switching one on switches the other off. + val sea = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true) + assertEquals(setOf(CatanExpansion.SEAFARERS), sea.expansions) } } 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/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index e6ea2ac..e4f5941 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -1,10 +1,10 @@ # 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 cannot yet be combined with Cities & Knights; the setup screen switches one off when the other is turned on. Where the published rules leave room, or where this game differs, it says so below. -- 2.47.3 From 5e9bb2feafdf9139a464acb3d909ec2f60db0353 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 17:17:57 +0000 Subject: [PATCH 04/11] Seafarers: Four Islands and Through the Desert maps Add two map-only scenarios. A scenario now says where the starting settlements may go (SeaStart); SeaState.starts holds the starting islands, and the first-landing bonus skips all of them. Saves without starts fall back to the home island. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../prolibertate/games/game/catan/CatanAi.kt | 2 +- .../games/game/catan/CatanModel.kt | 7 +- .../games/game/catan/CatanRules.kt | 5 +- .../games/game/catan/CatanSeaBoard.kt | 60 ++++++++- .../games/game/catan/CatanSeaRules.kt | 2 +- .../games/game/catan/CatanSeaScenariosTest.kt | 125 ++++++++++++++++++ docs/RULES-catan-seafarers.md | 22 ++- 7 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt 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 3d0a95c..3d2968c 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 @@ -646,7 +646,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi= 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) } 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 023275f..88732c7 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 @@ -465,12 +465,17 @@ 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(), ) +/** 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) 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 018c966..6e296c1 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 @@ -119,6 +119,7 @@ object CatanRules : GameRules { pirate = it.pirate, islandBonus = List(topo.islandCount) { NO_PLAYER }, home = it.home, + starts = it.starts, ) }, ) @@ -207,8 +208,8 @@ 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 for (e in state.topo.VERTEX_EDGES[v]) { if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue moves += SetupPlace(v, e) 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..0ca88e1 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,29 @@ 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.", + ), +} + +/** 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 +51,34 @@ object SeaBoards { "..........", ) + private val FOUR_ISLANDS_MAP = listOf( + "............", + "..###..###..", + "..###..###..", + "............", + "..$.....$...", + "............", + "..###..###..", + "..###..###..", + "............", + ) + + private val THROUGH_THE_DESERT_MAP = listOf( + "............", + ".###DD###...", + ".###DDD###..", + "..###DD###..", + "............", + "..$.....$...", + "............", + "...##...#...", + "............", + ) + 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 } private val topologies = HashMap() @@ -72,8 +116,13 @@ 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), ) + /** An island of this many land hexes or more can be started on. */ + private const val MAIN_ISLAND_HEXES = 4 + private val RESOURCE_TILES = listOf( Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS, ) @@ -139,6 +188,13 @@ 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) } + } + + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts) } } 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..01da44e 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 @@ -153,7 +153,7 @@ internal object SeaRules { 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/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..b8ad82d --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -0,0 +1,125 @@ +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)) + } + + @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) + assertEquals("$scenario hex $h", land && s.terrains[h] != Terrain.DESERT, s.numbers[h] != 0) + } + assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS) + 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 }) + } + + @Test + fun `engines finish a game on every map at three and four players`() { + 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) + assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state))) + } + } + } +} diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index e4f5941..494a11b 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -8,13 +8,23 @@ settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` 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. + +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 @@ -60,8 +70,8 @@ Everything else (development cards, trading, harbours, Largest Army, the discard 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 scenarios that need new rules (fog, cloth, wonders, the forgotten tribe, pirate islands) and trade ships. +A map-only scenario is a new text map and a new `SeaScenario` entry. ## How the AI plays it -- 2.47.3 From 87cf8decaa08252260f6a835ab295ed4fcd1cd92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:40:18 +0000 Subject: [PATCH 05/11] Seafarers: The Fog Islands scenario Add fog: land face down until a road or ship reaches it. The hidden tiles live in SeaState.fogDeck (hidden from other seats' views); a find pays one card of the resource or a gold choice, which resumes Road Building if that was under way. Fog is always land, so the fixed topology still holds. The AI is lured toward fog. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../prolibertate/games/game/catan/CatanAi.kt | 5 + .../games/game/catan/CatanModel.kt | 17 +++ .../games/game/catan/CatanRules.kt | 24 ++-- .../games/game/catan/CatanSeaBoard.kt | 37 +++++- .../games/game/catan/CatanSeaRules.kt | 50 +++++++- .../games/ui/game/catan/CatanBoardView.kt | 10 ++ .../games/ui/game/catan/CatanStyle.kt | 2 + .../games/ui/game/catan/CatanText.kt | 1 + .../games/game/catan/CatanSeaScenariosTest.kt | 110 +++++++++++++++++- docs/RULES-catan-seafarers.md | 23 +++- 10 files changed, 266 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index 3d2968c..6ac67f6 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 @@ -644,6 +644,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi() 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())) if (CatanRules.vertexOpenForSettlement(state, v)) { val island = topo.vertexIsland[v] val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER @@ -673,5 +675,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi = 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, ) +/** 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 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 6e296c1..ecffed2 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 @@ -120,6 +120,7 @@ object CatanRules : GameRules { islandBonus = List(topo.islandCount) { NO_PLAYER }, home = it.home, starts = it.starts, + fogDeck = it.fogDeck, ) }, ) @@ -210,6 +211,8 @@ object CatanRules : GameRules { if (!vertexOpenForSettlement(state, v)) 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) @@ -245,7 +248,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) continue val victims = state.topo.HEX_CORNERS[h] .mapNotNull { state.buildings[it] } .map { it.owner } @@ -661,7 +664,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 { @@ -680,7 +685,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 { @@ -693,9 +698,13 @@ 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 { @@ -1136,7 +1145,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, @@ -1205,6 +1214,7 @@ object CatanRules : GameRules { state.copy( // Hidden information stays with the holder and the host. devDeck = emptyList(), + sea = state.sea?.copy(fogDeck = 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 0ca88e1..5291e3f 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 @@ -19,6 +19,11 @@ enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart "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.", + ), } /** Where the starting settlements may go. */ @@ -75,10 +80,23 @@ object SeaBoards { "............", ) + /** `F` is fog: land that is face down until found. */ + private val FOG_ISLANDS_MAP = listOf( + "............", + ".###....FF..", + ".####..FFF..", + "..#D#..FFFF.", + ".###...FFF..", + ".......FF...", + "...##.......", + "............", + ) + 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 } private val topologies = HashMap() @@ -118,6 +136,8 @@ object SeaBoards { 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(), ) /** An island of this many land hexes or more can be started on. */ @@ -145,10 +165,12 @@ object SeaBoards { '$' -> Terrain.GOLD 'D' -> Terrain.DESERT '#' -> pool[plain.indexOf(h)] + 'F' -> Terrain.FOG else -> Terrain.SEA } } + // 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 } check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" } val tokens = TOKENS.take(numbered.size).shuffled(random) @@ -168,6 +190,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) @@ -178,7 +208,10 @@ 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 = fogHexes.map { topo.islandOf[it] }.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 -> @@ -195,6 +228,6 @@ object SeaBoards { }.ifEmpty { listOf(home) } } - return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts) + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck) } } 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 01da44e..536c144 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 @@ -135,12 +135,60 @@ 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) + } + } + if (owed.isEmpty()) return s + val merged = GoldOwed(seat, owed.sumOf { it.count }) + val sea = s.sea!! + return settleGold(s.copy(sea = sea.copy(gold = sea.gold + merged, afterGold = s.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold))) + } + // ----------------------------------------------------------------------- // Islands and scoring // ----------------------------------------------------------------------- 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..672c54c 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 @@ -156,6 +156,16 @@ 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, + ) + } } } 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..44bc064 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,8 @@ 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), ), 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..55d75b3 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,7 @@ object CatanText { Terrain.DESERT -> "Desert" Terrain.SEA -> "Sea" Terrain.GOLD -> "Gold field" + Terrain.FOG -> "Fog" } fun devCard(type: DevCardType): String = when (type) { 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 index b8ad82d..8cf1687 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -42,6 +42,7 @@ class CatanSeaScenariosTest { 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)) } @Test @@ -54,9 +55,14 @@ class CatanSeaScenariosTest { for (h in 0 until topo.HEX_COUNT) { val land = s.terrains[h] != Terrain.SEA assertEquals("$scenario hex $h", topo.land[h], land) - assertEquals("$scenario hex $h", land && s.terrains[h] != Terrain.DESERT, s.numbers[h] != 0) + val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG + assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0) } assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS) + // 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 } @@ -100,8 +106,108 @@ class CatanSeaScenariosTest { 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 for (scenario in SeaScenario.entries) { for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) { val opts = options(scenario, players) @@ -118,8 +224,10 @@ class CatanSeaScenariosTest { 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 assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state))) } } + assertTrue("no engine ever uncovered a fog hex", fogFound > 0) } } diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index 494a11b..1feaa98 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -24,6 +24,9 @@ Pick the map at setup; each scenario names where the starting settlements may go 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. + 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, @@ -66,11 +69,27 @@ 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. + ## 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 that need new rules (fog, cloth, wonders, the forgotten tribe, pirate islands) and trade ships. +treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations, +the scenarios that need new rules (cloth, wonders, the forgotten tribe, pirate islands) and trade ships. A map-only scenario is a new text map and a new `SeaScenario` entry. ## How the AI plays it -- 2.47.3 From 7f3c2fe0e9a976f22585a9321682379ca04e16f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 20:35:19 +0000 Subject: [PATCH 06/11] Katan: Cities & Knights on Seafarers maps Lift the one-expansion limit. Cities & Knights now reads the board from state.topo, so it runs on any Seafarers map. Ships, knights (on land posts reached by road or ship), the pirate (after the first barbarian attack, robbing goods) and gold (chosen after the roll, before chores) are wired into the C&K turn; first-landing bonuses count toward points. Setup toggles stack, with a 10-16 point menu when both are on. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- CLAUDE.md | 4 +- .../prolibertate/games/game/catan/CatanAi.kt | 2 +- .../prolibertate/games/game/catan/CatanCk.kt | 105 ++++++++---- .../games/game/catan/CatanCkAi.kt | 23 ++- .../games/game/catan/CatanModel.kt | 2 - .../games/game/catan/CatanRules.kt | 3 +- .../games/game/catan/CatanSeaRules.kt | 2 +- .../prolibertate/games/ui/GameSetupScreen.kt | 8 +- .../games/ui/game/catan/CatanScreen.kt | 2 +- .../games/ui/game/catan/CatanText.kt | 13 +- .../games/game/catan/CatanSeaCkTest.kt | 154 ++++++++++++++++++ .../games/ui/game/catan/CatanCkTextTest.kt | 9 +- docs/RULES-catan-ck.md | 7 + docs/RULES-catan-seafarers.md | 23 ++- 14 files changed, 298 insertions(+), 59 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanSeaCkTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index c28dd54..095f66e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,9 +56,9 @@ The island game is catalogue id `catan`, shown as **Katan**. It has two expansio (`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/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index 6ac67f6..5de0e0a 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 @@ -189,7 +189,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi, profile: AiProfile): CatanMove { + 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) { 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..b53fb5b 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,7 @@ object CatanCk { points[seat] += ck.defenderVp[seat] + ck.progressVp[seat] } ck.merchant?.let { points[it.seat] += 1 } + if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat) return points.toList() } @@ -266,15 +271,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 +335,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 +348,10 @@ 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) + } if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) { moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) } } @@ -380,7 +392,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 +435,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 +474,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 +524,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 +559,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 +588,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 +625,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 +1529,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 } @@ -1546,7 +1575,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 e81869f..30bff34 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 @@ -53,7 +53,9 @@ internal class CatanCkAi( 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}") } @@ -131,9 +133,9 @@ internal class CatanCkAi( 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 = CatanBoard.hexIndex.getValue(move.hex) + 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 @@ -178,7 +180,7 @@ internal class CatanCkAi( // 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 } @@ -212,6 +214,12 @@ internal class CatanCkAi( settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.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. legal.filterIsInstance() .maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) } @@ -226,6 +234,11 @@ internal class CatanCkAi( 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 - 0.04 * boldness } .maxByOrNull { ai.roadValue(state, it) } @@ -360,7 +373,7 @@ internal class CatanCkAi( /** 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 } 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 954d99d..4b54083 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 @@ -192,8 +192,6 @@ data class CatanOptions( init { require(playerCount in 3..4) { "The island seats three or four players" } require(pointsToWin >= 2) { "pointsToWin must be at least 2" } - // Lifted once Cities & Knights runs on a Seafarers map. - require(expansions.size <= 1) { "Combining expansions is not supported yet" } } val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions 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 ecffed2..5e0138d 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 @@ -601,7 +601,7 @@ object CatanRules : GameRules { } /** 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) { @@ -708,6 +708,7 @@ object CatanRules : GameRules { } 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 = "" 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 536c144..0fda06f 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 { 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 15a8dbb..ccca392 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -376,6 +376,8 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId) /** 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. CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14) // Cities & Knights is played to thirteen, which is also why it runs longer. @@ -386,6 +388,7 @@ internal fun catanPointsChoices(expansions: Set): List = wh /** 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 @@ -393,11 +396,10 @@ internal fun catanDefaultPoints(expansions: Set): Int = when { /** * [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. Expansions cannot be combined yet, - * so switching one on switches the other off. + * default unless the one already chosen is on its menu too. */ internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions { - val expansions = if (on) setOf(expansion) else options.expansions - expansion + val expansions = if (on) options.expansions + expansion else options.expansions - expansion return options.copy( expansions = expansions, pointsToWin = options.pointsToWin.takeIf { 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..b5659e9 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 @@ -198,7 +198,7 @@ fun CatanScreen( val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val actions = CatanActions( buildKinds = when { - current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP } + current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP || current.isSea } current.isSea -> BuildKind.SEAFARERS else -> BuildKind.BASE }, 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 55d75b3..a90a657 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 @@ -194,7 +194,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 @@ -401,6 +401,7 @@ object CatanText { building: BuildKind?, movingKnight: Boolean, armed: ProgressType?, + movingShip: Boolean, ): String { val me = seats.me val onClock = CatanRules.currentSeat(state) @@ -409,7 +410,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 @@ -435,6 +441,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." } @@ -442,6 +449,8 @@ 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.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/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/ui/game/catan/CatanCkTextTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt index 3ed5400..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 @@ -72,8 +72,11 @@ class CatanCkTextTest { assertEquals(8, short.pointsToWin) assertEquals(3, ck.playerCount) - // Expansions cannot be combined yet: switching one on switches the other off. - val sea = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true) - assertEquals(setOf(CatanExpansion.SEAFARERS), sea.expansions) + // 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/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 1feaa98..d500da7 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -4,7 +4,7 @@ Seafarers is an expansion switched on at setup (`CatanOptions.expansions`; the o 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 yet be combined with Cities & Knights; the setup screen switches one off when the other is turned on. +(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. @@ -85,10 +85,29 @@ pile is hidden from every seat but the host's, like the development deck. 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. +## 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**: -treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations, +treasure islands, the oasis, the great canal, the Explorers & Pirates combination, the scenarios that need new rules (cloth, wonders, the forgotten tribe, pirate islands) and trade ships. A map-only scenario is a new text map and a new `SeaScenario` entry. -- 2.47.3 From c3f2efa31343dac41839a1cffeed061319555a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 22:53:25 +0000 Subject: [PATCH 07/11] Seafarers: Cloth for Catan and The Forgotten Tribe Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../prolibertate/games/game/catan/CatanAi.kt | 17 ++ .../prolibertate/games/game/catan/CatanCk.kt | 1 + .../games/game/catan/CatanModel.kt | 28 +++ .../games/game/catan/CatanRules.kt | 9 +- .../games/game/catan/CatanSeaBoard.kt | 65 ++++++- .../games/game/catan/CatanSeaRules.kt | 107 ++++++++++- .../games/ui/game/catan/CatanBoardView.kt | 21 +++ .../games/ui/game/catan/CatanPanels.kt | 11 ++ .../games/ui/game/catan/CatanStyle.kt | 2 + .../games/ui/game/catan/CatanText.kt | 1 + .../games/game/catan/CatanSeaExtrasTest.kt | 168 ++++++++++++++++++ .../games/game/catan/CatanSeaScenariosTest.kt | 14 +- docs/RULES-catan-seafarers.md | 35 +++- 13 files changed, 465 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt 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 5de0e0a..5d1bc63 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 @@ -592,6 +592,20 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi 0) { + lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH + } + 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 @@ -602,6 +616,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi = 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(), ) +/** 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) 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 5e0138d..95d8705 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 @@ -121,6 +121,9 @@ object CatanRules : GameRules { 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, ) }, ) @@ -596,6 +599,7 @@ 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))) } @@ -743,7 +747,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) @@ -1184,6 +1188,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 } @@ -1194,7 +1199,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 } // ----------------------------------------------------------------------- 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 5291e3f..24748ce 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 @@ -24,6 +24,16 @@ enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart "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.", ), + 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. */ @@ -92,11 +102,37 @@ object SeaBoards { "............", ) + /** `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 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.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP + SeaScenario.FORGOTTEN_TRIBE -> FORGOTTEN_TRIBE_MAP } private val topologies = HashMap() @@ -138,11 +174,18 @@ object SeaBoards { 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(), ) /** 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 + private val RESOURCE_TILES = listOf( Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS, ) @@ -157,14 +200,15 @@ 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 'F' -> Terrain.FOG else -> Terrain.SEA } @@ -228,6 +272,21 @@ object SeaBoards { }.ifEmpty { listOf(home) } } - return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck) + // 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 } + } + + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages) } } 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 0fda06f..594cdc8 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 @@ -184,18 +184,113 @@ internal object SeaRules { } } if (owed.isEmpty()) return s - val merged = GoldOwed(seat, owed.sumOf { it.count }) - val sea = s.sea!! - return settleGold(s.copy(sea = sea.copy(gold = sea.gold + merged, afterGold = s.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold))) + return owe(s, seat, owed.sumOf { it.count }) + } + + /** 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 + + clothOf(state, seat) / CLOTH_PER_POINT + + (sea.chipPoints.getOrNull(seat) ?: 0) + } + + // ----------------------------------------------------------------------- + // 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 + } + + // ----------------------------------------------------------------------- + // 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 { 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 672c54c..49d3f1d 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 @@ -34,6 +34,7 @@ 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.Terrain +import org.prolibertate.games.game.catan.TribeChip /** * The island, drawn and tappable. @@ -166,6 +167,26 @@ private fun DrawScope.drawHexes( weight = FontWeight.Bold, ) } + val sea = state.sea + val note = when { + state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth" + 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/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index 00f79e2..779b0df 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,7 @@ 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") + addAll(seaAwards(state, seat, compact)) } CardLine( text = ckAwards.joinToString(" · ").ifEmpty { BLANK }, @@ -280,6 +281,7 @@ 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") + addAll(seaAwards(state, seat, compact)) } // Always drawn, so the card does not grow when somebody takes an award. CardLine( @@ -634,3 +636,12 @@ 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 (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/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt index 44bc064..1d6c65b 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 @@ -126,6 +126,8 @@ private val LightStyle = CatanStyle( 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), ), 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 a90a657..188a792 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 @@ -75,6 +75,7 @@ object CatanText { Terrain.SEA -> "Sea" Terrain.GOLD -> "Gold field" Terrain.FOG -> "Fog" + Terrain.VILLAGE -> "Village" } fun devCard(type: DevCardType): String = when (type) { 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..700c3e9 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt @@ -0,0 +1,168 @@ +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) + } +} 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 index 8cf1687..ee83e03 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -43,6 +43,8 @@ class CatanSeaScenariosTest { 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(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN)) + assertEquals(listOf(2, 3, 5, 17), sizes(SeaScenario.FORGOTTEN_TRIBE)) } @Test @@ -58,7 +60,9 @@ class CatanSeaScenariosTest { val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0) } - assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS) + val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN && + scenario != SeaScenario.FORGOTTEN_TRIBE + 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) @@ -208,6 +212,8 @@ class CatanSeaScenariosTest { @Test fun `engines finish a game on every map at three and four players`() { var fogFound = 0 + var clothPaid = 0 + var chipsTaken = 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) @@ -225,9 +231,15 @@ class CatanSeaScenariosTest { } 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.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) } } diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index d500da7..1b1cbbd 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -27,6 +27,11 @@ Pick the map at setup; each scenario names where the starting settlements may go * **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. + 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, @@ -85,6 +90,31 @@ pile is hidden from every seat but the host's, like the development deck. 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. + ## With Cities & Knights Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map @@ -108,12 +138,13 @@ Where the two meet, this game decides as follows: The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**: treasure islands, the oasis, the great canal, the Explorers & Pirates combination, -the scenarios that need new rules (cloth, wonders, the forgotten tribe, pirate islands) and trade ships. +the scenarios that need new rules (wonders, pirate islands) and trade ships. 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`). 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`. -- 2.47.3 From 20b6cb4b5f42befe744473da65f9e302c1cf50e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 05:21:46 +0000 Subject: [PATCH 08/11] Seafarers: Pirate Islands and Wonders of Catan Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../prolibertate/games/game/catan/CatanAi.kt | 10 ++ .../prolibertate/games/game/catan/CatanCk.kt | 1 + .../games/game/catan/CatanCkAi.kt | 3 + .../games/game/catan/CatanModel.kt | 22 +++++ .../games/game/catan/CatanRules.kt | 8 +- .../games/game/catan/CatanSeaBoard.kt | 51 +++++++++- .../games/game/catan/CatanSeaRules.kt | 97 +++++++++++++++++++ .../games/ui/game/catan/CatanBoardView.kt | 1 + .../games/ui/game/catan/CatanHud.kt | 4 + .../games/ui/game/catan/CatanInteraction.kt | 32 +++++- .../games/ui/game/catan/CatanPanels.kt | 5 + .../games/ui/game/catan/CatanScreen.kt | 15 ++- .../games/ui/game/catan/CatanStyle.kt | 2 + .../games/ui/game/catan/CatanText.kt | 7 ++ .../games/game/catan/CatanSeaExtrasTest.kt | 76 +++++++++++++++ .../games/game/catan/CatanSeaScenariosTest.kt | 13 ++- docs/RULES-catan-seafarers.md | 29 +++++- 17 files changed, 363 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index 5d1bc63..37041e8 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 @@ -277,6 +277,10 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi().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() .filter { shipValue(state, seat, it.edge) > 0.5 } @@ -661,6 +665,11 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi + 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 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER @@ -693,6 +702,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi().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 } 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 2cd5ac6..1ed0481 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 @@ -63,6 +63,12 @@ enum class Terrain(val produces: Resource?) { /** 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), } /** Axial coordinate of one hex on the board. See [CatanBoard]. */ @@ -495,6 +501,12 @@ data class SeaState( 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(), ) /** What the first settler beside a tribe hex takes (Forgotten Tribe). */ @@ -541,3 +553,13 @@ 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 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 95d8705..2f20df9 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 @@ -124,6 +124,8 @@ object CatanRules : GameRules { cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 }, villageCloth = it.villageCloth, chips = it.chips, + fortress = it.fortress, + wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(), ) }, ) @@ -251,7 +253,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] || state.terrains[h] == Terrain.FOG) 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 } @@ -283,6 +285,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) { @@ -417,6 +420,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 } /** @@ -470,6 +474,8 @@ 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) + BuildWonder -> SeaRules.applyWonder(state, seat) is BuildRoad -> applyBuildRoad(state, seat, move) is BuildSettlement -> applyBuildSettlement(state, seat, move) is BuildCity -> applyBuildCity(state, seat, move) 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 24748ce..2a90a1e 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 @@ -24,6 +24,16 @@ enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart "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.", ), + 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; " + @@ -126,11 +136,36 @@ object SeaBoards { "............", ) + /** `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.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 } @@ -178,6 +213,8 @@ object SeaBoards { 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(), ) /** An island of this many land hexes or more can be started on. */ @@ -186,6 +223,9 @@ object SeaBoards { /** Cloth each village starts with. */ const val VILLAGE_CLOTH = 6 + /** Ships' raids a pirate fortress takes to fall. */ + const val FORTRESS_STRENGTH = 3 + private val RESOURCE_TILES = listOf( Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS, ) @@ -209,13 +249,14 @@ object SeaBoards { 'D' -> Terrain.DESERT '#', 't' -> pool[plain.indexOf(h)] 'V' -> Terrain.VILLAGE + 'P' -> Terrain.FORTRESS 'F' -> Terrain.FOG else -> Terrain.SEA } } // 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 } + val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS } 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) @@ -287,6 +328,12 @@ object SeaBoards { List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 } } - return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages) + val fortress = if (kinds.none { it == 'P' }) { + emptyList() + } else { + List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 } + } + + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress) } } 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 594cdc8..e1b6dd6 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 @@ -205,6 +205,8 @@ internal object SeaRules { 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) } @@ -252,6 +254,101 @@ internal object SeaRules { 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]) } + } + 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 // ----------------------------------------------------------------------- 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 49d3f1d..6d9532a 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 @@ -170,6 +170,7 @@ private fun DrawScope.drawHexes( val sea = state.sea val note = when { state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth" + 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" 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..31fc508 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,8 @@ object CatanHudGlyphs { BuildKind.WALL -> "▦" BuildKind.KNIGHT -> "♞" BuildKind.SHIP -> "⛵" + BuildKind.RAID -> "⚔" + BuildKind.WONDER -> "✦" } fun label(kind: BuildKind): String = when (kind) { @@ -182,6 +184,8 @@ object CatanHudGlyphs { BuildKind.WALL -> CatanText.WALL BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP + BuildKind.RAID -> CatanText.RAID + BuildKind.WONDER -> CatanText.WONDER } } 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..f83f73d 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,9 @@ 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.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 +69,34 @@ 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, + + /** 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.wonders.isNotEmpty()) add(WONDER) + } + val usual = when { + state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, WONDER) + state.isSea -> SEAFARERS + else -> BASE + } + return usual + extras + } } } @@ -252,6 +275,10 @@ 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.WONDER -> BoardTargets() BuildKind.SETTLEMENT -> BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) @@ -519,6 +546,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 }?.let { return TapOutcome.Submit(it) } return when { moves.isNotEmpty() -> if (moves.size == 1) { TapOutcome.Submit(moves.first()) @@ -586,6 +614,8 @@ 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 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 779b0df..00a4812 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 @@ -600,6 +600,8 @@ 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.WONDER -> CatanText.WONDER } private fun buildCost(kind: BuildKind): String = when (kind) { @@ -609,6 +611,8 @@ 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.WONDER -> CatanText.WONDER_COST } @Composable @@ -642,6 +646,7 @@ private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List 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 } - current.isSea -> BuildKind.SEAFARERS - else -> BuildKind.BASE - }, + buildKinds = BuildKind.of(current), ck = current.isCk, canImprove = CatanInteraction.improvable(legal).isNotEmpty(), canPlayProgress = progressHand.any { it.playable }, 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 1d6c65b..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 @@ -128,6 +128,8 @@ private val LightStyle = CatanStyle( 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 188a792..038e6a3 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 @@ -76,6 +76,7 @@ object CatanText { Terrain.GOLD -> "Gold field" Terrain.FOG -> "Fog" Terrain.VILLAGE -> "Village" + Terrain.FORTRESS -> "Pirate fortress" } fun devCard(type: DevCardType): String = when (type) { @@ -115,6 +116,10 @@ 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 WONDER = "Wonder" + const val WONDER_COST = "next stage" const val GOLD_TITLE = "Gold field" const val GOLD_TAKE = "Take" const val ROLL = "Roll dice" @@ -248,6 +253,7 @@ 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.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." @@ -452,6 +458,7 @@ object CatanText { 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.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/catan/CatanSeaExtrasTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt index 700c3e9..f54e52f 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt @@ -165,4 +165,80 @@ class CatanSeaExtrasTest { 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 }) + } } 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 index ee83e03..e2be5b1 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -43,6 +43,8 @@ class CatanSeaScenariosTest { 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(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)) } @@ -57,11 +59,12 @@ class CatanSeaScenariosTest { 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 + 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.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_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 } @@ -214,6 +217,8 @@ class CatanSeaScenariosTest { var fogFound = 0 var clothPaid = 0 var chipsTaken = 0 + var fortsTaken = 0 + var wonderStages = 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) @@ -232,6 +237,8 @@ class CatanSeaScenariosTest { 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.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum() if (scenario == SeaScenario.FORGOTTEN_TRIBE) { chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE } } @@ -241,5 +248,7 @@ class CatanSeaScenariosTest { 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 wonder stage was ever built", wonderStages > 0) } } diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index 1b1cbbd..1c5ef98 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -32,6 +32,10 @@ Pick the map at setup; each scenario names where the starting settlements may go * **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. + 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, @@ -115,6 +119,26 @@ board). The chips are dealt at the start of the game, one of each kind in turn: 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. + ## With Cities & Knights Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map @@ -138,7 +162,7 @@ Where the two meet, this game decides as follows: The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**: treasure islands, the oasis, the great canal, the Explorers & Pirates combination, -the scenarios that need new rules (wonders, pirate islands) and trade ships. +the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships. A map-only scenario is a new text map and a new `SeaScenario` entry. ## How the AI plays it @@ -146,5 +170,6 @@ A map-only scenario is a new text map and a new `SeaScenario` entry. 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. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is -worth more (`CatanAi.scenarioLure`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does +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`. -- 2.47.3 From cd220396f916e8bb0fa496cb5e5140ff8c4f9f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 17:11:47 +0000 Subject: [PATCH 09/11] Seafarers: Treasure Islands, Oases and the Great Canal (inferred rules) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../prolibertate/games/game/catan/CatanAi.kt | 4 + .../games/game/catan/CatanCkAi.kt | 1 + .../games/game/catan/CatanModel.kt | 31 +++++++ .../games/game/catan/CatanRules.kt | 7 +- .../games/game/catan/CatanSeaBoard.kt | 85 ++++++++++++++++- .../games/game/catan/CatanSeaRules.kt | 93 ++++++++++++++++++- .../games/ui/game/catan/CatanBoardView.kt | 3 + .../games/ui/game/catan/CatanHud.kt | 2 + .../games/ui/game/catan/CatanInteraction.kt | 13 ++- .../games/ui/game/catan/CatanPanels.kt | 3 + .../games/ui/game/catan/CatanText.kt | 4 + .../games/game/catan/CatanSeaExtrasTest.kt | 92 ++++++++++++++++++ .../games/game/catan/CatanSeaScenariosTest.kt | 14 ++- docs/RE-catan-universe.md | 8 +- docs/RULES-catan-seafarers.md | 33 ++++++- 15 files changed, 383 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index 37041e8..30f1869 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 @@ -279,6 +279,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi().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. @@ -605,6 +606,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi 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 @@ -705,5 +708,6 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi().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. 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 1ed0481..dc6f1a5 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 @@ -507,8 +507,34 @@ data class SeaState( 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 { @@ -563,3 +589,8 @@ data class RaidFortress(val hex: HexCoord) : CatanMove @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 2f20df9..0e7ef6d 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 @@ -125,6 +125,10 @@ object CatanRules : GameRules { 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(), ) }, @@ -475,6 +479,7 @@ object CatanRules : GameRules { 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) @@ -1226,7 +1231,7 @@ object CatanRules : GameRules { state.copy( // Hidden information stays with the holder and the host. devDeck = emptyList(), - sea = state.sea?.copy(fogDeck = 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 2a90a1e..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 @@ -24,6 +24,21 @@ enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart "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; " + @@ -136,6 +151,39 @@ object SeaBoards { "............", ) + 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( "............", @@ -164,6 +212,9 @@ object SeaBoards { 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 @@ -215,6 +266,10 @@ object SeaBoards { 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. */ @@ -226,6 +281,9 @@ object SeaBoards { /** 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, ) @@ -250,13 +308,14 @@ object SeaBoards { '#', 't' -> pool[plain.indexOf(h)] 'V' -> Terrain.VILLAGE 'P' -> Terrain.FORTRESS + 'C' -> Terrain.DESERT 'F' -> Terrain.FOG else -> Terrain.SEA } } // 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 } + 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) @@ -294,7 +353,9 @@ object SeaBoards { val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 } ?: numbered.first() // Home is the largest island that is not under the fog: nobody starts on land they cannot see. - val fogged = fogHexes.map { topo.islandOf[it] }.toSet() + 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. @@ -334,6 +395,24 @@ object SeaBoards { List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 } } - return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress) + // 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 e1b6dd6..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 @@ -182,11 +182,55 @@ internal object SeaRules { } 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!! @@ -208,7 +252,51 @@ internal object SeaRules { (sea.raidPoints.getOrNull(seat) ?: 0) + (sea.wonders.getOrNull(seat) ?: 0) + clothOf(state, seat) / CLOTH_PER_POINT + - (sea.chipPoints.getOrNull(seat) ?: 0) + (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) } // ----------------------------------------------------------------------- @@ -328,6 +416,9 @@ internal object SeaRules { 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 } 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 6d9532a..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,6 +33,7 @@ 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 @@ -170,6 +171,8 @@ private fun DrawScope.drawHexes( 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" 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 31fc508..77aa31f 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 @@ -174,6 +174,7 @@ object CatanHudGlyphs { BuildKind.KNIGHT -> "♞" BuildKind.SHIP -> "⛵" BuildKind.RAID -> "⚔" + BuildKind.DIG -> "⛏" BuildKind.WONDER -> "✦" } @@ -185,6 +186,7 @@ object CatanHudGlyphs { BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP BuildKind.RAID -> CatanText.RAID + BuildKind.DIG -> CatanText.DIG BuildKind.WONDER -> CatanText.WONDER } } 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 f83f73d..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 @@ -7,6 +7,7 @@ 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 @@ -74,6 +75,9 @@ enum class BuildKind { /** 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; @@ -88,10 +92,11 @@ enum class BuildKind { 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, WONDER) + state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, DIG, WONDER) state.isSea -> SEAFARERS else -> BASE } @@ -278,6 +283,9 @@ object CatanInteraction { 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()) @@ -546,7 +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 }?.let { return TapOutcome.Submit(it) } + 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()) @@ -615,6 +623,7 @@ object CatanInteraction { 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 00a4812..9d01735 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 @@ -601,6 +601,7 @@ private fun buildLabel(kind: BuildKind): String = when (kind) { BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP BuildKind.RAID -> CatanText.RAID + BuildKind.DIG -> CatanText.DIG BuildKind.WONDER -> CatanText.WONDER } @@ -612,6 +613,7 @@ private fun buildCost(kind: BuildKind): String = when (kind) { 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 } @@ -646,6 +648,7 @@ private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List "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." @@ -459,6 +462,7 @@ object CatanText { 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/catan/CatanSeaExtrasTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt index f54e52f..23862d2 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt @@ -241,4 +241,96 @@ class CatanSeaExtrasTest { 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 index e2be5b1..04a1cc5 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -43,6 +43,9 @@ class CatanSeaScenariosTest { 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)) @@ -64,7 +67,8 @@ class CatanSeaScenariosTest { 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.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 } @@ -219,6 +223,8 @@ class CatanSeaScenariosTest { 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) @@ -238,6 +244,10 @@ class CatanSeaScenariosTest { 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 } @@ -249,6 +259,8 @@ class CatanSeaScenariosTest { 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/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-seafarers.md b/docs/RULES-catan-seafarers.md index 1c5ef98..2682130 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -36,6 +36,11 @@ Pick the map at setup; each scenario names where the starting settlements may go 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, @@ -139,6 +144,30 @@ board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain 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 @@ -161,8 +190,10 @@ Where the two meet, this game decides as follows: ## Not included The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**: -treasure islands, the oasis, the great canal, the Explorers & Pirates combination, +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 -- 2.47.3 From 42334c3bf82c2c5ac0b2a408d7af43a508c0be7e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 18:14:00 +0000 Subject: [PATCH 10/11] Katan: base-island scenarios (First Island, The Harbormaster) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../prolibertate/games/game/catan/CatanAi.kt | 5 + .../games/game/catan/CatanBoard.kt | 3 + .../prolibertate/games/game/catan/CatanCk.kt | 1 + .../games/game/catan/CatanModel.kt | 23 +++- .../games/game/catan/CatanRules.kt | 58 ++++++++- .../prolibertate/games/ui/GameSetupScreen.kt | 10 ++ .../games/ui/game/catan/CatanPanels.kt | 2 + .../games/game/catan/CatanBaseScenarioTest.kt | 115 ++++++++++++++++++ docs/RULES-catan.md | 20 +++ 9 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/game/catan/CatanBaseScenarioTest.kt 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 30f1869..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 @@ -599,6 +599,10 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi 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]) { @@ -709,5 +713,6 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi = 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" } @@ -214,6 +230,7 @@ private class CatanOptionsWire( 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. */ @@ -222,7 +239,7 @@ object CatanOptionsSerializer : KSerializer { override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue( CatanOptionsWire.serializer(), - CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario), + CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario, value.baseScenario), ) override fun deserialize(decoder: Decoder): CatanOptions { @@ -234,7 +251,7 @@ object CatanOptionsSerializer : KSerializer { 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) + return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario, wire.baseScenario) } } @@ -306,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, ) { 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 0e7ef6d..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 @@ -59,7 +59,9 @@ object CatanRules : GameRules { 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.citiesAndKnights @@ -532,7 +534,7 @@ 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, @@ -1023,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 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 ccca392..5c61086 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -61,6 +61,7 @@ import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.catan.CatanOptions 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 @@ -442,6 +443,15 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) - 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", 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 9d01735..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,7 @@ 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( @@ -281,6 +282,7 @@ 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. 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/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`). -- 2.47.3 From da772bd32662b7c674c9a6206bbfe85d4f5828e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 01:03:36 +0000 Subject: [PATCH 11/11] Katan: pinch-zoom and pan in portrait; max zoom 6x Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt | 2 +- .../java/org/prolibertate/games/ui/game/catan/CatanScreen.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 77aa31f..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 @@ -214,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/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 682d2ff..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 @@ -379,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() -- 2.47.3