Merge pull request 'Computer difficulty levels and hidden strategies' (#31) from feature/ai-difficulty into dev
CI / Build and check (push) Successful in 11m5s
CI / Deploy to Cloudflare Pages (push) Successful in 29s

This commit was merged in pull request #31.
This commit is contained in:
2026-11-07 23:26:08 -06:00
47 changed files with 4495 additions and 452 deletions
@@ -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<BackgammonState, BackgammonMove> {
class BackgammonAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: BackgammonLevel? = null,
) : GameAi<BackgammonState, BackgammonMove> {
/**
* 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<BackgammonMove> = 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>,
): 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<Pair<List<BackgammonMove>, 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<Pair<List<BackgammonMove>, Double>>,
profile: AiProfile,
state: BackgammonState,
legal: List<BackgammonMove>,
): 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
@@ -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<CatanState, CatanMove> {
class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<CatanState, CatanMove> {
/** 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>): 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<SetupPlace>())
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>(), profile)
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
// Gold: take what the seat wants most.
CatanPhase.GOLD -> legal.filterIsInstance<PickGold>().maxByOrNull { pick ->
pick.cards.sumOf { want(it) }
} ?: legal.first()
CatanPhase.GOLD -> chooseGold(state, seat, legal, profile)
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>(), profile)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>(), 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<CatanState, CatanMove> {
// Setup
// -----------------------------------------------------------------------
internal fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
internal fun chooseSetup(
state: CatanState,
seat: Int,
places: List<SetupPlace>,
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<Resource, Double>()
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<Resource, Double>()
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<CatanState, CatanMove> {
// Dice, discards and the robber
// -----------------------------------------------------------------------
private fun chooseDiscard(state: CatanState, seat: Int, discards: List<Discard>): Discard {
private fun chooseDiscard(
state: CatanState,
seat: Int,
discards: List<Discard>,
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>): 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<Resource, Int>): 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<CatanMove>, profile: AiProfile): CatanMove {
val picks = legal.filterIsInstance<PickGold>()
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<MoveRobber>,
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>): CatanMove {
// A city doubles the best production the seat already owns.
legal.filterIsInstance<BuildCity>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>, 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<BuildSettlement>().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<BuildCity>()
val settlements = legal.filterIsInstance<BuildSettlement>()
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<BuildShip>()
@@ -138,11 +285,15 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// A knight is worth playing whenever one is playable: it blocks an
// opponent's best hex and marches toward Largest Army.
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
if (!(easy && profile.slips())) {
legal.filterIsInstance<PlayKnight>().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<CatanState, CatanMove> {
return legal.filterIsInstance<PlayRoadBuilding>().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<BuildRoad>()
.maxByOrNull { roadValue(state, it) }
?.let { return it }
}
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
// Seafarers: a ship that heads for a settlement spot worth having.
@@ -162,7 +321,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// A paid road only when it reaches toward a spot worth settling.
legal.filterIsInstance<BuildRoad>()
.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<CatanState, CatanMove> {
.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<MaritimeTrade>()
.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<MaritimeTrade>()
.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 <T> pickBest(items: List<T>, 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<Resource, Int>): Map<Resource, Int> =
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<CatanMove>): PlayYearOfPlenty? {
private fun yearOfPlenty(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): PlayYearOfPlenty? {
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
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<CatanState, CatanMove> {
* 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<CatanState, CatanMove> {
.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<CatanState, CatanMove> {
// Free roads and trade responses
// -----------------------------------------------------------------------
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.05 }
.maxByOrNull { roadValue(state, it) }
return roads ?: legal.firstOrNull { it is EndFreeRoads }
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
?: legal.first()
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>().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>): CatanMove {
private fun freeRoadFallback(legal: List<CatanMove>): CatanMove =
legal.firstOrNull { it is EndFreeRoads }
?: legal.filterIsInstance<BuildRoad>().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<CatanMove>, profile: AiProfile): CatanMove {
val accept = legal.filterIsInstance<AcceptTrade>().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>): CatanMove {
private fun chooseCounter(state: CatanState, seat: Int, legal: List<CatanMove>, 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<AcceptCounter>()
.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<CatanState, CatanMove> {
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
}
}
@@ -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>): CatanMove = when (state.phase) {
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val profile = profiles.of(seat)
return choose(state, seat, legal, profile)
}
private fun choose(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove = when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>(), 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<MoveRobber>())
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>(), 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>): CatanMove {
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>, 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>): MoveRobber {
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>, 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>): CatanMove {
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>, 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<BuildCity>().maxByOrNull { production(state, it.vertex) }
?.let { return it }
val cities = legal.filterIsInstance<BuildCity>()
val settlements = legal.filterIsInstance<BuildSettlement>()
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<BuildSettlement>().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<BuyImprovement>()
@@ -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<BuildWall>().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<BuildRoad>()
.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<GoodsBankTrade>()
.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<CatanMove>, soon: Boolean): CatanMove? {
private fun defence(
state: CatanState,
seat: Int,
legal: List<CatanMove>,
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<ActivateKnight>().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<HireKnight>().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>): CatanMove =
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.05 }
.maxByOrNull { ai.roadValue(state, it) }
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>().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>): 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<CatanMove>, 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) {
@@ -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<CheckersState, CheckersMove> {
class CheckersAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: CheckersLevel? = null,
) : GameAi<CheckersState, CheckersMove> {
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>,
): 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<Pair<CheckersMove, Int>>()
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
@@ -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<ChessState, ChessMove> {
class ChessAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<ChessState, ChessMove> {
/** 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<ChessState, ChessM
override fun chooseMove(state: ChessState, seat: Int, legal: List<ChessMove>): 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<ChessState, ChessM
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<Pair<ChessMove, Int>>()
// 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<ChessState, ChessM
// makes them beatable without making them play nonsense.
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 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<ChessState, ChessM
*/
private fun quiescence(state: ChessState, alpha: Int, beta: Int, ply: Int): Int {
nodesUsed++
val standPat = evaluate(state)
val standPat = evaluate(state) + if (styleActive) styleTerm(state) else 0
if (standPat >= 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<ChessState, ChessM
const val INFINITY = 1_000_000
const val MATE = 100_000
const val NODE_BUDGET = 120_000
const val HARD_NODE_BUDGET = 240_000
const val EASY_EXTRA_SLACK = 60
const val MAX_QUIESCENCE_PLY = 24
/**
@@ -1,52 +1,135 @@
package org.prolibertate.games.game.crazyeights
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 Crazy 8s opponent.
* A Crazy 8s opponent whose strength and temperament come from the seat's profile.
*
* It sheds expensive cards first, keeps its eights back for when it is stuck,
* and when it does play one it names the suit it holds most of.
* MEDIUM (the default) sheds expensive cards first, keeps its eights back for
* when it is stuck, and when it does play one it names the suit it holds most of.
*
* EASY plays the same way but sometimes sheds a cheaper card or wastes an eight.
* HARD counts the cards on the discard pile to know what is still out, plays to
* keep a long suit it can run, and when someone is close to going out it turns
* the game to a suit that is nearly exhausted so they cannot follow.
*/
class CrazyEightsAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class CrazyEightsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<CrazyEightsState, CrazyEightsMove> {
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>,
): CrazyEightsMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val plays = legal.filterIsInstance<PlayCard>()
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<PlayCard>,
eightEndgame: Int,
): List<PlayCard> {
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<PlayCard>,
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<Card>().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<Card>): 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<Card> = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } }
}
}
@@ -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<CribbageState, CribbageMove> {
class CribbageAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<CribbageState, CribbageMove> {
override fun chooseMove(
state: CribbageState,
@@ -27,30 +37,78 @@ class CribbageAi : GameAi<CribbageState, CribbageMove> {
legal: List<CribbageMove>,
): CribbageMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val layAways = legal.filterIsInstance<LayAway>()
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways)
return choosePeg(state, legal.filterIsInstance<PegCard>())
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways, profile)
val pegs = legal.filterIsInstance<PegCard>()
if (pegs.isNotEmpty()) return choosePeg(state, seat, pegs, profile)
return legal.first()
}
private fun chooseLayAway(
state: CribbageState,
seat: Int,
choices: List<LayAway>,
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<Card>,
unseen: List<Card>,
options: CribbageOptions,
): List<List<Card>> {
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<Card>()
while (picked.size < others + 1) picked += unseen[random.nextInt(unseen.size)]
picked.toList()
}
}
private fun sampledCribValue(given: List<Card>, trials: List<List<Card>>): 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<CribbageState, CribbageMove> {
return value
}
private fun choosePeg(state: CribbageState, choices: List<PegCard>): PegCard {
private fun choosePeg(
state: CribbageState,
seat: Int,
choices: List<PegCard>,
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<Card>, after: Int, unseen: List<Card>): 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
}
}
@@ -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 <T> pick(ranked: List<T>): 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<Int, AiProfile>()
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)
}
}
@@ -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,
)
/**
@@ -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<EuchreState, EuchreMove> {
/**
@@ -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<Int, SeenCards>()
/** 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<Card>()
}
/** Public information only: cards that have appeared on the table this hand. */
private fun observe(state: EuchreState, seat: Int): Set<Card> = 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>): 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<Card>, 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<Card>, 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<EuchreMove>,
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<EuchreMove>,
profile: AiProfile,
): EuchreMove {
val hand = state.hands[seat]
val callable = legal.filterIsInstance<CallTrump>()
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>): EuchreMove {
private fun chooseDiscard(
state: EuchreState,
legal: List<EuchreMove>,
profile: AiProfile,
): EuchreMove {
val trump = requireNotNull(state.trump)
val discards = legal.filterIsInstance<Discard>()
// 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<Discard> { 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<EuchreMove>,
profile: AiProfile,
seen: Set<Card>,
): EuchreMove {
val trump = requireNotNull(state.trump)
val cards = legal.filterIsInstance<PlayCard>().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<Card>,
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<Card>, 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<Card>,
trump: Suit,
seen: Set<Card>,
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<Card>({ 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<Card>, 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 }
@@ -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<GolfState, GolfMove> {
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>): 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>): GolfMove {
private fun chooseReveal(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
val reveals = legal.filterIsInstance<RevealCard>()
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>): GolfMove {
// -----------------------------------------------------------------------
// Drawing
// -----------------------------------------------------------------------
private fun chooseDraw(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
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>): GolfMove {
// -----------------------------------------------------------------------
// Placing
// -----------------------------------------------------------------------
private fun choosePlace(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
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<GolfMove> {
addAll(legal.filterIsInstance<DiscardAndFlip>())
addAll(legal.filterIsInstance<ReplaceCard>().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<GolfMove>,
params: Params,
): GolfMove {
val drawn = state.drawn ?: return legal.first()
val replacements = legal.filterIsInstance<ReplaceCard>()
val flips = legal.filterIsInstance<DiscardAndFlip>()
@@ -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<Rank, Int>()
private val unseenTotal: Int
val unknownValue: Double
private val lines: List<List<Int>>
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<Card?> =
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<Card?>): 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<Pair<Rank, Int>> = 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<Card?>, 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<GolfMove> = 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<GolfMove>,
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<GolfMove>,
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)
}
}
@@ -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<HeartsState, HeartsMove> {
class HeartsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<HeartsState, HeartsMove> {
override fun chooseMove(state: HeartsState, seat: Int, legal: List<HeartsMove>): 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>): HeartsMove {
// -----------------------------------------------------------------------
// Passing
// -----------------------------------------------------------------------
private fun choosePass(
state: HeartsState,
seat: Int,
legal: List<HeartsMove>,
profile: AiProfile,
): HeartsMove {
val hand = state.hands[seat]
val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
val wanted: Set<Card> = 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<Card>, profile: AiProfile): Set<Card> {
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<Card>, profile: AiProfile): Set<Card> {
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<Card>): 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<Card>, 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<HeartsState, HeartsMove> {
}
}
private fun choosePlay(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
// -----------------------------------------------------------------------
// Playing
// -----------------------------------------------------------------------
private fun choosePlay(
state: HeartsState,
seat: Int,
legal: List<HeartsMove>,
profile: AiProfile,
): HeartsMove {
val cards = legal.filterIsInstance<PlayCard>().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<Card>): List<Card> =
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>): 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<Card>): List<Card> {
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>): Card {
private fun rankedFollow(state: HeartsState, cards: List<Card>): List<Card> {
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<HeartsState, HeartsMove> {
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>): 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<Card> = state.hands[seat]
val unseen: List<Card>
init {
val seen = HashSet<Card>()
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<Card>, 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<Card>, mem: Memory): Card {
if (state.trick.isEmpty()) {
val masters = cards.filter { mem.unseenHigher(it) == 0 }
if (masters.isNotEmpty()) {
return masters.sortedWith(
compareBy<Card> { 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<Card>, 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<Card>,
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<Card>,
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<Card> = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } }
}
}
@@ -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<KaiserState, KaiserMove> {
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<Int, SeenCards>()
private class SeenCards {
var key: Long = Long.MIN_VALUE
val cards = HashSet<Card>()
}
/** Public information only: cards that have appeared on the table this hand. */
private fun observe(state: KaiserState, seat: Int): Set<Card> = 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>): 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>): 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<Card>, 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<KaiserMove>,
profile: AiProfile,
): KaiserMove {
val hand = state.hands[seat]
val hard = profile.level == AiLevel.HARD
val easy = profile.level == AiLevel.EASY
val naming = legal.filterIsInstance<NameTrump>()
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<MakeBid>().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>): KaiserMove {
private fun choosePlay(
state: KaiserState,
seat: Int,
legal: List<KaiserMove>,
profile: AiProfile,
seen: Set<Card>,
): KaiserMove {
val cards = legal.filterIsInstance<PlayCard>().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<Card>,
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<Card>,
seen: Set<Card>,
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<Card>({ -(counts[it.suit] ?: 0) }, { it.rank.order }))
} else {
offSuit.minWith(compareBy<Card>({ 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()
}
}
@@ -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<MastermindState, MastermindMove> {
class MastermindAi(
@Suppress("unused") private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: MastermindLevel? = null,
) : GameAi<MastermindState, MastermindMove> {
constructor(level: MastermindLevel?) : this(AiProfiles.DEFAULT, level)
override fun chooseMove(
state: MastermindState,
@@ -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<MorrisState, MorrisMove> {
class MorrisAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: MorrisLevel? = null,
) : GameAi<MorrisState, MorrisMove> {
// 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<MorrisState, Mor
override fun chooseMove(state: MorrisState, seat: Int, legal: List<MorrisMove>): 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<MorrisState, Mor
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<Pair<MorrisMove, Int>>()
val ordered = order(legal, preferred = best)
for (move in ordered) {
@@ -73,7 +108,16 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
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 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<MorrisState, Mor
/** The position from the point of view of the side to move. */
private fun evaluate(state: MorrisState): Int {
val us = state.turn
val them = other(us)
val me = rootSeat
val them = other(me)
var score = PIECE * (state.remaining(us) - state.remaining(them))
score += MILL * (state.millCount(us) - state.millCount(them))
score += ALMOST * (almostMills(state, us) - almostMills(state, them))
score += MOBILITY * (mobility(state, us) - mobility(state, them))
score += LADDER * (midEdgeCount(state, us) - midEdgeCount(state, them))
score -= DANGER * (onThree(state, us) - onThree(state, them))
return score
var score = PIECE * (state.remaining(me) - state.remaining(them))
score += wMill * (state.millCount(me) - state.millCount(them))
score += wAlmost * almostMills(state, me) - wTheirAlmost * almostMills(state, them)
score += wMobility * mobility(state, me) - wTheirMobility * mobility(state, them)
score += LADDER * (midEdgeCount(state, me) - midEdgeCount(state, them))
score -= wDanger * (onThree(state, me) - onThree(state, them))
// Scored from the root seat's side; flip for the side to move.
return if (state.turn == me) score else -score
}
/**
@@ -215,6 +260,8 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
const val INFINITY = 1_000_000
const val WIN = 100_000
const val NODE_BUDGET = 120_000
const val HARD_NODE_BUDGET = 280_000
const val EASY_TOLERANCE = 120
const val PIECE = 120
const val MILL = 26
@@ -1,6 +1,10 @@
package org.prolibertate.games.game.pirates
import kotlin.math.roundToInt
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
/**
@@ -14,17 +18,73 @@ import org.prolibertate.games.game.engine.GameAi
* from the Bulgars' side and negated for the pirates, so the two cannot drift
* apart and start valuing the same position differently.
*/
class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState, PiratesMove> {
class PiratesAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: PiratesLevel? = null,
) : GameAi<PiratesState, PiratesMove> {
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>): 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<PiratesState,
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<Pair<PiratesMove, Int>>()
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<PiratesState,
best = results.maxBy { it.second }.first
}
if (easy) {
val ranked = scored.sortedByDescending { it.second }.map { it.first }
val picked = profile.pick(ranked)
if (picked != ranked.first() && picked in legal) return picked
}
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= 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<PiratesState,
private fun evaluate(state: PiratesState): Int {
var bulgarView = 0
bulgarView += state.count(BULGAR_SEAT) * BULGAR
bulgarView += state.stronghold() * IN_STRONGHOLD
bulgarView += state.count(BULGAR_SEAT) * wBulgar
bulgarView += state.stronghold() * wStronghold
// How far up the board the crowd has come. The stronghold is at the
// top, so a low row number is ground gained.
for (point in 0 until POINTS) {
if (state.board[point] != BULGAR_SEAT) continue
bulgarView += (GRID - 1 - rowOf(point)) * ADVANCE
bulgarView += (GRID - 1 - rowOf(point)) * wAdvance
// A Bulgar with an empty point directly behind it on a line is one
// a pirate can jump. Being unjumpable is most of staying alive.
if (isExposed(state, point)) bulgarView -= EXPOSED
if (isExposed(state, point)) bulgarView -= wExposed
}
// Room for the pirates to work in, which is what the Bulgars are
// slowly taking away.
bulgarView -= pirateMobility(state) * PIRATE_ROOM
bulgarView -= pirateMobility(state) * wRoom
return if (state.turn == BULGAR_SEAT) bulgarView else -bulgarView
}
@@ -160,6 +232,7 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
const val INFINITY = 1_000_000
const val WIN = 100_000
const val NODE_BUDGET = 150_000
const val HARD_NODE_BUDGET = 400_000
const val BULGAR = 100
const val IN_STRONGHOLD = 55
@@ -1,63 +1,85 @@
package org.prolibertate.games.game.president
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 President opponent.
* A President opponent whose strength and temperament come from the seat's profile.
*
* It sheds low cards early, spends the cheapest set that takes the pile, and
* holds twos back for when they are worth something. It does not count cards.
* MEDIUM (the default) sheds low cards early, spends the cheapest set that takes
* the pile, and holds twos back for when they are worth something. It does not
* count cards.
*
* EASY spends high cards freely when leading and sometimes plays a worse set or
* passes when it should have played. HARD reads the round's log to count which
* ranks are still out, leads so that its remaining hand is unbeatable, blocks an
* opponent who is about to go out, and keeps its high cards back when a cheap
* pile is not worth them.
*/
class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class PresidentAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<PresidentState, PresidentMove> {
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>,
): PresidentMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val plays = legal.filterIsInstance<PlayCards>()
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<PlayCards>,
legal: List<PresidentMove>,
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<PlayCards>,
): PresidentMove {
hoardAbove: Int,
): List<PlayCards> {
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<PlayCards>(
{ 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<PlayCards>,
legal: List<PresidentMove>,
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<PresidentMove> {
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<Int, Int>()
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<PlayCards>,
legal: List<PresidentMove>,
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<Int>,
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<PlayCards>,
counts: Counts,
others: List<Int>,
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<PlayCards>,
legal: List<PresidentMove>,
counts: Counts,
others: List<Int>,
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<String, Card> = Suit.entries.flatMap { suit ->
Rank.standard.map { Card(it, suit) }
}.associateBy { it.label }
}
}
@@ -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<SequenceState, SequenceMove> {
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>,
): 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<ExchangeDeadCard>().firstOrNull()
if (exchange != null && legal.none { it is PlaceChip }) return exchange
val best = legal
val placements = legal
.filterIsInstance<PlaceChip>()
.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<RemoveChip>()
@@ -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)
@@ -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<TayuState, TayuMove> {
class TayuAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: TayuLevel? = null,
) : GameAi<TayuState, TayuMove> {
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>): 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<TayuState, TayuMove>
// 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<TayuState, TayuMove>
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
}
}
@@ -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<WizardState, WizardMove> {
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<Card> = Decks.wizard()
}
/** Cards this AI has watched played in the current round, per seat it plays. */
private val memory = HashMap<Int, SeenCards>()
private class SeenCards {
var key: Long = Long.MIN_VALUE
val cards = HashSet<Card>()
}
/** Public information only: cards that have appeared on the table this round. */
private fun observe(state: WizardState, seat: Int): Set<Card> = 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>): 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>): WizardMove {
private fun chooseBid(
state: WizardState,
seat: Int,
legal: List<WizardMove>,
profile: AiProfile,
): WizardMove {
val hand = state.hands[seat]
val hard = profile.level == AiLevel.HARD
val easy = profile.level == AiLevel.EASY
val naming = legal.filterIsInstance<ChooseTrump>()
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<MakeBid>()
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<Card>, 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<Card>, 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>): WizardMove {
private fun choosePlay(
state: WizardState,
seat: Int,
legal: List<WizardMove>,
profile: AiProfile,
seen: Set<Card>,
): WizardMove {
val cards = legal.filterIsInstance<PlayCard>().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<Card>,
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<Card>,
seen: Set<Card>,
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<Card>({ 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<Card>, trump: Suit?, wantsTrick: Boolean): Card {
if (wantsTrick) {
// A led wizard is an unloseable trick.
@@ -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<YahtzeeState, YahtzeeMove> {
class YahtzeeAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<YahtzeeState, YahtzeeMove> {
override fun chooseMove(state: YahtzeeState, seat: Int, legal: List<YahtzeeMove>): YahtzeeMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val rolls = legal.filterIsInstance<RollDice>()
val writes = legal.filterIsInstance<ScoreIn>()
// 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<Int>): Set<Int> {
val counts = countsOf(dice)
val second = counts.entries
.sortedWith(compareByDescending<Map.Entry<Int, Int>> { 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<YahtzeeState, YahtzeeMove> {
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<RollDice>,
writes: List<ScoreIn>,
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<IntArray, Double> {
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<Int>): 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<Int>): 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<YahtzeeCategory>): 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<IntArray> = vectors(DICE_COUNT)
val rerolls: Array<List<Outcome>> = 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<List<IntArray>?> = 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<Int> {
val faces = ArrayList<Int>(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<IntArray> {
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<IntArray> {
val out = ArrayList<IntArray>()
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,
)
}
}
@@ -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<PlayerSlot> = emptyList(),
val discovered: List<DiscoveredHost> = 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)
@@ -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 ->
@@ -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,
)
/**
@@ -60,6 +60,7 @@ fun LobbyScreen(
hostName = playerName,
seatCount = seatCount,
teamOf = { seat -> teamForSeat(route.gameId, seat, seatCount) },
aiDifficulty = route.aiDifficulty,
)
} else {
env.lobby.startDiscovery()
@@ -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<EuchreState, EuchreMove>(
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<SequenceState, SequenceMove>(
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<PresidentState, PresidentMove>(
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<GolfState, GolfMove>(
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<KaiserState, KaiserMove>(
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<WizardState, WizardMove>(
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<CrazyEightsState, CrazyEightsMove>(
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<CribbageState, CribbageMove>(
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<CheckersState, CheckersMove>(
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<BackgammonState, BackgammonMove>(
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<MastermindState, MastermindMove>(
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<PiratesState, PiratesMove>(
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<HeartsState, HeartsMove>(
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<YahtzeeState, YahtzeeMove>(
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<CatanState, CatanMove>(
rules = CatanRules,
ai = CatanAi(),
ai = CatanAi(AiProfiles(route.config)),
config = route.config,
scope = scope,
role = role,
@@ -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<BackgammonMove> {
var state = BackgammonRules.initialState(BackgammonOptions(), seed)
val chosen = mutableListOf<BackgammonMove>()
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)
}
}
@@ -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)
}
}
}
@@ -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<Pair<Any, Any>>()
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)
}
}
@@ -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)
}
}
@@ -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<CrazyEightsMove> {
var state = CrazyEightsRules.initialState(config(options, seed))
val moves = mutableListOf<CrazyEightsMove>()
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)
}
}
}
@@ -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<Long> {
// 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<Long>()
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<CribbageMove> {
var state = CribbageRules.initialState(config(options, seed))
val moves = mutableListOf<CribbageMove>()
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)
}
}
@@ -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)
}
}
@@ -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<EuchreMove> {
val moves = mutableListOf<EuchreMove>()
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)
}
}
}
@@ -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<GolfMove> {
var state = GolfRules.initialState(config(options, seed))
val moves = mutableListOf<GolfMove>()
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)
}
}
}
@@ -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<HeartsMove> {
var state = HeartsRules.initialState(config(seed))
val moves = mutableListOf<HeartsMove>()
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)
}
}
}
@@ -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<KaiserMove> {
val moves = mutableListOf<KaiserMove>()
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)
}
}
}
@@ -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<Pair<Any, Any>>()
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)
}
}
@@ -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`() {
@@ -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<PiratesMove> {
var state = PiratesRules.initialState(PiratesOptions(level = PiratesLevel.CASUAL))
val chosen = mutableListOf<PiratesMove>()
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)
}
}
@@ -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<PresidentMove> {
var state = PresidentRules.initialState(config(options, seed))
val moves = mutableListOf<PresidentMove>()
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)
}
}
}
@@ -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<Long> {
// 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<Long>()
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<SequenceMove> {
var state = SequenceRules.initialState(config(options, seed))
val moves = mutableListOf<SequenceMove>()
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)
}
}
@@ -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<Long> {
// 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<Long>()
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<TayuMove> {
var state = TayuRules.initialState(config(seed))
val moves = mutableListOf<TayuMove>()
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)
}
}
@@ -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<WizardMove> {
val moves = mutableListOf<WizardMove>()
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)
}
}
}
@@ -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<Long> {
// 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<Long>()
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)
}
}
@@ -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 })
@@ -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()))