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()))