Release: Katan scenarios, expansions and portrait zoom #42
@@ -51,14 +51,14 @@ integration:
|
||||
|
||||
## Katan
|
||||
|
||||
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
|
||||
sets chosen at setup: the base game (`docs/RULES-catan.md`), Cities & Knights
|
||||
The island game is catalogue id `catan`, shown as **Katan**. It has two expansions
|
||||
(toggles, `CatanOptions.expansions`; none on is the base game): the base game (`docs/RULES-catan.md`), Cities & Knights
|
||||
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
|
||||
`CatanState.ck`, which is null in the base game) and Seafarers
|
||||
(`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind
|
||||
`CatanState.sea`). The base and Seafarers rules read the board from `state.topo`, not from
|
||||
`CatanState.sea`); the two can be switched on together. The base and Seafarers rules read the board from `state.topo`, not from
|
||||
`CatanBoard` directly, because a Seafarers map is not the 19-hex island
|
||||
(Cities & Knights is always the standard island and still uses `CatanBoard`). The
|
||||
(Cities & Knights also reads `state.topo`, so it runs on a Seafarers map too). The
|
||||
Catan Universe reverse engineering that shaped the landscape HUD and trade board
|
||||
is in `docs/RE-catan-universe.md`. The screen is layered; see
|
||||
`docs/UI-catan.md` before changing it.
|
||||
|
||||
@@ -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,132 @@ 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
|
||||
}
|
||||
|
||||
internal 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 }
|
||||
}
|
||||
|
||||
// A raid on a fortress, or the next stage of a wonder, beats a card.
|
||||
legal.filterIsInstance<RaidFortress>().firstOrNull()?.let { return it }
|
||||
legal.filterIsInstance<DigCanal>().firstOrNull()?.let { return it }
|
||||
legal.filterIsInstance<BuildWonder>().firstOrNull()?.let { return it }
|
||||
|
||||
// Seafarers: a ship toward an island nobody has landed on is worth more than a card.
|
||||
legal.filterIsInstance<BuildShip>()
|
||||
@@ -138,11 +290,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 +308,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 +326,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 +337,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 +476,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 +489,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 +521,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 }
|
||||
}
|
||||
@@ -315,6 +597,26 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What a seat would think of building on [vertex]: production and ports. */
|
||||
/** What a spot beside a cloth village or an unclaimed tribe chip is worth beyond its cards. */
|
||||
private fun scenarioLure(state: CatanState, vertex: Int): Double {
|
||||
// The Harbormaster: a town on a harbour is a point toward a title worth two.
|
||||
if (CatanRules.harbormasterGame(state) &&
|
||||
state.topo.VERTEX_EDGES[vertex].any { e -> state.ports.any { it.edge == e } }
|
||||
) return HARBOR_LURE
|
||||
val sea = state.sea ?: return 0.0
|
||||
var lure = 0.0
|
||||
for (h in state.topo.VERTEX_HEXES[vertex]) {
|
||||
// A cloth token is a quarter-point, rolled as often as its number comes up.
|
||||
if (state.terrains[h] == Terrain.VILLAGE && sea.villageCloth.getOrElse(h) { 0 } > 0) {
|
||||
lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH
|
||||
}
|
||||
// An unfinished canal tile is a place to dig: points now, grain land later.
|
||||
if (sea.canalNumbers.getOrElse(h) { 0 } != 0 && state.terrains[h] == Terrain.DESERT) lure += CANAL_LURE
|
||||
if (sea.chips.getOrNull(h).let { it != null && it != TribeChip.NONE }) lure += CHIP_LURE
|
||||
}
|
||||
return lure
|
||||
}
|
||||
|
||||
internal fun spotValue(state: CatanState, vertex: Int): Double {
|
||||
if (state.buildings[vertex] != null) return -1.0
|
||||
var value = 0.0
|
||||
@@ -325,6 +627,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
kinds += resource
|
||||
}
|
||||
value += 0.12 * kinds.size // variety keeps a build plan alive
|
||||
value += scenarioLure(state, vertex)
|
||||
for (e in state.topo.VERTEX_EDGES[vertex]) {
|
||||
val port = state.ports.firstOrNull { it.edge == e } ?: continue
|
||||
value += if (port.resource == null) 0.1 else 0.2
|
||||
@@ -367,9 +670,16 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
||||
val next = mutableListOf<Int>()
|
||||
for (v in frontier) {
|
||||
if (!seen.add(v)) continue
|
||||
// Fog is worth a look: a card on arrival, and land to settle that nobody has seen.
|
||||
if (topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) best = maxOf(best, FOG_LURE * Math.pow(0.85, depth.toDouble()))
|
||||
// A fortress is worth a raid once a ship lies beside it.
|
||||
if (sea.fortress.isNotEmpty() && topo.VERTEX_EDGES[v].any { e ->
|
||||
topo.edgeSea[e] && topo.EDGE_HEXES[e].any { sea.fortress.getOrElse(it) { 0 } > 0 }
|
||||
}
|
||||
) best = maxOf(best, FORT_LURE * Math.pow(0.85, depth.toDouble()))
|
||||
if (CatanRules.vertexOpenForSettlement(state, v)) {
|
||||
val island = topo.vertexIsland[v]
|
||||
val fresh = island >= 0 && island != sea.home && sea.islandBonus[island] == NO_PLAYER
|
||||
val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER
|
||||
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
|
||||
best = maxOf(best, value)
|
||||
}
|
||||
@@ -392,4 +702,17 @@ class CatanAi : GameAi<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
|
||||
|
||||
/** What a ship end beside undiscovered land is worth, on the scale of [spotValue]. */
|
||||
const val FOG_LURE = 0.6
|
||||
const val FORT_LURE = 1.2
|
||||
const val CLOTH_WORTH = 2.5
|
||||
const val CHIP_LURE = 0.5
|
||||
const val CANAL_LURE = 0.6
|
||||
const val HARBOR_LURE = 0.5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,9 @@ object CatanBoard {
|
||||
* tokens go to four of the six board-corner hexes, which are pairwise
|
||||
* non-adjacent, and at most one corner can be desert.
|
||||
*/
|
||||
/** The seed of the First Island scenario's fixed layout. */
|
||||
const val FIRST_ISLAND_SEED = 1L
|
||||
|
||||
fun generate(seed: Long): Generated {
|
||||
val random = Random(seed)
|
||||
val terrains = TERRAIN_TILES.shuffled(random)
|
||||
|
||||
@@ -171,18 +171,22 @@ object CatanCk {
|
||||
|
||||
/** Cities of [seat] that can carry a metropolis or fall to the barbarians' rival: all but metropolises. */
|
||||
private fun plainCities(state: CatanState, seat: Int): List<Int> =
|
||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||
(0 until state.topo.VERTEX_COUNT).filter { v ->
|
||||
state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && !hasMetropolis(state, v)
|
||||
}
|
||||
|
||||
private fun ownsCity(state: CatanState, seat: Int): Boolean =
|
||||
state.buildings.any { it == CatanBuilding(BuildingType.CITY, seat) }
|
||||
|
||||
/** Empty crossings at the end of one of [seat]'s roads: where a knight can be put. */
|
||||
/** Whether [seat] has a road, or a ship in Seafarers, on [edge]: the network a knight moves along. */
|
||||
private fun holds(state: CatanState, seat: Int, edge: Int): Boolean =
|
||||
state.roads[edge] == seat || state.sea?.ships?.get(edge) == seat
|
||||
|
||||
/** Empty crossings at the end of one of [seat]'s roads or ships on land: where a knight can be put. */
|
||||
private fun knightSpots(state: CatanState, seat: Int): List<Int> =
|
||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||
state.buildings[v] == null && knightAt(state, v) == null &&
|
||||
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
|
||||
(0 until state.topo.VERTEX_COUNT).filter { v ->
|
||||
state.topo.vertexLand[v] && state.buildings[v] == null && knightAt(state, v) == null &&
|
||||
state.topo.VERTEX_EDGES[v].any { holds(state, seat, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,9 +200,9 @@ object CatanCk {
|
||||
val queue = ArrayDeque<Int>().apply { add(from) }
|
||||
while (queue.isNotEmpty()) {
|
||||
val v = queue.removeFirst()
|
||||
for (e in CatanBoard.VERTEX_EDGES[v]) {
|
||||
if (state.roads[e] != seat) continue
|
||||
val w = CatanBoard.otherEnd(e, v)
|
||||
for (e in state.topo.VERTEX_EDGES[v]) {
|
||||
if (!holds(state, seat, e)) continue
|
||||
val w = state.topo.otherEnd(e, v)
|
||||
if (!seen.add(w)) continue
|
||||
val building = state.buildings[w]
|
||||
val knight = knightAt(state, w)
|
||||
@@ -215,14 +219,14 @@ object CatanCk {
|
||||
private fun destinations(state: CatanState, k: Knight): List<Int> =
|
||||
walk(state, k.owner, k.vertex).filter { w ->
|
||||
val there = knightAt(state, w)
|
||||
if (state.buildings[w] != null) return@filter false
|
||||
if (state.buildings[w] != null || !state.topo.vertexLand[w]) return@filter false
|
||||
there == null || (there.owner != k.owner && there.level < k.level)
|
||||
}.sorted()
|
||||
|
||||
/** Empty crossings a driven-off [knight] of its owner could still reach. */
|
||||
private fun refuges(state: CatanState, knight: Knight): List<Int> =
|
||||
walk(state, knight.owner, knight.vertex).filter { w ->
|
||||
state.buildings[w] == null && knightAt(state, w) == null
|
||||
state.buildings[w] == null && state.topo.vertexLand[w] && knightAt(state, w) == null
|
||||
}.sorted()
|
||||
|
||||
private fun promotable(state: CatanState, k: Knight): Boolean {
|
||||
@@ -251,6 +255,8 @@ object CatanCk {
|
||||
points[seat] += ck.defenderVp[seat] + ck.progressVp[seat]
|
||||
}
|
||||
ck.merchant?.let { points[it.seat] += 1 }
|
||||
state.harbormaster?.let { points[it] += CatanRules.HARBORMASTER_POINTS }
|
||||
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
|
||||
return points.toList()
|
||||
}
|
||||
|
||||
@@ -266,15 +272,18 @@ object CatanCk {
|
||||
addAll(actionMoves(state, seat))
|
||||
}
|
||||
|
||||
CatanPhase.ROBBER -> robberMoves(state, seat)
|
||||
// The bishop moves the robber alone; a seven may send the pirate instead.
|
||||
CatanPhase.ROBBER -> robberMoves(state, seat) +
|
||||
(if (state.isSea && !state.ck!!.bishop) SeaRules.pirateMoves(state, seat) else emptyList())
|
||||
|
||||
CatanPhase.PLAYING -> playingMoves(state, seat)
|
||||
|
||||
CatanPhase.FREE_ROADS ->
|
||||
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
|
||||
CatanRules.roadMoves(state, seat) +
|
||||
(if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) +
|
||||
listOf<CatanMove>(EndFreeRoads)
|
||||
|
||||
// Seafarers is never combined with Cities & Knights.
|
||||
CatanPhase.GOLD -> emptyList()
|
||||
CatanPhase.GOLD -> SeaRules.goldMoves(state)
|
||||
|
||||
CatanPhase.TRADE_PENDING -> {
|
||||
val offer = state.ck!!.goodsOffer
|
||||
@@ -327,7 +336,7 @@ object CatanCk {
|
||||
if (!k.active && affordable(state, seat, ACTIVATE_COST)) moves += ActivateKnight(k.vertex)
|
||||
if (canAct(state, k)) {
|
||||
destinations(state, k).forEach { moves += MoveKnight(k.vertex, it) }
|
||||
if (ck.attacked && k.vertex in CatanBoard.HEX_CORNERS[state.robber]) {
|
||||
if (ck.attacked && k.vertex in state.topo.HEX_CORNERS[state.robber]) {
|
||||
moves += ChaseRobber(k.vertex)
|
||||
}
|
||||
}
|
||||
@@ -340,6 +349,11 @@ object CatanCk {
|
||||
val moves = mutableListOf<CatanMove>()
|
||||
|
||||
if (affordable(state, seat, CatanRules.ROAD_COST)) moves += CatanRules.roadMoves(state, seat)
|
||||
if (state.isSea) {
|
||||
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
|
||||
moves += SeaRules.moveShipMoves(state, seat)
|
||||
moves += SeaRules.scenarioMoves(state, seat)
|
||||
}
|
||||
if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) {
|
||||
moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) }
|
||||
}
|
||||
@@ -380,7 +394,7 @@ object CatanCk {
|
||||
/** Own cities with no wall, while the seat has a wall left to build. */
|
||||
private fun wallSites(state: CatanState, seat: Int): List<Int> {
|
||||
if (wallsOf(state, seat) >= MAX_WALLS) return emptyList()
|
||||
return (0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||
return (0 until state.topo.VERTEX_COUNT).filter { v ->
|
||||
state.buildings[v] == CatanBuilding(BuildingType.CITY, seat) && v !in state.ck!!.walls
|
||||
}
|
||||
}
|
||||
@@ -423,20 +437,37 @@ object CatanCk {
|
||||
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
|
||||
val bishop = state.ck!!.bishop
|
||||
val moves = mutableListOf<MoveRobber>()
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
if (h == state.robber) continue
|
||||
for (h in 0 until state.topo.HEX_COUNT) {
|
||||
if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG) continue
|
||||
val victims = victimsAt(state, seat, h)
|
||||
when {
|
||||
bishop -> moves += MoveRobber(CatanBoard.HEXES[h])
|
||||
victims.isEmpty() -> moves += MoveRobber(CatanBoard.HEXES[h])
|
||||
else -> victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
|
||||
bishop -> moves += MoveRobber(state.topo.HEXES[h])
|
||||
victims.isEmpty() -> moves += MoveRobber(state.topo.HEXES[h])
|
||||
else -> victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
/** The pirate, sent by a seven once the barbarians have landed: it robs a seat with a ship beside it. */
|
||||
fun applyPirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
|
||||
val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}")
|
||||
var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1)
|
||||
var stole = ""
|
||||
val hand = move.victim?.let { goodsList(s, it) }.orEmpty()
|
||||
if (move.victim != null && hand.isNotEmpty()) {
|
||||
val card = hand[Random(state.seed * 1_000_003L + state.rngStep).nextInt(hand.size)]
|
||||
s = give(s, move.victim, seat, listOf(card))
|
||||
stole = " and takes a ${good(card)} from seat ${move.victim}"
|
||||
}
|
||||
return s.copy(
|
||||
phase = if (state.ck!!.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING,
|
||||
log = s.log + "Seat $seat sends the pirate to (${move.hex.q},${move.hex.r})$stole.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun victimsAt(state: CatanState, seat: Int, hex: Int): List<Int> =
|
||||
CatanBoard.HEX_CORNERS[hex]
|
||||
state.topo.HEX_CORNERS[hex]
|
||||
.mapNotNull { state.buildings[it] }
|
||||
.map { it.owner }
|
||||
.filter { it != seat }
|
||||
@@ -445,7 +476,7 @@ object CatanCk {
|
||||
|
||||
fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
|
||||
val ck = state.ck!!
|
||||
val hex = CatanBoard.hexIndex[move.hex]
|
||||
val hex = state.topo.hexIndex[move.hex]
|
||||
?: throw IllegalArgumentException("No hex at ${move.hex}")
|
||||
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
|
||||
val rng = Random(state.seed * 1_000_003L + state.rngStep)
|
||||
@@ -495,7 +526,7 @@ object CatanCk {
|
||||
ProgressType.ENGINEER -> wallSites(state, seat).map { PlayProgress(id, vertex = it) }
|
||||
|
||||
ProgressType.INVENTOR -> {
|
||||
val hexes = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] in INVENTOR_NUMBERS }
|
||||
val hexes = (0 until state.topo.HEX_COUNT).filter { state.numbers[it] in INVENTOR_NUMBERS }
|
||||
hexes.flatMap { a ->
|
||||
hexes.filter { b -> b > a && state.numbers[b] != state.numbers[a] }
|
||||
.map { b -> PlayProgress(id, hexA = a, hexB = b) }
|
||||
@@ -530,7 +561,7 @@ object CatanCk {
|
||||
|
||||
ProgressType.INTRIGUE ->
|
||||
ck.knights.filter { k ->
|
||||
k.owner != seat && CatanBoard.VERTEX_EDGES[k.vertex].any { state.roads[it] == seat }
|
||||
k.owner != seat && state.topo.VERTEX_EDGES[k.vertex].any { holds(state, seat, it) }
|
||||
}.map { PlayProgress(id, vertex = it.vertex) }
|
||||
|
||||
ProgressType.SABOTEUR -> listOf(PlayProgress(id))
|
||||
@@ -559,9 +590,9 @@ object CatanCk {
|
||||
}
|
||||
|
||||
ProgressType.MERCHANT ->
|
||||
(0 until CatanBoard.HEX_COUNT).filter { h ->
|
||||
state.terrains[h] != Terrain.DESERT &&
|
||||
CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
|
||||
(0 until state.topo.HEX_COUNT).filter { h ->
|
||||
state.terrains[h] != Terrain.DESERT && state.topo.land[h] && state.terrains[h] != Terrain.FOG &&
|
||||
state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
|
||||
}.map { PlayProgress(id, hexA = it) }
|
||||
|
||||
ProgressType.MERCHANT_FLEET -> Good.entries.map { PlayProgress(id, good = it) }
|
||||
@@ -596,19 +627,19 @@ object CatanCk {
|
||||
}
|
||||
|
||||
private fun hexesNextTo(state: CatanState, seat: Int, terrain: Terrain): List<Int> =
|
||||
(0 until CatanBoard.HEX_COUNT).filter { h ->
|
||||
(0 until state.topo.HEX_COUNT).filter { h ->
|
||||
state.terrains[h] == terrain &&
|
||||
CatanBoard.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
|
||||
state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
|
||||
}
|
||||
|
||||
/** Roads with a loose end: one end touching nothing else of anyone's. */
|
||||
private fun openRoads(state: CatanState): List<Int> =
|
||||
(0 until CatanBoard.EDGE_COUNT).filter { e ->
|
||||
(0 until state.topo.EDGE_COUNT).filter { e ->
|
||||
if (state.roads[e] == NO_PLAYER) return@filter false
|
||||
val (a, b) = CatanBoard.EDGE_VERTICES[e]
|
||||
val (a, b) = state.topo.EDGE_VERTICES[e]
|
||||
listOf(a, b).any { v ->
|
||||
state.buildings[v] == null && knightAt(state, v) == null &&
|
||||
CatanBoard.VERTEX_EDGES[v].none { it != e && state.roads[it] != NO_PLAYER }
|
||||
state.topo.VERTEX_EDGES[v].none { it != e && state.roads[it] != NO_PLAYER }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1500,12 +1531,12 @@ object CatanCk {
|
||||
|
||||
private fun production(state: CatanState, number: Int): CatanState {
|
||||
val gains = List(state.playerCount) { mutableMapOf<Good, Int>() }
|
||||
for (h in 0 until CatanBoard.HEX_COUNT) {
|
||||
for (h in 0 until state.topo.HEX_COUNT) {
|
||||
if (state.numbers[h] != number || h == state.robber) continue
|
||||
val terrain = state.terrains[h]
|
||||
val resource = terrain.produces ?: continue
|
||||
val commodity = commodityOf(terrain)
|
||||
for (v in CatanBoard.HEX_CORNERS[h]) {
|
||||
for (v in state.topo.HEX_CORNERS[h]) {
|
||||
val building = state.buildings[v] ?: continue
|
||||
val mine = gains[building.owner]
|
||||
fun add(good: Good, n: Int) { mine[good] = (mine[good] ?: 0) + n }
|
||||
@@ -1539,6 +1570,7 @@ object CatanCk {
|
||||
}
|
||||
val said = if (notes.isEmpty()) "nothing for anyone" else notes.joinToString(", ")
|
||||
s = s.copy(log = s.log + "The dice pay: $said.")
|
||||
if (state.isSea) s = SeaRules.payCloth(s, number)
|
||||
|
||||
// The aqueduct: a seat that was paid nothing may take a resource of its choice.
|
||||
val order = (0 until s.playerCount).map { (s.turn + it) % s.playerCount }
|
||||
@@ -1546,7 +1578,11 @@ object CatanCk {
|
||||
improvement(s, seat, Track.SCIENCE) >= IMPROVEMENT_POWER_LEVEL && gains[seat].values.sum() == 0
|
||||
}
|
||||
val queued = enqueue(s, dry.map { AqueductChore(it) }, Back.AFTER_PRODUCTION)
|
||||
return if (queued.ck!!.chores.isNotEmpty()) queued else queued.copy(phase = CatanPhase.PLAYING)
|
||||
val next = if (queued.ck!!.chores.isNotEmpty()) queued else queued.copy(phase = CatanPhase.PLAYING)
|
||||
// Gold is chosen before anything else; the chores (and then play) wait behind it.
|
||||
val gold = if (state.isSea) CatanRules.goldOwed(state, number) else emptyList()
|
||||
if (gold.isEmpty()) return next
|
||||
return SeaRules.settleGold(next.copy(sea = next.sea!!.copy(gold = gold, afterGold = next.phase)))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfile
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
|
||||
/**
|
||||
* The computer opponent for Cities & Knights, reached through [CatanAi].
|
||||
*
|
||||
@@ -14,28 +18,44 @@ package org.prolibertate.games.game.catan
|
||||
* hired, and are activated when the barbarians draw near), never plays the
|
||||
* progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat,
|
||||
* Deserter, Intrigue), and makes no domestic offers, though it answers them.
|
||||
*
|
||||
* The seat's profile shapes it as in [CatanAi]: easy plays on a whim and
|
||||
* discards badly, hard sizes its knights to the barbarian threat, values cards
|
||||
* by what its improvements still need and keeps its robber off its own hexes,
|
||||
* and the style leans it toward knights and expansion, toward spending down
|
||||
* a big hand, or toward the leader.
|
||||
*/
|
||||
internal class CatanCkAi(private val ai: CatanAi) {
|
||||
internal class CatanCkAi(
|
||||
private val ai: CatanAi,
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
) {
|
||||
|
||||
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): 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()
|
||||
CatanPhase.GOLD -> ai.chooseGold(state, seat, legal, profile)
|
||||
|
||||
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||
?: error("Nothing to choose in phase ${state.phase}")
|
||||
}
|
||||
|
||||
@@ -43,18 +63,25 @@ internal class CatanCkAi(private val ai: CatanAi) {
|
||||
// Chores
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>): 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,37 +125,62 @@ 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 hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||
val hard = profile.level == AiLevel.HARD
|
||||
val spite = profile.style.spite
|
||||
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
|
||||
val victimWeight = 0.3 + 0.1 * profile.style.boldness
|
||||
fun score(move: MoveRobber): Double {
|
||||
val hex = state.topo.hexIndex.getValue(move.hex)
|
||||
var score = 0.0
|
||||
for (v in CatanBoard.HEX_CORNERS[hex]) {
|
||||
for (v in state.topo.HEX_CORNERS[hex]) {
|
||||
val building = state.buildings[v] ?: continue
|
||||
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
|
||||
score += if (building.owner == seat) -worth * odds else worth * odds
|
||||
score += if (building.owner == seat) {
|
||||
-worth * odds * (if (hard) 1.5 else 1.0)
|
||||
} else {
|
||||
worth * odds + if (building.owner == leader) spite * worth * odds else 0.0
|
||||
}
|
||||
}
|
||||
if (move.victim != null) {
|
||||
score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim]
|
||||
val cards = CatanCk.goodsCount(state, move.victim)
|
||||
if (!hard || cards > 0) {
|
||||
score += 0.15 * cards + victimWeight * points[move.victim]
|
||||
if (move.victim == leader) score += 0.4 * spite
|
||||
}
|
||||
}
|
||||
score
|
||||
} ?: moves.first()
|
||||
return score
|
||||
}
|
||||
return if (profile.level == AiLevel.EASY) {
|
||||
profile.pick(moves.sortedByDescending { score(it) })
|
||||
} else {
|
||||
moves.maxByOrNull { score(it) } ?: moves.first()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The main phase
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): 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) {
|
||||
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
|
||||
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
|
||||
val robberHurtsMe = state.topo.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
|
||||
if (onMine != null && robberHurtsMe) return onMine
|
||||
}
|
||||
|
||||
@@ -138,10 +190,38 @@ internal class CatanCkAi(private val ai: CatanAi) {
|
||||
.maxByOrNull { production(state, it.vertex!!) }
|
||||
?.let { return it }
|
||||
|
||||
legal.filterIsInstance<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) }
|
||||
settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it }
|
||||
}
|
||||
|
||||
legal.filterIsInstance<RaidFortress>().firstOrNull()?.let { return it }
|
||||
legal.filterIsInstance<DigCanal>().firstOrNull()?.let { return it }
|
||||
legal.filterIsInstance<BuildWonder>().firstOrNull()?.let { return it }
|
||||
|
||||
// Seafarers: a ship toward land nobody has settled is worth more than a card.
|
||||
legal.filterIsInstance<BuildShip>()
|
||||
.filter { ai.shipValue(state, seat, it.edge) > 0.5 }
|
||||
.maxByOrNull { ai.shipValue(state, seat, it.edge) }
|
||||
?.let { return it }
|
||||
|
||||
// Improvements spend cards nothing else needs. Favour the track furthest along.
|
||||
@@ -150,16 +230,21 @@ internal class CatanCkAi(private val ai: CatanAi) {
|
||||
?.let { return it }
|
||||
|
||||
// Knights: stand ready by the time the barbarians land.
|
||||
defence(state, seat, legal, soon)?.let { return it }
|
||||
if (!(easy && profile.slips())) defence(state, seat, legal, soon, profile)?.let { return it }
|
||||
|
||||
progress(state, seat, legal)?.let { return it }
|
||||
if (!(easy && profile.slips())) progress(state, seat, legal)?.let { return it }
|
||||
|
||||
legal.filterIsInstance<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<BuildShip>()
|
||||
.filter { ai.shipValue(state, seat, it.edge) > 0.15 }
|
||||
.maxByOrNull { ai.shipValue(state, seat, it.edge) }
|
||||
?.let { 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 +253,43 @@ internal class CatanCkAi(private val ai: CatanAi) {
|
||||
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
|
||||
?.let { return it }
|
||||
|
||||
return legal.lastOrNull { it is EndTurn } ?: legal.first()
|
||||
// Stay under the seven: a cautious (or careful) seat sells down a big hand.
|
||||
if ((hard || boldness < 0) && CatanCk.goodsCount(state, seat) > 7) {
|
||||
legal.filterIsInstance<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.
|
||||
@@ -267,7 +377,7 @@ internal class CatanCkAi(private val ai: CatanAi) {
|
||||
|
||||
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
|
||||
private fun production(state: CatanState, vertex: Int): Double =
|
||||
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
|
||||
state.topo.VERTEX_HEXES[vertex].sumOf { h ->
|
||||
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
|
||||
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
|
||||
}
|
||||
@@ -289,26 +399,44 @@ internal class CatanCkAi(private val ai: CatanAi) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): 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 +448,22 @@ internal class CatanCkAi(private val ai: CatanAi) {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
|
||||
private fun worth(state: CatanState, seat: Int, good: Good): Double =
|
||||
good.resource?.let { ai.want(it) } ?: when (good) {
|
||||
private fun worth(state: CatanState, seat: Int, good: Good, profile: AiProfile = AiProfile.DEFAULT): Double {
|
||||
val base = good.resource?.let { ai.want(it) } ?: when (good) {
|
||||
Good.PAPER -> 0.95
|
||||
Good.COIN -> 0.9
|
||||
else -> 0.85
|
||||
}
|
||||
if (profile.level != AiLevel.HARD) return base
|
||||
// Hard keeps the commodity an improvement is about to need.
|
||||
var bonus = if (good.resource == Resource.ORE || good.resource == Resource.GRAIN) 0.1 else 0.0
|
||||
for (track in Track.entries) {
|
||||
if (Good.of(track.commodity) != good) continue
|
||||
val level = CatanCk.improvement(state, seat, track)
|
||||
if (level < IMPROVEMENT_MAX && held(state, seat, good) <= level + 1) bonus += 0.5
|
||||
}
|
||||
return base + bonus
|
||||
}
|
||||
|
||||
/** Which track to lean toward when two are level: politics for the knights, then science. */
|
||||
private fun trackLean(track: Track): Int = when (track) {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
|
||||
const val NO_PLAYER = -1
|
||||
@@ -45,6 +54,36 @@ enum class Terrain(val produces: Resource?) {
|
||||
|
||||
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
|
||||
GOLD(null),
|
||||
|
||||
/**
|
||||
* Seafarers: land not yet found. It becomes a resource or gold when a road or ship first reaches it
|
||||
* (`SeaState.fogDeck`). Until then nothing is built on it, nothing produces, and the robber keeps off.
|
||||
*/
|
||||
FOG(null),
|
||||
|
||||
/** Seafarers, Cloth for Catan: a village. It pays cloth, not cards, to the settlements around it when its number rolls. */
|
||||
VILLAGE(null),
|
||||
|
||||
/**
|
||||
* Seafarers, Pirate Islands: a pirate fortress. It stands until ships raid it (`SeaState.fortress`), and
|
||||
* nobody settles its island meanwhile. Once taken it becomes desert.
|
||||
*/
|
||||
FORTRESS(null),
|
||||
}
|
||||
|
||||
/** Base-island scenarios: the board and goals of a game without Seafarers. Seafarers games choose a [SeaScenario] instead. */
|
||||
@Serializable
|
||||
enum class BaseScenario(val title: String, val blurb: String) {
|
||||
RANDOM_MAP("Random map", "The usual island, its tiles and numbers shuffled for every game."),
|
||||
FIRST_ISLAND(
|
||||
"First Island",
|
||||
"The same fixed island every game, so the set-up is not a surprise: good for a first game or a rematch.",
|
||||
),
|
||||
HARBORMASTER(
|
||||
"The Harbormaster",
|
||||
"Harbours matter. A settlement on a harbour counts a point and a city two; the first to three has the " +
|
||||
"Harbormaster title, worth 2 victory points, and whoever gets more takes it.",
|
||||
),
|
||||
}
|
||||
|
||||
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||
@@ -151,12 +190,9 @@ enum class CatanPhase {
|
||||
CHORE,
|
||||
}
|
||||
|
||||
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
|
||||
/** An expansion a table can switch on. The base game is what is left with none selected. */
|
||||
@Serializable
|
||||
enum class CatanRuleSet {
|
||||
/** The base game: development cards, Largest Army, a robber from the start. */
|
||||
BASE,
|
||||
|
||||
enum class CatanExpansion {
|
||||
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
|
||||
CITIES_AND_KNIGHTS,
|
||||
|
||||
@@ -164,16 +200,59 @@ enum class CatanRuleSet {
|
||||
SEAFARERS,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
/**
|
||||
* What a table plays: the expansions switched on, and the Seafarers map when that one is.
|
||||
*
|
||||
* Saves written before expansions became toggles carry `"ruleSet"` instead of `"expansions"`;
|
||||
* [CatanOptionsSerializer] reads either.
|
||||
*/
|
||||
@Serializable(with = CatanOptionsSerializer::class)
|
||||
data class CatanOptions(
|
||||
val playerCount: Int = 3,
|
||||
val pointsToWin: Int = 10,
|
||||
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
|
||||
val expansions: Set<CatanExpansion> = emptySet(),
|
||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
|
||||
}
|
||||
|
||||
val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions
|
||||
val citiesAndKnights: Boolean get() = CatanExpansion.CITIES_AND_KNIGHTS in expansions
|
||||
}
|
||||
|
||||
/** The wire shape of [CatanOptions]. */
|
||||
@Serializable
|
||||
private class CatanOptionsWire(
|
||||
val playerCount: Int = 3,
|
||||
val pointsToWin: Int = 10,
|
||||
val expansions: Set<CatanExpansion> = emptySet(),
|
||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
|
||||
)
|
||||
|
||||
/** Reads the legacy single `ruleSet` field as an expansion set. */
|
||||
object CatanOptionsSerializer : KSerializer<CatanOptions> {
|
||||
override val descriptor: SerialDescriptor = CatanOptionsWire.serializer().descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
|
||||
CatanOptionsWire.serializer(),
|
||||
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario, value.baseScenario),
|
||||
)
|
||||
|
||||
override fun deserialize(decoder: Decoder): CatanOptions {
|
||||
val input = decoder as JsonDecoder
|
||||
var obj = input.decodeJsonElement().jsonObject
|
||||
val legacy = (obj["ruleSet"] as? JsonPrimitive)?.content
|
||||
if (legacy != null) {
|
||||
val expansions = if (legacy == "BASE") emptyList() else listOf(JsonPrimitive(legacy))
|
||||
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
|
||||
}
|
||||
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
|
||||
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario, wire.baseScenario)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,6 +277,8 @@ data class CatanState(
|
||||
val turn: Int,
|
||||
/** How many setup placements are done, 0 until 2 * playerCount. */
|
||||
val setupIndex: Int,
|
||||
/** The seat that places first and takes the first turn; drawn from the table seed. */
|
||||
val firstSeat: Int = 0,
|
||||
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
|
||||
val turnNumber: Int,
|
||||
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
|
||||
@@ -242,6 +323,8 @@ data class CatanState(
|
||||
val log: List<String>,
|
||||
/** Everything Cities & Knights adds; null in the base game. */
|
||||
val ck: CkState? = null,
|
||||
/** The Harbormaster scenario: the seat holding the title, or null. */
|
||||
val harbormaster: Int? = null,
|
||||
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
|
||||
val sea: SeaState? = null,
|
||||
) {
|
||||
@@ -414,12 +497,85 @@ data class SeaState(
|
||||
val shipMoved: Boolean = false,
|
||||
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
|
||||
val islandBonus: List<Int>,
|
||||
/** The island the players start on. */
|
||||
/** The island that counts as home: the largest, and the pirate-free start of the original map. */
|
||||
val home: Int,
|
||||
/** The islands the starting settlements may go on. Empty in a save from before there were several: just [home]. */
|
||||
val starts: List<Int> = emptyList(),
|
||||
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
|
||||
val gold: List<GoldOwed> = emptyList(),
|
||||
/**
|
||||
* The tiles still face down, next to be found first. Hidden from every view but the host's, like the
|
||||
* development deck.
|
||||
*/
|
||||
val fogDeck: List<FogTile> = emptyList(),
|
||||
/** Where the game goes when the gold queue empties: [CatanPhase.PLAYING] unless a find interrupted something else. */
|
||||
val afterGold: CatanPhase? = null,
|
||||
/** Cloth tokens each seat holds (Cloth for Catan); empty when the scenario has none. Two make a victory point. */
|
||||
val cloth: List<Int> = emptyList(),
|
||||
/** Cloth still on each hex's village, by hex (Cloth for Catan); empty when the scenario has none. */
|
||||
val villageCloth: List<Int> = emptyList(),
|
||||
/** The tribe chip waiting on each hex (Forgotten Tribe), by hex; empty when the scenario has none. */
|
||||
val chips: List<TribeChip> = emptyList(),
|
||||
/** Seats that took a harbour chip and so trade 3:1 anywhere. */
|
||||
val harbourSeats: List<Int> = emptyList(),
|
||||
/** Victory points each seat holds from tribe chips; empty until one is taken. */
|
||||
val chipPoints: List<Int> = emptyList(),
|
||||
/** Strength still left in each hex's pirate fortress, by hex (Pirate Islands); empty when the map has none. */
|
||||
val fortress: List<Int> = emptyList(),
|
||||
/** Victory points each seat holds for taking fortresses; empty until one falls. */
|
||||
val raidPoints: List<Int> = emptyList(),
|
||||
/** Wonder stages each seat has built (Wonders of Catan), by seat; empty when the scenario has none. */
|
||||
val wonders: List<Int> = emptyList(),
|
||||
/** The chest under each hex of fog, by hex (Treasure Islands, Oases); found when the hex is. Hidden from other views. */
|
||||
val treasures: List<Treasure> = emptyList(),
|
||||
/** Units dug so far into each canal tile, by hex (Great Canal); empty when the map has none. */
|
||||
val canalUnits: List<Int> = emptyList(),
|
||||
/** The number token waiting under each canal tile, by hex; it shows when the tile is finished. */
|
||||
val canalNumbers: List<Int> = emptyList(),
|
||||
/** Canal units each seat has dug; every two are a victory point. */
|
||||
val canalDug: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
/** What a treasure chest holds (Treasure Islands). */
|
||||
@Serializable
|
||||
enum class Treasure {
|
||||
NONE,
|
||||
|
||||
/** One brick, one wool and one grain. */
|
||||
BRICK_WOOL_GRAIN,
|
||||
|
||||
/** One resource of the finder's choice. */
|
||||
CHOICE,
|
||||
|
||||
/** A development card (a resource of choice in Cities & Knights, which has none). */
|
||||
DEV_CARD,
|
||||
|
||||
/** Two roads' worth of cards: two lumber and two brick. */
|
||||
ROADS,
|
||||
}
|
||||
|
||||
/** What the first settler beside a tribe hex takes (Forgotten Tribe). */
|
||||
@Serializable
|
||||
enum class TribeChip {
|
||||
NONE,
|
||||
|
||||
/** One victory point. */
|
||||
POINT,
|
||||
|
||||
/** A 3:1 harbour, good wherever the seat trades. */
|
||||
HARBOUR,
|
||||
|
||||
/** Two resources of the seat's choice. */
|
||||
GOLD,
|
||||
}
|
||||
|
||||
/** One tile of the fog: what the hex turns out to be and the number token that comes with it. */
|
||||
@Serializable
|
||||
data class FogTile(val terrain: Terrain, val number: Int)
|
||||
|
||||
/** Whether [island] is one the game starts on, and so never earns the new-island bonus. */
|
||||
fun SeaState.isStart(island: Int): Boolean = island == home || island in starts
|
||||
|
||||
@Serializable
|
||||
data class GoldOwed(val seat: Int, val count: Int)
|
||||
|
||||
@@ -442,3 +598,18 @@ data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
|
||||
@Serializable
|
||||
@SerialName("gold")
|
||||
data class PickGold(val cards: List<Resource>) : CatanMove
|
||||
|
||||
/** Raid the pirate fortress on [hex] from a ship beside it: 1 ore, 1 wool and 1 grain take one point of its strength. */
|
||||
@Serializable
|
||||
@SerialName("raid")
|
||||
data class RaidFortress(val hex: HexCoord) : CatanMove
|
||||
|
||||
/** Build the next stage of your wonder, which needs a city of yours; the stage's cost is in `SeaRules.WONDER_STAGES`. */
|
||||
@Serializable
|
||||
@SerialName("wonder")
|
||||
data object BuildWonder : CatanMove
|
||||
|
||||
/** Dig one unit of the canal tile on [hex]: 1 brick and 1 ore, from a seat with a settlement or city beside it. */
|
||||
@Serializable
|
||||
@SerialName("dig")
|
||||
data class DigCanal(val hex: HexCoord) : CatanMove
|
||||
|
||||
@@ -54,15 +54,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
|
||||
val sea = options.ruleSet == CatanRuleSet.SEAFARERS
|
||||
val scenario = SeaScenario.NEW_SHORES
|
||||
val sea = options.seafarers
|
||||
val scenario = options.scenario
|
||||
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
|
||||
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
|
||||
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
|
||||
?: CatanBoard.generate(config.seed)
|
||||
?: CatanBoard.generate(
|
||||
if (options.baseScenario == BaseScenario.FIRST_ISLAND) CatanBoard.FIRST_ISLAND_SEED else config.seed,
|
||||
)
|
||||
// A different random stream than the island, so the same seed still
|
||||
// produces both a fixed island and a fixed deck.
|
||||
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
|
||||
val ck = options.citiesAndKnights
|
||||
// Cities & Knights has no development cards: knights and progress
|
||||
// cards take their place.
|
||||
val devDeck = if (ck) {
|
||||
@@ -73,12 +75,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
.shuffled(Random(config.seed + 1))
|
||||
}
|
||||
|
||||
// Seat 0 used to lead every game; a separate stream from the board and
|
||||
// the deck keeps the same seed giving the same table.
|
||||
val firstSeat = Random(config.seed + 2).nextInt(options.playerCount)
|
||||
|
||||
return CatanState(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
phase = CatanPhase.SETUP_FIRST,
|
||||
turn = 0,
|
||||
turn = firstSeat,
|
||||
setupIndex = 0,
|
||||
firstSeat = firstSeat,
|
||||
turnNumber = 0,
|
||||
terrains = board.terrains,
|
||||
numbers = board.numbers,
|
||||
@@ -114,6 +121,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
pirate = it.pirate,
|
||||
islandBonus = List(topo.islandCount) { NO_PLAYER },
|
||||
home = it.home,
|
||||
starts = it.starts,
|
||||
fogDeck = it.fogDeck,
|
||||
cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 },
|
||||
villageCloth = it.villageCloth,
|
||||
chips = it.chips,
|
||||
fortress = it.fortress,
|
||||
treasures = it.treasures,
|
||||
canalUnits = if (it.canalNumbers.isEmpty()) emptyList() else List(topo.HEX_COUNT) { 0 },
|
||||
canalNumbers = it.canalNumbers,
|
||||
canalDug = if (it.canalNumbers.isEmpty()) emptyList() else List(options.playerCount) { 0 },
|
||||
wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -121,8 +139,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
|
||||
override fun currentSeat(state: CatanState): Int? = when (state.phase) {
|
||||
CatanPhase.GAME_OVER -> null
|
||||
CatanPhase.SETUP_FIRST -> state.setupIndex
|
||||
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
|
||||
CatanPhase.SETUP_FIRST -> (state.firstSeat + state.setupIndex) % state.playerCount
|
||||
CatanPhase.SETUP_SECOND ->
|
||||
(state.firstSeat + 2 * state.playerCount - 1 - state.setupIndex) % state.playerCount
|
||||
CatanPhase.DISCARDING -> state.discardSeat
|
||||
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
|
||||
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
|
||||
@@ -201,8 +220,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val moves = mutableListOf<SetupPlace>()
|
||||
for (v in 0 until state.topo.VERTEX_COUNT) {
|
||||
if (!vertexOpenForSettlement(state, v)) continue
|
||||
// Seafarers: everyone starts on the home island.
|
||||
if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue
|
||||
// Seafarers: the starting settlements go on a starting island.
|
||||
if (state.sea != null && !state.sea.isStart(state.topo.vertexIsland[v])) continue
|
||||
// Nobody starts beside land that has not been found.
|
||||
if (state.topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) continue
|
||||
for (e in state.topo.VERTEX_EDGES[v]) {
|
||||
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
|
||||
moves += SetupPlace(v, e)
|
||||
@@ -238,7 +259,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
|
||||
val moves = mutableListOf<MoveRobber>()
|
||||
for (h in 0 until state.topo.HEX_COUNT) {
|
||||
if (h == state.robber || !state.topo.land[h]) continue
|
||||
if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG || state.terrains[h] == Terrain.FORTRESS) continue
|
||||
val victims = state.topo.HEX_CORNERS[h]
|
||||
.mapNotNull { state.buildings[it] }
|
||||
.map { it.owner }
|
||||
@@ -270,6 +291,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
if (state.isSea) {
|
||||
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
|
||||
moves += SeaRules.moveShipMoves(state, seat)
|
||||
moves += SeaRules.scenarioMoves(state, seat)
|
||||
}
|
||||
|
||||
if (!state.devCardPlayedThisTurn) {
|
||||
@@ -404,6 +426,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
/** An empty vertex with no building on it or one edge away. */
|
||||
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
|
||||
!SeaRules.fortressHolds(state, v) &&
|
||||
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||
|
||||
/**
|
||||
@@ -457,6 +480,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
is MoveShip -> applyMoveShip(state, seat, move)
|
||||
is MovePirate -> applyMovePirate(state, seat, move)
|
||||
is PickGold -> applyPickGold(state, seat, move)
|
||||
is RaidFortress -> SeaRules.applyRaid(state, seat, move)
|
||||
is DigCanal -> SeaRules.applyDig(state, seat, move)
|
||||
BuildWonder -> SeaRules.applyWonder(state, seat)
|
||||
is BuildRoad -> applyBuildRoad(state, seat, move)
|
||||
is BuildSettlement -> applyBuildSettlement(state, seat, move)
|
||||
is BuildCity -> applyBuildCity(state, seat, move)
|
||||
@@ -508,13 +534,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
}
|
||||
|
||||
val newIndex = state.setupIndex + 1
|
||||
s = s.copy(setupIndex = newIndex)
|
||||
s = refreshHarbormaster(s.copy(setupIndex = newIndex))
|
||||
return when {
|
||||
newIndex >= 2 * n -> s.copy(
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
turn = 0,
|
||||
turn = state.firstSeat,
|
||||
turnNumber = 1,
|
||||
log = s.log + "Setup done — seat 0 leads.",
|
||||
log = s.log + "Setup done — seat ${state.firstSeat} leads.",
|
||||
)
|
||||
|
||||
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
|
||||
@@ -586,12 +612,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
s = s.copy(
|
||||
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
|
||||
)
|
||||
if (state.isSea) s = SeaRules.payCloth(s, number)
|
||||
if (goldOwed.isEmpty()) return s
|
||||
return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed)))
|
||||
}
|
||||
|
||||
/** What each seat is owed for gold fields showing [number], in seat order from the roller. */
|
||||
private fun goldOwed(state: CatanState, number: Int): List<GoldOwed> {
|
||||
internal fun goldOwed(state: CatanState, number: Int): List<GoldOwed> {
|
||||
val topo = state.topo
|
||||
val owed = IntArray(state.playerCount)
|
||||
for (h in 0 until topo.HEX_COUNT) {
|
||||
@@ -654,7 +681,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
s = s.copy(pendingFreeRoads = left)
|
||||
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
|
||||
}
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
|
||||
s = s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")
|
||||
if (state.sea != null) s = SeaRules.reveal(s, seat, move.edge)
|
||||
return checkWin(s)
|
||||
}
|
||||
|
||||
private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState {
|
||||
@@ -673,7 +702,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
s = s.copy(pendingFreeRoads = left)
|
||||
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
|
||||
}
|
||||
return checkWin(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."))
|
||||
return checkWin(SeaRules.reveal(s.copy(log = s.log + "Seat $seat launches a ship on ${move.edge}."), seat, move.edge))
|
||||
}
|
||||
|
||||
private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState {
|
||||
@@ -686,12 +715,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val s = state.copy(
|
||||
sea = sea.copy(ships = ships, shipMoved = true, builtThisTurn = sea.builtThisTurn + move.to),
|
||||
)
|
||||
return checkWin(refreshLongestRoad(s).let {
|
||||
it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.")
|
||||
})
|
||||
return checkWin(
|
||||
SeaRules.reveal(
|
||||
refreshLongestRoad(s).let { it.copy(log = it.log + "Seat $seat sails a ship from ${move.from} to ${move.to}.") },
|
||||
seat,
|
||||
move.to,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyMovePirate(state: CatanState, seat: Int, move: MovePirate): CatanState {
|
||||
if (state.isCk) return CatanCk.applyPirate(state, seat, move)
|
||||
val hex = state.topo.hexIndex[move.hex] ?: throw IllegalArgumentException("No hex at ${move.hex}")
|
||||
var s = state.copy(sea = state.sea!!.copy(pirate = hex), rngStep = state.rngStep + 1)
|
||||
var stolen = ""
|
||||
@@ -726,7 +760,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
},
|
||||
)
|
||||
s = payCost(s, seat, SETTLEMENT_COST)
|
||||
s = SeaRules.claimIsland(s, seat, move.vertex)
|
||||
s = SeaRules.settled(s, seat, move.vertex)
|
||||
// A settlement can break another player's road, so the award is
|
||||
// recomputed for everyone.
|
||||
s = refreshLongestRoad(s)
|
||||
@@ -991,16 +1025,64 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
}
|
||||
state.longestRoadOwner?.let { points[it] += 5 }
|
||||
state.largestArmyOwner?.let { points[it] += 5 }
|
||||
state.harbormaster?.let { points[it] += HARBORMASTER_POINTS }
|
||||
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
|
||||
return points.toList()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The Harbormaster
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The scenario's title: points for a harbour town and for the Harbormaster. */
|
||||
const val HARBORMASTER_MINIMUM = 3
|
||||
const val HARBORMASTER_POINTS = 2
|
||||
|
||||
/** Whether this table plays the Harbormaster (never in a Seafarers game, which has its own maps). */
|
||||
internal fun harbormasterGame(state: CatanState): Boolean =
|
||||
state.options.baseScenario == BaseScenario.HARBORMASTER && !state.isSea
|
||||
|
||||
/** Harbour points: a settlement on a harbour counts 1, a city 2, for each harbour it touches. */
|
||||
fun harbourPoints(state: CatanState, seat: Int): Int {
|
||||
var total = 0
|
||||
for (port in state.ports) {
|
||||
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
|
||||
val own = listOfNotNull(state.buildings[a], state.buildings[b]).firstOrNull { it.owner == seat }
|
||||
if (own != null) total += if (own.type == BuildingType.CITY) 2 else 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Gives the title to the first seat with three harbour points, and to a seat with strictly more than the holder. */
|
||||
internal fun refreshHarbormaster(state: CatanState): CatanState {
|
||||
if (!harbormasterGame(state)) return state
|
||||
val points = List(state.playerCount) { harbourPoints(state, it) }
|
||||
val holder = state.harbormaster
|
||||
val best = points.max()
|
||||
val leaders = points.indices.filter { points[it] == best }
|
||||
val next = when {
|
||||
holder == null -> leaders.singleOrNull()?.takeIf { best >= HARBORMASTER_MINIMUM }
|
||||
best > points[holder] -> leaders.singleOrNull() ?: holder
|
||||
else -> holder
|
||||
}
|
||||
if (next == holder) return state
|
||||
return state.copy(
|
||||
harbormaster = next,
|
||||
log = state.log + if (holder == null) {
|
||||
"Seat $next is now harbormaster."
|
||||
} else {
|
||||
"Seat $next has taken the title harbormaster from seat $holder."
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun victoryPoints(state: CatanState, seat: Int): Int =
|
||||
publicVictoryPoints(state)[seat] +
|
||||
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
||||
|
||||
internal fun checkWin(state: CatanState): CatanState {
|
||||
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||
internal fun checkWin(settled: CatanState): CatanState {
|
||||
if (settled.phase == CatanPhase.GAME_OVER) return settled
|
||||
val state = refreshHarbormaster(settled)
|
||||
val seat = state.turn
|
||||
val points = victoryPoints(state, seat)
|
||||
if (points < state.options.pointsToWin) return state
|
||||
@@ -1129,7 +1211,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
cards.groupingBy { it }.eachCount()
|
||||
|
||||
/** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */
|
||||
private fun withCounts(
|
||||
internal fun withCounts(
|
||||
resources: List<Map<Resource, Int>>,
|
||||
seat: Int,
|
||||
delta: Map<Resource, Int>,
|
||||
@@ -1167,6 +1249,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
|
||||
if (mine) best = port.ratio
|
||||
}
|
||||
if (best > 3 && state.sea?.harbourSeats?.contains(seat) == true) best = 3
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -1177,7 +1260,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
|
||||
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
|
||||
}
|
||||
return 4
|
||||
return if (state.sea?.harbourSeats?.contains(seat) == true) 3 else 4
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1198,6 +1281,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
state.copy(
|
||||
// Hidden information stays with the holder and the host.
|
||||
devDeck = emptyList(),
|
||||
sea = state.sea?.copy(fogDeck = emptyList(), treasures = emptyList()),
|
||||
devHands = state.devHands.mapIndexed { index, hand ->
|
||||
if (index == seat) hand else emptyList()
|
||||
},
|
||||
|
||||
@@ -5,11 +5,69 @@ import kotlin.random.Random
|
||||
|
||||
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
|
||||
@Serializable
|
||||
enum class SeaScenario(val title: String, val blurb: String) {
|
||||
enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart = SeaStart.HOME_ONLY) {
|
||||
NEW_SHORES(
|
||||
"Heading for New Shores",
|
||||
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
|
||||
),
|
||||
FOUR_ISLANDS(
|
||||
"Four Islands",
|
||||
"Four equal islands, one start each, and two gold islets in the middle. Settle your own island, then sail.",
|
||||
SeaStart.MAIN_ISLANDS,
|
||||
),
|
||||
THROUGH_THE_DESERT(
|
||||
"Through the Desert",
|
||||
"One island cut by a broad desert that yields nothing. Reach the far side by road, or sail round to the islets.",
|
||||
),
|
||||
FOG_ISLANDS(
|
||||
"The Fog Islands",
|
||||
"A home island, an islet, and a bank of fog. Land in the fog is face down until a road or ship reaches it; " +
|
||||
"the finder takes a card of what lies there.",
|
||||
),
|
||||
TREASURE_ISLANDS(
|
||||
"The Treasure Islands",
|
||||
"Chests lie hidden on the fog-covered isles beside a home island. Whoever uncovers one opens it: " +
|
||||
"cards, a resource of choice, a development card or a road's worth of materials.",
|
||||
),
|
||||
OASES(
|
||||
"The Oases",
|
||||
"An enormous desert, with fertile oases hidden inside it. Roads that reach an oasis uncover it " +
|
||||
"and whatever treasure is buried there.",
|
||||
),
|
||||
GREAT_CANAL(
|
||||
"The Great Canal",
|
||||
"A desert strip splits the island. Dig the canal tile by tile (a brick and an ore a unit) and each finished " +
|
||||
"tile becomes grain land; every two units dug are a victory point.",
|
||||
),
|
||||
PIRATE_ISLANDS(
|
||||
"Pirate Islands",
|
||||
"Two isles are held by pirate fortresses. Sail ships beside one and raid it with ore, wool and grain; " +
|
||||
"nobody may settle the isle until the fortress falls, and the one who takes it earns two points.",
|
||||
),
|
||||
WONDERS_OF_CATAN(
|
||||
"Wonders of Catan",
|
||||
"Two big islands and room to grow. Raise a city, then build a wonder in four stages; each stage is a " +
|
||||
"victory point and the first to finish all four wins.",
|
||||
),
|
||||
CLOTH_FOR_CATAN(
|
||||
"Cloth for Catan",
|
||||
"Four villages weave cloth. Settle beside one and it pays you a cloth token whenever its number rolls; " +
|
||||
"every two tokens are a victory point.",
|
||||
),
|
||||
FORGOTTEN_TRIBE(
|
||||
"The Forgotten Tribe",
|
||||
"Four small isles hold the chips of a lost tribe. The first to settle beside one takes it: " +
|
||||
"a victory point, a harbour, or gold.",
|
||||
),
|
||||
}
|
||||
|
||||
/** Where the starting settlements may go. */
|
||||
enum class SeaStart {
|
||||
/** The largest island only. */
|
||||
HOME_ONLY,
|
||||
|
||||
/** Any island big enough to live on (four hexes or more); the small ones are prizes to sail to. */
|
||||
MAIN_ISLANDS,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +91,134 @@ object SeaBoards {
|
||||
"..........",
|
||||
)
|
||||
|
||||
private val FOUR_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
"..###..###..",
|
||||
"..###..###..",
|
||||
"............",
|
||||
"..$.....$...",
|
||||
"............",
|
||||
"..###..###..",
|
||||
"..###..###..",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val THROUGH_THE_DESERT_MAP = listOf(
|
||||
"............",
|
||||
".###DD###...",
|
||||
".###DDD###..",
|
||||
"..###DD###..",
|
||||
"............",
|
||||
"..$.....$...",
|
||||
"............",
|
||||
"...##...#...",
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `F` is fog: land that is face down until found. */
|
||||
private val FOG_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
".###....FF..",
|
||||
".####..FFF..",
|
||||
"..#D#..FFFF.",
|
||||
".###...FFF..",
|
||||
".......FF...",
|
||||
"...##.......",
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `V` is a village; `t` is a resource hex that carries a tribe chip. */
|
||||
private val CLOTH_FOR_CATAN_MAP = listOf(
|
||||
"............",
|
||||
"..V###..V##.",
|
||||
"..#####..##.",
|
||||
"..####V..#..",
|
||||
"...###......",
|
||||
".......##V..",
|
||||
"............",
|
||||
"...##.......",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val FORGOTTEN_TRIBE_MAP = listOf(
|
||||
"............",
|
||||
"..###..t#...",
|
||||
"..#####..#t.",
|
||||
"..#####..#..",
|
||||
"..####......",
|
||||
"........t##.",
|
||||
".#t.........",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val TREASURE_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
"..###..FF...",
|
||||
"..####..FF..",
|
||||
"..#####.....",
|
||||
"..###...F...",
|
||||
".......FF...",
|
||||
"..F.........",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val OASES_MAP = listOf(
|
||||
"............",
|
||||
"..###DD###..",
|
||||
"..#DDFDD#...",
|
||||
"..#DDDFD#...",
|
||||
"..#DFDDD#...",
|
||||
"..###DD###..",
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `C` is a canal tile: desert with a number hidden under it until it is dug out. */
|
||||
private val GREAT_CANAL_MAP = listOf(
|
||||
"............",
|
||||
"..#####.....",
|
||||
"..#####.....",
|
||||
"..CCCCC.....",
|
||||
"..#####.....",
|
||||
"..#####.....",
|
||||
"........$$..",
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `P` is a pirate fortress. */
|
||||
private val PIRATE_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
"..####.P##..",
|
||||
"..####..##..",
|
||||
"..####......",
|
||||
"..####..P#..",
|
||||
"...##...##..",
|
||||
"............",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val WONDERS_OF_CATAN_MAP = listOf(
|
||||
"............",
|
||||
"..####.###..",
|
||||
".#####.####.",
|
||||
"..###D.###..",
|
||||
".......##...",
|
||||
"..$......$..",
|
||||
"...##.......",
|
||||
"............",
|
||||
)
|
||||
|
||||
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
|
||||
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
|
||||
SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP
|
||||
SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP
|
||||
SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP
|
||||
SeaScenario.TREASURE_ISLANDS -> TREASURE_ISLANDS_MAP
|
||||
SeaScenario.OASES -> OASES_MAP
|
||||
SeaScenario.GREAT_CANAL -> GREAT_CANAL_MAP
|
||||
SeaScenario.PIRATE_ISLANDS -> PIRATE_ISLANDS_MAP
|
||||
SeaScenario.WONDERS_OF_CATAN -> WONDERS_OF_CATAN_MAP
|
||||
SeaScenario.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP
|
||||
SeaScenario.FORGOTTEN_TRIBE -> FORGOTTEN_TRIBE_MAP
|
||||
}
|
||||
|
||||
private val topologies = HashMap<SeaScenario, Topology>()
|
||||
@@ -72,8 +256,34 @@ object SeaBoards {
|
||||
val pirate: Int,
|
||||
val ports: List<Port>,
|
||||
val home: Int,
|
||||
/** The islands the starting settlements may go on; always includes [home]. */
|
||||
val starts: List<Int> = listOf(home),
|
||||
/** The tiles under the fog, in the order they will be found. */
|
||||
val fogDeck: List<FogTile> = emptyList(),
|
||||
/** The tribe chip on each hex, or [TribeChip.NONE]; empty when the map has none. */
|
||||
val chips: List<TribeChip> = emptyList(),
|
||||
/** Cloth waiting on each village, by hex; empty when the map has none. */
|
||||
val villageCloth: List<Int> = emptyList(),
|
||||
/** Strength of the fortress on each hex, by hex; empty when the map has none. */
|
||||
val fortress: List<Int> = emptyList(),
|
||||
/** The chest under each hex, by hex; empty when the map has none. */
|
||||
val treasures: List<Treasure> = emptyList(),
|
||||
/** The number under each canal tile, by hex; empty when the map has none. */
|
||||
val canalNumbers: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
/** An island of this many land hexes or more can be started on. */
|
||||
private const val MAIN_ISLAND_HEXES = 4
|
||||
|
||||
/** Cloth each village starts with. */
|
||||
const val VILLAGE_CLOTH = 6
|
||||
|
||||
/** Ships' raids a pirate fortress takes to fall. */
|
||||
const val FORTRESS_STRENGTH = 3
|
||||
|
||||
/** Chests dealt to the fog, at most. */
|
||||
private const val CHESTS = 5
|
||||
|
||||
private val RESOURCE_TILES = listOf(
|
||||
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
|
||||
)
|
||||
@@ -88,19 +298,24 @@ object SeaBoards {
|
||||
val random = Random(seed)
|
||||
|
||||
// Resources fill the '#' hexes as evenly as the count allows, in shuffled order.
|
||||
val plain = kinds.indices.filter { kinds[it] == '#' }
|
||||
val plain = kinds.indices.filter { kinds[it] == '#' || kinds[it] == 't' }
|
||||
val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random)
|
||||
val terrains = MutableList(kinds.size) { Terrain.SEA }
|
||||
kinds.forEachIndexed { h, ch ->
|
||||
terrains[h] = when (ch) {
|
||||
'$' -> Terrain.GOLD
|
||||
'D' -> Terrain.DESERT
|
||||
'#' -> pool[plain.indexOf(h)]
|
||||
'#', 't' -> pool[plain.indexOf(h)]
|
||||
'V' -> Terrain.VILLAGE
|
||||
'P' -> Terrain.FORTRESS
|
||||
'C' -> Terrain.DESERT
|
||||
'F' -> Terrain.FOG
|
||||
else -> Terrain.SEA
|
||||
}
|
||||
}
|
||||
|
||||
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT }
|
||||
// The fog hexes take part as slots, so the tokens they hide are dealt from the same pile.
|
||||
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && (terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS || kinds[it] == 'C') }
|
||||
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
|
||||
val tokens = TOKENS.take(numbered.size).shuffled(random)
|
||||
val numbers = IntArray(terrains.size)
|
||||
@@ -119,6 +334,14 @@ object SeaBoards {
|
||||
attempt++
|
||||
}
|
||||
|
||||
// What lies under the fog: mostly resources, one gold field in five, drawn from the same tokens.
|
||||
val fogHexes = terrains.indices.filter { terrains[it] == Terrain.FOG }
|
||||
val fogKinds = List(fogHexes.size) { i ->
|
||||
if (i % 5 == 4) Terrain.GOLD else RESOURCE_TILES[i % RESOURCE_TILES.size]
|
||||
}.shuffled(random)
|
||||
val fogDeck = fogHexes.mapIndexed { i, h -> FogTile(fogKinds[i], numbers[h]) }.shuffled(random)
|
||||
fogHexes.forEach { numbers[it] = 0 }
|
||||
|
||||
val portTypes = (
|
||||
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
|
||||
).shuffled(random)
|
||||
@@ -129,7 +352,12 @@ object SeaBoards {
|
||||
|
||||
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
|
||||
?: numbered.first()
|
||||
val home = (0 until topo.islandCount).maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
|
||||
// Home is the largest island that is not under the fog: nobody starts on land they cannot see.
|
||||
val fogged = (0 until topo.islandCount).filter { island ->
|
||||
topo.islandOf.indices.filter { topo.islandOf[it] == island }.all { terrains[it] == Terrain.FOG }
|
||||
}.toSet()
|
||||
val home = (0 until topo.islandCount).filter { it !in fogged }
|
||||
.maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
|
||||
// The pirate starts in the open sea: the sea hex farthest from every shore.
|
||||
val sea = terrains.indices.filter { terrains[it] == Terrain.SEA }
|
||||
val pirate = sea.maxByOrNull { h ->
|
||||
@@ -139,6 +367,52 @@ object SeaBoards {
|
||||
} ?: 0
|
||||
} ?: sea.first()
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home)
|
||||
val starts = when (scenario.start) {
|
||||
SeaStart.HOME_ONLY -> listOf(home)
|
||||
SeaStart.MAIN_ISLANDS -> (0 until topo.islandCount).filter { island ->
|
||||
topo.islandOf.count { it == island } >= MAIN_ISLAND_HEXES
|
||||
}.ifEmpty { listOf(home) }
|
||||
}
|
||||
|
||||
// Tribe chips: one of each kind in turn, dealt to the marked hexes in random order.
|
||||
val chipHexes = kinds.indices.filter { kinds[it] == 't' }
|
||||
val chips = if (chipHexes.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
val kindsOfChip = listOf(TribeChip.POINT, TribeChip.HARBOUR, TribeChip.GOLD)
|
||||
val dealt = List(chipHexes.size) { kindsOfChip[it % kindsOfChip.size] }.shuffled(random)
|
||||
List(terrains.size) { h -> chipHexes.indexOf(h).let { if (it < 0) TribeChip.NONE else dealt[it] } }
|
||||
}
|
||||
val villages = if (kinds.none { it == 'V' }) {
|
||||
emptyList()
|
||||
} else {
|
||||
List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 }
|
||||
}
|
||||
|
||||
val fortress = if (kinds.none { it == 'P' }) {
|
||||
emptyList()
|
||||
} else {
|
||||
List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 }
|
||||
}
|
||||
|
||||
// Chests under the fog: the four rewards in turn, on hexes picked at random.
|
||||
val chestKinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.CHOICE, Treasure.DEV_CARD, Treasure.ROADS)
|
||||
val chestHexes = if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) {
|
||||
fogHexes.shuffled(random).take(CHESTS)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val treasures = if (chestHexes.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
List(terrains.size) { h -> chestHexes.indexOf(h).let { if (it < 0) Treasure.NONE else chestKinds[it % chestKinds.size] } }
|
||||
}
|
||||
|
||||
// Canal tiles keep their number under the tile until it is dug out.
|
||||
val canalHexes = kinds.indices.filter { kinds[it] == 'C' }
|
||||
val canalNumbers = if (canalHexes.isEmpty()) emptyList() else List(terrains.size) { h -> if (h in canalHexes) numbers[h] else 0 }
|
||||
canalHexes.forEach { numbers[it] = 0 }
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress, treasures, canalNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ internal object SeaRules {
|
||||
.map { sea.ships[it] }
|
||||
.filter { it != NO_PLAYER && it != thief }
|
||||
.distinct()
|
||||
.filter { state.resourceCount(it) > 0 }
|
||||
.filter { if (state.isCk) CatanCk.goodsCount(state, it) > 0 else state.resourceCount(it) > 0 }
|
||||
}
|
||||
|
||||
fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> {
|
||||
@@ -135,25 +135,356 @@ internal object SeaRules {
|
||||
val supply = Resource.entries.sumOf { CatanRules.bankSupply(state, it) }
|
||||
val queue = if (supply > 0) sea.gold else emptyList()
|
||||
return if (queue.isEmpty()) {
|
||||
state.copy(sea = sea.copy(gold = emptyList()), phase = CatanPhase.PLAYING)
|
||||
state.copy(sea = sea.copy(gold = emptyList(), afterGold = null), phase = sea.afterGold ?: CatanPhase.PLAYING)
|
||||
} else {
|
||||
state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fog
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Hidden hexes a piece on [edge] reaches: any that touches either end. */
|
||||
private fun fogReached(state: CatanState, edge: Int): List<Int> {
|
||||
val topo = state.topo
|
||||
return topo.EDGE_VERTICES[edge].toList()
|
||||
.flatMap { topo.VERTEX_HEXES[it] }
|
||||
.filter { state.terrains[it] == Terrain.FOG }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
|
||||
/**
|
||||
* After a road or ship lands on [edge]: turn each fog hex it now touches face up. The finder takes one card
|
||||
* of what it yields, or chooses any resource if it is gold, supply permitting. The caller's phase is kept.
|
||||
*/
|
||||
fun reveal(state: CatanState, seat: Int, edge: Int): CatanState {
|
||||
var s = state
|
||||
val found = fogReached(state, edge)
|
||||
if (found.isEmpty()) return s
|
||||
val owed = mutableListOf<GoldOwed>()
|
||||
for (hex in found) {
|
||||
val sea = s.sea!!
|
||||
val tile = sea.fogDeck.firstOrNull() ?: break
|
||||
val name = tile.terrain.name.lowercase()
|
||||
s = s.copy(
|
||||
terrains = s.terrains.toMutableList().also { it[hex] = tile.terrain },
|
||||
numbers = s.numbers.toMutableList().also { it[hex] = tile.number },
|
||||
sea = sea.copy(fogDeck = sea.fogDeck.drop(1)),
|
||||
log = s.log + "Seat $seat uncovers ${if (tile.terrain == Terrain.GOLD) "a gold field" else "the $name"} " +
|
||||
"(${s.topo.HEXES[hex].q},${s.topo.HEXES[hex].r})" +
|
||||
if (tile.number != 0) " with a ${tile.number}." else ".",
|
||||
)
|
||||
val resource = tile.terrain.produces
|
||||
if (resource != null && CatanRules.bankSupply(s, resource) > 0) {
|
||||
s = s.copy(resources = CatanRules.withCounts(s.resources, seat, mapOf(resource to 1), 1))
|
||||
} else if (tile.terrain == Terrain.GOLD) {
|
||||
owed += GoldOwed(seat, 1)
|
||||
}
|
||||
val chest = s.sea!!.treasures.getOrNull(hex) ?: Treasure.NONE
|
||||
if (chest != Treasure.NONE) {
|
||||
val (opened, choose) = openChest(s, seat, chest)
|
||||
s = opened
|
||||
if (choose > 0) owed += GoldOwed(seat, choose)
|
||||
}
|
||||
}
|
||||
if (owed.isEmpty()) return s
|
||||
return owe(s, seat, owed.sumOf { it.count })
|
||||
}
|
||||
|
||||
/**
|
||||
* [seat] opens [chest]: the state after what it pays directly, and the number of cards of the seat's own
|
||||
* choice still owed. Cards the bank cannot cover are simply not paid.
|
||||
*/
|
||||
private fun openChest(state: CatanState, seat: Int, chest: Treasure): Pair<CatanState, Int> {
|
||||
var s = state
|
||||
fun pay(cards: Map<Resource, Int>) {
|
||||
val real = cards.filter { (r, n) -> CatanRules.bankSupply(s, r) >= n }
|
||||
if (real.isNotEmpty()) s = s.copy(resources = CatanRules.withCounts(s.resources, seat, real, 1))
|
||||
}
|
||||
var choose = 0
|
||||
val said = when (chest) {
|
||||
Treasure.BRICK_WOOL_GRAIN -> {
|
||||
pay(mapOf(Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1))
|
||||
"a brick, a wool and a grain"
|
||||
}
|
||||
Treasure.ROADS -> {
|
||||
pay(mapOf(Resource.LUMBER to 2, Resource.BRICK to 2))
|
||||
"materials for two roads"
|
||||
}
|
||||
Treasure.DEV_CARD -> if (!s.isCk && s.devDeck.isNotEmpty()) {
|
||||
val card = s.devDeck.first().copy(boughtTurn = s.turnNumber)
|
||||
val hands = s.devHands.toMutableList().also { it[seat] = it[seat] + card }
|
||||
s = s.copy(devDeck = s.devDeck.drop(1), devHands = hands, devHandCounts = hands.map { it.size })
|
||||
"a development card"
|
||||
} else {
|
||||
choose = 1
|
||||
"a resource of its choice"
|
||||
}
|
||||
else -> {
|
||||
choose = 1
|
||||
"a resource of its choice"
|
||||
}
|
||||
}
|
||||
s = s.copy(log = s.log + "Seat $seat opens a treasure chest: $said.")
|
||||
return s to choose
|
||||
}
|
||||
|
||||
/** Queues [count] cards of [seat]'s choice, to be taken before play resumes where it left off. */
|
||||
private fun owe(state: CatanState, seat: Int, count: Int): CatanState {
|
||||
val sea = state.sea!!
|
||||
val queued = sea.copy(
|
||||
gold = sea.gold + GoldOwed(seat, count),
|
||||
afterGold = state.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold,
|
||||
)
|
||||
return settleGold(state.copy(sea = queued))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Islands and scoring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The bonus points [seat] holds for first landings. */
|
||||
fun bonusPoints(state: CatanState, seat: Int): Int =
|
||||
(state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS
|
||||
/** The bonus points [seat] holds: first landings, tribe chips, and one for every two cloth. */
|
||||
fun bonusPoints(state: CatanState, seat: Int): Int {
|
||||
val sea = state.sea ?: return 0
|
||||
return sea.islandBonus.count { it == seat } * ISLAND_BONUS +
|
||||
(sea.raidPoints.getOrNull(seat) ?: 0) +
|
||||
(sea.wonders.getOrNull(seat) ?: 0) +
|
||||
clothOf(state, seat) / CLOTH_PER_POINT +
|
||||
(sea.chipPoints.getOrNull(seat) ?: 0) +
|
||||
(sea.canalDug.getOrNull(seat) ?: 0) / CANAL_UNITS_PER_POINT
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The Great Canal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
val DIG_COST: Map<Resource, Int> = mapOf(Resource.BRICK to 1, Resource.ORE to 1)
|
||||
|
||||
/** Units that finish a canal tile. */
|
||||
const val CANAL_TILE_UNITS = 3
|
||||
|
||||
/** Two units dug are a victory point. */
|
||||
const val CANAL_UNITS_PER_POINT = 2
|
||||
|
||||
/** Tiles [seat] may dig a unit of: unfinished, with a settlement or city of the seat's on a corner. */
|
||||
private fun digTargets(state: CatanState, seat: Int): List<Int> {
|
||||
val sea = state.sea!!
|
||||
if (sea.canalUnits.isEmpty()) return emptyList()
|
||||
return sea.canalUnits.indices.filter { h ->
|
||||
sea.canalNumbers[h] != 0 && sea.canalUnits[h] in 0 until CANAL_TILE_UNITS &&
|
||||
state.terrains[h] == Terrain.DESERT &&
|
||||
state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
|
||||
}
|
||||
}
|
||||
|
||||
fun applyDig(state: CatanState, seat: Int, move: DigCanal): CatanState {
|
||||
val sea = state.sea!!
|
||||
val hex = state.topo.hexIndex.getValue(move.hex)
|
||||
val units = sea.canalUnits[hex] + 1
|
||||
val dug = sea.canalDug.toMutableList().also { it[seat] += 1 }
|
||||
var s = state.copy(
|
||||
resources = CatanRules.withCounts(state.resources, seat, DIG_COST, -1),
|
||||
sea = sea.copy(canalUnits = sea.canalUnits.toMutableList().also { it[hex] = units }, canalDug = dug),
|
||||
log = state.log + "Seat $seat digs the canal at (${move.hex.q},${move.hex.r}): $units of $CANAL_TILE_UNITS.",
|
||||
)
|
||||
if (units == CANAL_TILE_UNITS) {
|
||||
s = s.copy(
|
||||
terrains = s.terrains.toMutableList().also { it[hex] = Terrain.FIELDS },
|
||||
numbers = s.numbers.toMutableList().also { it[hex] = sea.canalNumbers[hex] },
|
||||
log = s.log + "The tile is dug out: it is fields now, with a ${sea.canalNumbers[hex]}.",
|
||||
)
|
||||
}
|
||||
return CatanRules.checkWin(s)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cloth for Catan
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const val CLOTH_PER_POINT = 2
|
||||
|
||||
fun clothOf(state: CatanState, seat: Int): Int = state.sea?.cloth?.getOrNull(seat) ?: 0
|
||||
|
||||
/**
|
||||
* After the dice pay: every village showing [number] pays one cloth to each settlement or city beside it
|
||||
* (a city is not worth more here), until its stock runs out. A robber on the village stops it.
|
||||
*/
|
||||
fun payCloth(state: CatanState, number: Int): CatanState {
|
||||
val sea = state.sea ?: return state
|
||||
if (sea.villageCloth.isEmpty()) return state
|
||||
var s = state
|
||||
val topo = state.topo
|
||||
for (h in 0 until topo.HEX_COUNT) {
|
||||
if (state.terrains[h] != Terrain.VILLAGE || state.numbers[h] != number || h == state.robber) continue
|
||||
val takers = topo.HEX_CORNERS[h].mapNotNull { state.buildings[it]?.owner }
|
||||
if (takers.isEmpty()) continue
|
||||
// Seats in turn order from the roller get the last of the stock.
|
||||
val order = takers.sortedBy { (it - state.turn + state.playerCount) % state.playerCount }
|
||||
val current = s.sea!!
|
||||
var stock = current.villageCloth[h]
|
||||
val cloth = current.cloth.toMutableList()
|
||||
val said = mutableListOf<String>()
|
||||
for (seat in order) {
|
||||
if (stock == 0) break
|
||||
cloth[seat] += 1
|
||||
stock--
|
||||
said += "seat $seat"
|
||||
}
|
||||
if (said.isEmpty()) continue
|
||||
val stocks = current.villageCloth.toMutableList().also { it[h] = stock }
|
||||
s = s.copy(
|
||||
sea = current.copy(cloth = cloth, villageCloth = stocks),
|
||||
log = s.log + "The village at (${topo.HEXES[h].q},${topo.HEXES[h].r}) pays cloth to ${said.joinToString(", ")}.",
|
||||
)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pirate Islands
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
val RAID_COST: Map<Resource, Int> = mapOf(Resource.ORE to 1, Resource.WOOL to 1, Resource.GRAIN to 1)
|
||||
|
||||
const val FORTRESS_POINTS = 2
|
||||
|
||||
/** Whether a pirate fortress still stands on the island [vertex] is part of, so nobody may settle there. */
|
||||
fun fortressHolds(state: CatanState, vertex: Int): Boolean {
|
||||
val sea = state.sea ?: return false
|
||||
if (sea.fortress.isEmpty()) return false
|
||||
val island = state.topo.vertexIsland[vertex]
|
||||
return island >= 0 && sea.fortress.indices.any { sea.fortress[it] > 0 && state.topo.islandOf[it] == island }
|
||||
}
|
||||
|
||||
/** Fortress hexes [seat] has a ship beside. */
|
||||
private fun raidTargets(state: CatanState, seat: Int): List<Int> {
|
||||
val sea = state.sea!!
|
||||
if (sea.fortress.isEmpty()) return emptyList()
|
||||
val topo = state.topo
|
||||
return sea.fortress.indices.filter { h ->
|
||||
sea.fortress[h] > 0 && (0 until topo.EDGE_COUNT).any { h in topo.EDGE_HEXES[it] && sea.ships[it] == seat }
|
||||
}
|
||||
}
|
||||
|
||||
fun applyRaid(state: CatanState, seat: Int, move: RaidFortress): CatanState {
|
||||
val sea = state.sea!!
|
||||
val hex = state.topo.hexIndex.getValue(move.hex)
|
||||
val left = sea.fortress[hex] - 1
|
||||
var s = state.copy(resources = CatanRules.withCounts(state.resources, seat, RAID_COST, -1))
|
||||
val strength = sea.fortress.toMutableList().also { it[hex] = left }
|
||||
if (left > 0) {
|
||||
return s.copy(
|
||||
sea = sea.copy(fortress = strength),
|
||||
log = s.log + "Seat $seat raids the fortress at (${move.hex.q},${move.hex.r}); $left strength left.",
|
||||
)
|
||||
}
|
||||
val points = sea.raidPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList().also { it[seat] += FORTRESS_POINTS }
|
||||
s = s.copy(
|
||||
terrains = s.terrains.toMutableList().also { it[hex] = Terrain.DESERT },
|
||||
sea = sea.copy(fortress = strength, raidPoints = points),
|
||||
log = s.log + "Seat $seat takes the fortress at (${move.hex.q},${move.hex.r}): +$FORTRESS_POINTS victory points, " +
|
||||
"and the island is open.",
|
||||
)
|
||||
return CatanRules.checkWin(s)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wonders of Catan
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What each of the four stages of a wonder costs. */
|
||||
val WONDER_STAGES: List<Map<Resource, Int>> = listOf(
|
||||
mapOf(Resource.BRICK to 2, Resource.LUMBER to 1, Resource.GRAIN to 1),
|
||||
mapOf(Resource.BRICK to 1, Resource.ORE to 2, Resource.WOOL to 1),
|
||||
mapOf(Resource.ORE to 3, Resource.GRAIN to 1, Resource.LUMBER to 1),
|
||||
mapOf(Resource.ORE to 3, Resource.GRAIN to 2, Resource.BRICK to 1),
|
||||
)
|
||||
|
||||
/** The next stage [seat] could build, if the scenario has wonders and the seat has a city to raise it in. */
|
||||
private fun nextStage(state: CatanState, seat: Int): Map<Resource, Int>? {
|
||||
val stage = state.sea?.wonders?.getOrNull(seat) ?: return null
|
||||
if (stage >= WONDER_STAGES.size) return null
|
||||
if (state.buildings.none { it?.owner == seat && it.type == BuildingType.CITY }) return null
|
||||
return WONDER_STAGES[stage]
|
||||
}
|
||||
|
||||
/** The extra moves a scenario offers during play: raids and wonder stages. */
|
||||
fun scenarioMoves(state: CatanState, seat: Int): List<CatanMove> {
|
||||
val moves = mutableListOf<CatanMove>()
|
||||
if (CatanRules.affordable(state, seat, RAID_COST)) {
|
||||
raidTargets(state, seat).forEach { moves += RaidFortress(state.topo.HEXES[it]) }
|
||||
}
|
||||
if (CatanRules.affordable(state, seat, DIG_COST)) {
|
||||
digTargets(state, seat).forEach { moves += DigCanal(state.topo.HEXES[it]) }
|
||||
}
|
||||
nextStage(state, seat)?.let { if (CatanRules.affordable(state, seat, it)) moves += BuildWonder }
|
||||
return moves
|
||||
}
|
||||
|
||||
fun applyWonder(state: CatanState, seat: Int): CatanState {
|
||||
val sea = state.sea!!
|
||||
val stage = sea.wonders[seat] + 1
|
||||
val built = sea.wonders.toMutableList().also { it[seat] = stage }
|
||||
val s = state.copy(
|
||||
resources = CatanRules.withCounts(state.resources, seat, WONDER_STAGES[stage - 1], -1),
|
||||
sea = sea.copy(wonders = built),
|
||||
log = state.log + "Seat $seat builds stage $stage of a wonder.",
|
||||
)
|
||||
if (stage < WONDER_STAGES.size) return CatanRules.checkWin(s)
|
||||
return s.copy(
|
||||
phase = CatanPhase.GAME_OVER,
|
||||
winner = seat,
|
||||
log = s.log + "Seat $seat completes a wonder of Catan and wins.",
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The Forgotten Tribe
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** After a settlement on [vertex]: take any tribe chip lying on a hex beside it. */
|
||||
fun takeChips(state: CatanState, seat: Int, vertex: Int): CatanState {
|
||||
var s = state
|
||||
if (s.sea?.chips.isNullOrEmpty()) return s
|
||||
var gold = 0
|
||||
for (hex in state.topo.VERTEX_HEXES[vertex]) {
|
||||
val sea = s.sea!!
|
||||
val chip = sea.chips.getOrNull(hex) ?: continue
|
||||
if (chip == TribeChip.NONE) continue
|
||||
val rest = sea.chips.toMutableList().also { it[hex] = TribeChip.NONE }
|
||||
var next = sea.copy(chips = rest)
|
||||
val said = when (chip) {
|
||||
TribeChip.POINT -> {
|
||||
val points = next.chipPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList()
|
||||
points[seat] += 1
|
||||
next = next.copy(chipPoints = points)
|
||||
"a victory point"
|
||||
}
|
||||
TribeChip.HARBOUR -> {
|
||||
next = next.copy(harbourSeats = (next.harbourSeats + seat).distinct())
|
||||
"a harbour that trades 3:1"
|
||||
}
|
||||
else -> {
|
||||
gold += 2
|
||||
"two resources of its choice"
|
||||
}
|
||||
}
|
||||
s = s.copy(sea = next, log = s.log + "Seat $seat takes the tribe's chip beside it: $said.")
|
||||
}
|
||||
return if (gold > 0) owe(s, seat, gold) else s
|
||||
}
|
||||
|
||||
/** What happens to a new settlement of [seat]'s on [vertex] beyond the base game. */
|
||||
fun settled(state: CatanState, seat: Int, vertex: Int): CatanState =
|
||||
takeChips(claimIsland(state, seat, vertex), seat, vertex)
|
||||
|
||||
/** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */
|
||||
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
|
||||
val sea = state.sea ?: return state
|
||||
val island = state.topo.vertexIsland[vertex]
|
||||
if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state
|
||||
if (island < 0 || sea.isStart(island) || sea.islandBonus[island] != NO_PLAYER) return state
|
||||
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
|
||||
return state.copy(
|
||||
sea = sea.copy(islandBonus = bonus),
|
||||
|
||||
@@ -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,8 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
@@ -16,9 +18,18 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* - **Strong** is Knuth's idea one ply deep: among the codes still standing, it
|
||||
* plays the one whose *worst* possible answer leaves the smallest number of
|
||||
* candidates behind, so it cannot be given a reply that tells it little.
|
||||
*
|
||||
* The computer difficulty chosen at setup picks the level: Easy plays Casual,
|
||||
* Medium plays whatever the table's saved [MastermindOptions.level] says (Club
|
||||
* for a new game) and Hard plays Strong. [level] overrides all of that, which is
|
||||
* what the tests use.
|
||||
*/
|
||||
class MastermindAi(private val level: MastermindLevel? = null) :
|
||||
GameAi<MastermindState, MastermindMove> {
|
||||
class MastermindAi(
|
||||
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,
|
||||
@@ -26,7 +37,12 @@ class MastermindAi(private val level: MastermindLevel? = null) :
|
||||
legal: List<MastermindMove>,
|
||||
): MastermindMove {
|
||||
require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
val profile = profiles.of(seat)
|
||||
val setting = level ?: when (profile.level) {
|
||||
AiLevel.EASY -> MastermindLevel.CASUAL
|
||||
AiLevel.MEDIUM -> state.options.level
|
||||
AiLevel.HARD -> MastermindLevel.STRONG
|
||||
}
|
||||
|
||||
// Deterministic for a given position, so a host and a client watching
|
||||
// the same game agree on what the computer did.
|
||||
|
||||
@@ -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,15 +30,13 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.AiDifficulty
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||
import org.prolibertate.games.game.checkers.CheckersLevel
|
||||
import org.prolibertate.games.game.checkers.CheckersOptions
|
||||
import org.prolibertate.games.game.chess.ChessLevel
|
||||
import org.prolibertate.games.game.chess.ChessOptions
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||
@@ -56,15 +54,14 @@ import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||
import org.prolibertate.games.game.morris.MorrisLevel
|
||||
import org.prolibertate.games.game.morris.MorrisOptions
|
||||
import org.prolibertate.games.game.pirates.PiratesLevel
|
||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.SeaScenario
|
||||
import org.prolibertate.games.game.catan.BaseScenario
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
import org.prolibertate.games.game.tayu.TayuOptions
|
||||
@@ -88,7 +85,7 @@ fun GameSetupScreen(
|
||||
playerName: String,
|
||||
onBack: () -> Unit,
|
||||
onPlayOffline: (TableConfig, Int) -> Unit,
|
||||
onHostOnline: (String) -> Unit,
|
||||
onHostOnline: (String, AiDifficulty) -> Unit,
|
||||
/** A game of this kind left half-played, if there is one. */
|
||||
saved: SavedMatch? = null,
|
||||
onResume: (SavedMatch) -> Unit = {},
|
||||
@@ -124,6 +121,7 @@ fun GameSetupScreen(
|
||||
// Pirates and Bulgars is not symmetrical, so which side you take is the
|
||||
// first thing about the game rather than a detail of it.
|
||||
var playPirates by remember { mutableStateOf(false) }
|
||||
var aiDifficulty by remember { mutableStateOf(AiDifficulty.MEDIUM) }
|
||||
|
||||
val optionsJson = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||
@@ -243,6 +241,22 @@ fun GameSetupScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
if (!solo) {
|
||||
ChipRow(
|
||||
label = "Computer difficulty",
|
||||
values = AiDifficulty.entries.toList(),
|
||||
selected = aiDifficulty,
|
||||
display = { it.label },
|
||||
onSelect = { aiDifficulty = it },
|
||||
)
|
||||
if (aiDifficulty == AiDifficulty.MIXED) {
|
||||
Text(
|
||||
"Each computer player gets its own level.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Two games let the local player pick a seat rather than always
|
||||
// taking the first one: chess by colour, and Pirates and Bulgars by
|
||||
// which side of a very lopsided fight they fancy.
|
||||
@@ -259,13 +273,13 @@ fun GameSetupScreen(
|
||||
}
|
||||
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
||||
onPlayOffline(
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat, aiDifficulty),
|
||||
localSeat,
|
||||
)
|
||||
}
|
||||
if (!solo) {
|
||||
PrimaryAction(text = "Host a game for others to join") {
|
||||
onHostOnline(optionsJson)
|
||||
onHostOnline(optionsJson, aiDifficulty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +293,7 @@ private fun offlineConfig(
|
||||
seatCount: Int,
|
||||
playerName: String,
|
||||
localSeat: Int = 0,
|
||||
aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
|
||||
): TableConfig = TableConfig(
|
||||
gameId = gameId,
|
||||
seats = (0 until seatCount).map { seat ->
|
||||
@@ -292,6 +307,7 @@ private fun offlineConfig(
|
||||
optionsJson = optionsJson,
|
||||
// Seeded from the clock so consecutive games are not identical.
|
||||
seed = System.currentTimeMillis(),
|
||||
aiDifficulty = aiDifficulty,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -359,47 +375,50 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
|
||||
else -> seat
|
||||
}
|
||||
|
||||
/** The targets the setup screen offers for a Katan rule set. */
|
||||
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) {
|
||||
// Five is a short game for trying things out; ten is the published target.
|
||||
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
|
||||
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
|
||||
/** The targets the setup screen offers for a Katan game with [expansions] on. */
|
||||
internal fun catanPointsChoices(expansions: Set<CatanExpansion>): List<Int> = when {
|
||||
// Both: Cities & Knights' long game plus the new-island points.
|
||||
expansions.size > 1 -> listOf(10, 12, 14, 16)
|
||||
// Seafarers adds two points for each new island, so the target is higher.
|
||||
CatanRuleSet.SEAFARERS -> listOf(8, 10, 12, 14)
|
||||
CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14)
|
||||
// Cities & Knights is played to thirteen, which is also why it runs longer.
|
||||
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> listOf(8, 10, 13, 15)
|
||||
// Five is a short game for trying things out; ten is the published target.
|
||||
else -> listOf(5, 8, 10, 12)
|
||||
}
|
||||
|
||||
/** The published target for a rule set. */
|
||||
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
|
||||
CatanRuleSet.BASE -> 10
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
|
||||
CatanRuleSet.SEAFARERS -> 12
|
||||
/** The published target for a game with [expansions] on. */
|
||||
internal fun catanDefaultPoints(expansions: Set<CatanExpansion>): Int = when {
|
||||
expansions.size > 1 -> 14
|
||||
CatanExpansion.SEAFARERS in expansions -> 12
|
||||
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> 13
|
||||
else -> 10
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] switched to [ruleSet]. The target moves to the new rule set's own
|
||||
* [options] with [expansion] switched on or off. The target moves to the new combination's own
|
||||
* default unless the one already chosen is on its menu too.
|
||||
*/
|
||||
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions =
|
||||
options.copy(
|
||||
ruleSet = ruleSet,
|
||||
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) }
|
||||
?: catanDefaultPoints(ruleSet),
|
||||
internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions {
|
||||
val expansions = if (on) options.expansions + expansion else options.expansions - expansion
|
||||
return options.copy(
|
||||
expansions = expansions,
|
||||
pointsToWin = options.pointsToWin.takeIf {
|
||||
it in catanPointsChoices(expansions) && it != catanDefaultPoints(options.expansions)
|
||||
} ?: catanDefaultPoints(expansions),
|
||||
)
|
||||
|
||||
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||
CatanRuleSet.BASE -> "Base game"
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||
CatanRuleSet.SEAFARERS -> "Seafarers"
|
||||
}
|
||||
|
||||
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||
CatanRuleSet.BASE ->
|
||||
"Settle the island, trade, and play development cards. Longest Road and Largest Army score."
|
||||
CatanRuleSet.CITIES_AND_KNIGHTS ->
|
||||
private fun catanExpansionName(expansion: CatanExpansion): String = when (expansion) {
|
||||
CatanExpansion.CITIES_AND_KNIGHTS -> "Cities & Knights"
|
||||
CatanExpansion.SEAFARERS -> "Seafarers"
|
||||
}
|
||||
|
||||
private fun catanExpansionBlurb(expansion: CatanExpansion): String = when (expansion) {
|
||||
CatanExpansion.CITIES_AND_KNIGHTS ->
|
||||
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
|
||||
"cards and metropolises. No development cards; the robber sleeps until the first attack."
|
||||
CatanRuleSet.SEAFARERS ->
|
||||
CatanExpansion.SEAFARERS ->
|
||||
"Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " +
|
||||
"and the first settlement on each new island scores two extra points."
|
||||
}
|
||||
@@ -407,14 +426,33 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
|
||||
@Composable
|
||||
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Rules",
|
||||
values = CatanRuleSet.entries,
|
||||
selected = options.ruleSet,
|
||||
display = ::catanRuleSetName,
|
||||
onSelect = { onChange(catanSwitchRules(options, it)) },
|
||||
)
|
||||
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall)
|
||||
CatanExpansion.entries.forEach { expansion ->
|
||||
ToggleRow(
|
||||
title = catanExpansionName(expansion),
|
||||
subtitle = catanExpansionBlurb(expansion),
|
||||
checked = expansion in options.expansions,
|
||||
onChange = { onChange(catanToggleExpansion(options, expansion, it)) },
|
||||
)
|
||||
}
|
||||
if (options.seafarers) {
|
||||
ChipRow(
|
||||
label = "Map",
|
||||
values = SeaScenario.entries,
|
||||
selected = options.scenario,
|
||||
display = { it.title },
|
||||
onSelect = { onChange(options.copy(scenario = it)) },
|
||||
)
|
||||
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
ChipRow(
|
||||
label = "Island",
|
||||
values = BaseScenario.entries,
|
||||
selected = options.baseScenario,
|
||||
display = { it.title },
|
||||
onSelect = { onChange(options.copy(baseScenario = it)) },
|
||||
)
|
||||
Text(options.baseScenario.blurb, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(3, 4),
|
||||
@@ -424,7 +462,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
|
||||
)
|
||||
ChipRow(
|
||||
label = "Game to",
|
||||
values = catanPointsChoices(options.ruleSet),
|
||||
values = catanPointsChoices(options.expansions),
|
||||
selected = options.pointsToWin,
|
||||
display = { "$it points" },
|
||||
onSelect = { onChange(options.copy(pointsToWin = it)) },
|
||||
@@ -765,13 +803,6 @@ private fun ChessOptionsEditor(
|
||||
display = { if (it) "White" else "Black" },
|
||||
onSelect = onColour,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = ChessLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Fifty-move rule",
|
||||
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
|
||||
@@ -795,13 +826,6 @@ private fun ChessOptionsEditor(
|
||||
@Composable
|
||||
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = CheckersLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Flying kings",
|
||||
subtitle = "A king slides the length of a diagonal and takes from a distance, " +
|
||||
@@ -836,13 +860,6 @@ private fun BackgammonOptionsEditor(
|
||||
onChange: (BackgammonOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = BackgammonLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Count gammons",
|
||||
subtitle = "A loser who bore nothing off has lost double, and one still on the " +
|
||||
@@ -864,13 +881,6 @@ private fun MastermindOptionsEditor(
|
||||
onChange: (MastermindOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MastermindLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Colours",
|
||||
values = listOf(6, 8),
|
||||
@@ -935,13 +945,6 @@ private fun PiratesOptionsEditor(
|
||||
display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
|
||||
onSelect = onSide,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = PiratesLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "A pirate who can take must take",
|
||||
subtitle = "The rule that huffing exists to enforce, applied directly",
|
||||
@@ -967,13 +970,6 @@ private fun PiratesOptionsEditor(
|
||||
@Composable
|
||||
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MorrisLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Pieces each",
|
||||
values = listOf(3, 6, 9),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,7 +33,9 @@ import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.NO_PLAYER
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.SeaRules
|
||||
import org.prolibertate.games.game.catan.Terrain
|
||||
import org.prolibertate.games.game.catan.TribeChip
|
||||
|
||||
/**
|
||||
* The island, drawn and tappable.
|
||||
@@ -156,6 +158,39 @@ private fun DrawScope.drawHexes(
|
||||
|
||||
val number = state.numbers[hex]
|
||||
if (number != 0) drawToken(number, centre, board, style, measurer)
|
||||
if (state.terrains[hex] == Terrain.FOG) {
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = "?",
|
||||
at = centre,
|
||||
fontPx = board.radius * 0.9f,
|
||||
color = style.hexEdge.copy(alpha = 0.7f),
|
||||
weight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
val sea = state.sea
|
||||
val note = when {
|
||||
state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth"
|
||||
sea != null && sea.canalNumbers.getOrElse(hex) { 0 } != 0 && state.terrains[hex] == Terrain.DESERT ->
|
||||
"dug ${sea.canalUnits.getOrElse(hex) { 0 }}/${SeaRules.CANAL_TILE_UNITS}"
|
||||
state.terrains[hex] == Terrain.FORTRESS && sea != null -> "fort ${sea.fortress.getOrElse(hex) { 0 }}"
|
||||
else -> when (sea?.chips?.getOrNull(hex)) {
|
||||
TribeChip.POINT -> "+1 VP"
|
||||
TribeChip.HARBOUR -> "3:1"
|
||||
TribeChip.GOLD -> "gold"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (note != null) {
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = note,
|
||||
at = Pt(centre.x, centre.y + board.radius * 0.62f),
|
||||
fontPx = board.radius * 0.26f,
|
||||
color = style.tokenInk,
|
||||
weight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,9 @@ object CatanHudGlyphs {
|
||||
BuildKind.WALL -> "▦"
|
||||
BuildKind.KNIGHT -> "♞"
|
||||
BuildKind.SHIP -> "⛵"
|
||||
BuildKind.RAID -> "⚔"
|
||||
BuildKind.DIG -> "⛏"
|
||||
BuildKind.WONDER -> "✦"
|
||||
}
|
||||
|
||||
fun label(kind: BuildKind): String = when (kind) {
|
||||
@@ -182,6 +185,9 @@ object CatanHudGlyphs {
|
||||
BuildKind.WALL -> CatanText.WALL
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
BuildKind.RAID -> CatanText.RAID
|
||||
BuildKind.DIG -> CatanText.DIG
|
||||
BuildKind.WONDER -> CatanText.WONDER
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +214,7 @@ fun HudConfirmButtons(
|
||||
fun ZoomPanBox(
|
||||
modifier: Modifier = Modifier,
|
||||
minScale: Float = 1f,
|
||||
maxScale: Float = 3f,
|
||||
maxScale: Float = 6f,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
|
||||
@@ -6,7 +6,10 @@ import org.prolibertate.games.game.catan.ActivateKnight
|
||||
import org.prolibertate.games.game.catan.BuildCity
|
||||
import org.prolibertate.games.game.catan.BuildRoad
|
||||
import org.prolibertate.games.game.catan.BuildShip
|
||||
import org.prolibertate.games.game.catan.BuildWonder
|
||||
import org.prolibertate.games.game.catan.DigCanal
|
||||
import org.prolibertate.games.game.catan.MovePirate
|
||||
import org.prolibertate.games.game.catan.RaidFortress
|
||||
import org.prolibertate.games.game.catan.MoveShip
|
||||
import org.prolibertate.games.game.catan.Topology
|
||||
import org.prolibertate.games.game.catan.BuildSettlement
|
||||
@@ -67,13 +70,38 @@ enum class BuildKind {
|
||||
ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
|
||||
|
||||
/** Seafarers: a ship, laid on a sea edge. */
|
||||
SHIP;
|
||||
SHIP,
|
||||
|
||||
/** Pirate Islands: raid a fortress; the target is the fortress hex. */
|
||||
RAID,
|
||||
|
||||
/** Great Canal: dig a canal tile; the target is the tile. */
|
||||
DIG,
|
||||
|
||||
/** Wonders of Catan: the next stage of the wonder, which needs no tap on the board. */
|
||||
WONDER;
|
||||
|
||||
companion object {
|
||||
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
|
||||
|
||||
/** Seafarers adds the ship, listed after the road it sails beside. */
|
||||
val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, SETTLEMENT, CITY)
|
||||
|
||||
/** The kinds a table's build menu lists: the scenario's own raid or wonder follows the usual ones. */
|
||||
fun of(state: CatanState): List<BuildKind> {
|
||||
val sea = state.sea
|
||||
val extras = buildList {
|
||||
if (sea != null && sea.fortress.isNotEmpty()) add(RAID)
|
||||
if (sea != null && sea.canalUnits.isNotEmpty()) add(DIG)
|
||||
if (sea != null && sea.wonders.isNotEmpty()) add(WONDER)
|
||||
}
|
||||
val usual = when {
|
||||
state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, DIG, WONDER)
|
||||
state.isSea -> SEAFARERS
|
||||
else -> BASE
|
||||
}
|
||||
return usual + extras
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +280,13 @@ object CatanInteraction {
|
||||
return when (building) {
|
||||
BuildKind.ROAD -> BoardTargets(edges = roads)
|
||||
BuildKind.SHIP -> BoardTargets(edges = ships)
|
||||
BuildKind.RAID -> BoardTargets(
|
||||
hexes = legal.filterIsInstance<RaidFortress>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
|
||||
)
|
||||
BuildKind.DIG -> BoardTargets(
|
||||
hexes = legal.filterIsInstance<DigCanal>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
|
||||
)
|
||||
BuildKind.WONDER -> BoardTargets()
|
||||
BuildKind.SETTLEMENT ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
|
||||
|
||||
@@ -519,6 +554,7 @@ object CatanInteraction {
|
||||
val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
|
||||
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
||||
val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
|
||||
legal.firstOrNull { (it is RaidFortress && it.hex == coord) || (it is DigCanal && it.hex == coord) }?.let { return TapOutcome.Submit(it) }
|
||||
return when {
|
||||
moves.isNotEmpty() -> if (moves.size == 1) {
|
||||
TapOutcome.Submit(moves.first())
|
||||
@@ -586,6 +622,9 @@ object CatanInteraction {
|
||||
if (legal.any { it is BuildShip }) add(BuildKind.SHIP)
|
||||
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
|
||||
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
||||
if (legal.any { it is RaidFortress }) add(BuildKind.RAID)
|
||||
if (legal.any { it is DigCanal }) add(BuildKind.DIG)
|
||||
if (legal.any { it is BuildWonder }) add(BuildKind.WONDER)
|
||||
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
|
||||
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
|
||||
}
|
||||
|
||||
@@ -256,6 +256,8 @@ fun CatanPlayerCard(
|
||||
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
|
||||
}
|
||||
if (ck.merchant?.seat == seat) add("Merchant")
|
||||
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
|
||||
addAll(seaAwards(state, seat, compact))
|
||||
}
|
||||
CardLine(
|
||||
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
|
||||
@@ -280,6 +282,8 @@ fun CatanPlayerCard(
|
||||
val awards = buildList {
|
||||
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
|
||||
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
|
||||
addAll(seaAwards(state, seat, compact))
|
||||
}
|
||||
// Always drawn, so the card does not grow when somebody takes an award.
|
||||
CardLine(
|
||||
@@ -598,6 +602,9 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.WALL -> CatanText.WALL
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
BuildKind.RAID -> CatanText.RAID
|
||||
BuildKind.DIG -> CatanText.DIG
|
||||
BuildKind.WONDER -> CatanText.WONDER
|
||||
}
|
||||
|
||||
private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||
@@ -607,6 +614,9 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.WALL -> CatanText.WALL_COST
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
|
||||
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
|
||||
BuildKind.RAID -> CatanText.RAID_COST
|
||||
BuildKind.DIG -> CatanText.DIG_COST
|
||||
BuildKind.WONDER -> CatanText.WONDER_COST
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -634,3 +644,14 @@ fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Seafarers scenario holdings worth a word on a player's card: cloth, and a tribe harbour. */
|
||||
private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List<String> {
|
||||
val sea = state.sea ?: return emptyList()
|
||||
return buildList {
|
||||
if (sea.cloth.isNotEmpty()) add(if (compact) "${sea.cloth[seat]}c" else "Cloth ${sea.cloth[seat]}")
|
||||
if (sea.canalDug.isNotEmpty()) add(if (compact) "${sea.canalDug[seat]}⛏" else "Dug ${sea.canalDug[seat]}")
|
||||
if (sea.wonders.isNotEmpty()) add(if (compact) "W${sea.wonders[seat]}" else "Wonder ${sea.wonders[seat]}/4")
|
||||
if (seat in sea.harbourSeats) add(if (compact) "3:1" else "Harbour 3:1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.catan.AcceptTrade
|
||||
import org.prolibertate.games.game.catan.AqueductChore
|
||||
import org.prolibertate.games.game.catan.BuildWonder
|
||||
import org.prolibertate.games.game.catan.BuyDevCard
|
||||
import org.prolibertate.games.game.catan.CatanCk
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
@@ -159,6 +160,14 @@ fun CatanScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// A wonder stage needs no tap on the board: choosing it from the menu builds it.
|
||||
LaunchedEffect(building) {
|
||||
if (building == BuildKind.WONDER) {
|
||||
legal.firstOrNull { it is BuildWonder }?.let { controller.submit(it) }
|
||||
building = null
|
||||
}
|
||||
}
|
||||
|
||||
val submit: (CatanMove) -> Unit = { move ->
|
||||
controller.submit(move)
|
||||
building = null
|
||||
@@ -197,11 +206,7 @@ fun CatanScreen(
|
||||
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
|
||||
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
||||
val actions = CatanActions(
|
||||
buildKinds = when {
|
||||
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
|
||||
current.isSea -> BuildKind.SEAFARERS
|
||||
else -> BuildKind.BASE
|
||||
},
|
||||
buildKinds = BuildKind.of(current),
|
||||
ck = current.isCk,
|
||||
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
|
||||
canPlayProgress = progressHand.any { it.playable },
|
||||
@@ -374,7 +379,8 @@ fun CatanScreen(
|
||||
players(false)
|
||||
if (current.isCk) CatanBarbarians(current, style = style)
|
||||
promptRow()
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
// Pinch to zoom and drag to pan, as in landscape: a wide Seafarers map is small at fit.
|
||||
ZoomPanBox(Modifier.weight(1f).fillMaxWidth()) {
|
||||
board(Modifier)
|
||||
}
|
||||
handAndActions()
|
||||
|
||||
@@ -124,6 +124,12 @@ private val LightStyle = CatanStyle(
|
||||
// Seafarers. Sea hexes are drawn as a faint wash over the water; gold is a rich yellow.
|
||||
Terrain.SEA to Color(0xFF3A7CA5),
|
||||
Terrain.GOLD to Color(0xFFF2B01E),
|
||||
// Land not yet found: a pale grey bank of fog with a question mark.
|
||||
Terrain.FOG to Color(0xFFB9C2C9),
|
||||
// Cloth villages: a dyed-cloth purple.
|
||||
Terrain.VILLAGE to Color(0xFF9B6BB5),
|
||||
// A pirate fortress: dark stone.
|
||||
Terrain.FORTRESS to Color(0xFF4A4F57),
|
||||
),
|
||||
hexEdge = Color(0x55000000),
|
||||
token = Color(0xFFF7EFD9),
|
||||
|
||||
@@ -74,6 +74,9 @@ object CatanText {
|
||||
Terrain.DESERT -> "Desert"
|
||||
Terrain.SEA -> "Sea"
|
||||
Terrain.GOLD -> "Gold field"
|
||||
Terrain.FOG -> "Fog"
|
||||
Terrain.VILLAGE -> "Village"
|
||||
Terrain.FORTRESS -> "Pirate fortress"
|
||||
}
|
||||
|
||||
fun devCard(type: DevCardType): String = when (type) {
|
||||
@@ -113,6 +116,12 @@ object CatanText {
|
||||
const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)."
|
||||
const val SHIP = "Ship"
|
||||
const val SHIP_COST_TEXT = "1 lumber + 1 wool"
|
||||
const val RAID = "Raid"
|
||||
const val RAID_COST = "1 ore + 1 wool + 1 grain"
|
||||
const val DIG = "Dig"
|
||||
const val DIG_COST = "1 brick + 1 ore"
|
||||
const val WONDER = "Wonder"
|
||||
const val WONDER_COST = "next stage"
|
||||
const val GOLD_TITLE = "Gold field"
|
||||
const val GOLD_TAKE = "Take"
|
||||
const val ROLL = "Roll dice"
|
||||
@@ -193,7 +202,7 @@ object CatanText {
|
||||
/** Seafarers: a ship has been picked to sail, and its destination is awaited. */
|
||||
movingShip: Boolean = false,
|
||||
): String {
|
||||
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
|
||||
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed, movingShip)
|
||||
val me = seats.me
|
||||
val onClock = CatanRules.currentSeat(state)
|
||||
val mine = onClock == me
|
||||
@@ -246,6 +255,8 @@ object CatanText {
|
||||
!mine -> "$turn is playing…"
|
||||
movingShip -> "Tap the sea edge to sail the ship to."
|
||||
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
|
||||
building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it."
|
||||
building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit."
|
||||
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||
@@ -400,6 +411,7 @@ object CatanText {
|
||||
building: BuildKind?,
|
||||
movingKnight: Boolean,
|
||||
armed: ProgressType?,
|
||||
movingShip: Boolean,
|
||||
): String {
|
||||
val me = seats.me
|
||||
val onClock = CatanRules.currentSeat(state)
|
||||
@@ -408,7 +420,12 @@ object CatanText {
|
||||
val ck = state.ck!!
|
||||
|
||||
return when (state.phase) {
|
||||
CatanPhase.GOLD -> ""
|
||||
CatanPhase.GOLD ->
|
||||
if (mine) {
|
||||
"Gold! Choose ${state.sea?.gold?.firstOrNull()?.count ?: 1} resource cards."
|
||||
} else {
|
||||
"${seats.label(onClock ?: state.turn)} is choosing from the gold…"
|
||||
}
|
||||
|
||||
CatanPhase.GAME_OVER -> {
|
||||
val winner = state.winner
|
||||
@@ -434,6 +451,7 @@ object CatanText {
|
||||
CatanPhase.ROBBER -> when {
|
||||
!mine -> "$turn is moving the robber…"
|
||||
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
|
||||
state.isSea -> SEA_ROBBER
|
||||
else -> "Move the robber. Tap a hex."
|
||||
}
|
||||
|
||||
@@ -441,6 +459,10 @@ object CatanText {
|
||||
!mine -> "$turn is playing…"
|
||||
armed != null -> armedPrompt(armed)
|
||||
movingKnight -> "Tap where the knight should go."
|
||||
movingShip -> "Tap the sea edge to sail the ship to."
|
||||
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
|
||||
building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it."
|
||||
building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit."
|
||||
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||
|
||||
@@ -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: Set<CatanExpansion>, cap: Int) {
|
||||
val options = CatanOptions(
|
||||
playerCount = 3,
|
||||
pointsToWin = when {
|
||||
CatanExpansion.CITIES_AND_KNIGHTS in rules -> 13
|
||||
CatanExpansion.SEAFARERS in rules -> 12
|
||||
else -> 10
|
||||
},
|
||||
expansions = rules,
|
||||
)
|
||||
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) {
|
||||
for (style in AiStyle.entries) {
|
||||
val tableSeed = tableSeedFor(difficulty, style)
|
||||
val state = play(options, 40L + style.ordinal, AiProfiles(difficulty, tableSeed), cap)
|
||||
assertTrue(
|
||||
"$rules $difficulty $style: the game never got past setup",
|
||||
state.turnNumber > 3 || CatanRules.isFinished(state),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play the base game`() = forEveryProfile(emptySet(), 1_500)
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play Cities and Knights`() =
|
||||
forEveryProfile(setOf(CatanExpansion.CITIES_AND_KNIGHTS), 1_500)
|
||||
|
||||
@Test
|
||||
fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(setOf(CatanExpansion.SEAFARERS), 1_500)
|
||||
|
||||
@Test
|
||||
fun `a mixed table of four finishes a base game`() {
|
||||
val state = play(CatanOptions(playerCount = 4), 77L, AiProfiles(AiDifficulty.MIXED, 5L), 12_000)
|
||||
assertEquals(CatanPhase.GAME_OVER, state.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the default profile plays exactly as an explicit medium table does`() {
|
||||
for (rules in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
|
||||
val options = CatanOptions(playerCount = 3, pointsToWin = 10, expansions = rules)
|
||||
val plain = play(options, 9L, AiProfiles.DEFAULT, 600)
|
||||
val medium = play(options, 9L, AiProfiles(AiDifficulty.MEDIUM, 1234L), 600)
|
||||
assertEquals("$rules", plain, medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** The base-island scenarios: First Island's fixed board and the Harbormaster. */
|
||||
class CatanBaseScenarioTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun game(
|
||||
scenario: BaseScenario,
|
||||
seed: Long = 5L,
|
||||
expansions: Set<CatanExpansion> = emptySet(),
|
||||
players: Int = 3,
|
||||
) = CatanRules.initialState(config(CatanOptions(players, 10, expansions, baseScenario = scenario), seed))
|
||||
|
||||
@Test
|
||||
fun `first island is the same board for every seed and the random map is not`() {
|
||||
val a = game(BaseScenario.FIRST_ISLAND, 1L)
|
||||
val b = game(BaseScenario.FIRST_ISLAND, 99L)
|
||||
assertEquals(a.terrains, b.terrains)
|
||||
assertEquals(a.numbers, b.numbers)
|
||||
assertEquals(a.ports, b.ports)
|
||||
assertNotEquals(game(BaseScenario.RANDOM_MAP, 1L).terrains, game(BaseScenario.RANDOM_MAP, 99L).terrains)
|
||||
// The deck and the seating still follow the table's own seed.
|
||||
assertNotEquals(a.devDeck, b.devDeck)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save from before base scenarios reads as a random map`() {
|
||||
val old = """{"playerCount":3,"pointsToWin":10,"expansions":[],"scenario":"NEW_SHORES"}"""
|
||||
assertEquals(BaseScenario.RANDOM_MAP, json.decodeFromString<CatanOptions>(old).baseScenario)
|
||||
val options = CatanOptions(3, 10, baseScenario = BaseScenario.HARBORMASTER)
|
||||
assertEquals(options, json.decodeFromString<CatanOptions>(json.encodeToString(options)))
|
||||
}
|
||||
|
||||
/** Vertices of the harbours, one per harbour, in board order. */
|
||||
private fun CatanState.harbourVertices(): List<Int> = ports.map { topo.EDGE_VERTICES[it.edge].first }
|
||||
|
||||
private fun CatanState.with(seat: Int, vertex: Int, type: BuildingType) =
|
||||
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) })
|
||||
|
||||
@Test
|
||||
fun `three harbour points make a harbormaster and more takes the title`() {
|
||||
var s = game(BaseScenario.HARBORMASTER).copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
|
||||
val v = s.harbourVertices()
|
||||
s = s.with(0, v[0], BuildingType.SETTLEMENT).with(0, v[1], BuildingType.SETTLEMENT)
|
||||
assertEquals(2, CatanRules.harbourPoints(s, 0))
|
||||
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
|
||||
|
||||
// A city counts two: 1 + 2 = 3.
|
||||
s = CatanRules.refreshHarbormaster(s.with(0, v[1], BuildingType.CITY))
|
||||
assertEquals(3, CatanRules.harbourPoints(s, 0))
|
||||
assertEquals(0, s.harbormaster)
|
||||
assertEquals(CatanRules.publicVictoryPoints(s.copy(harbormaster = null))[0] + 2, CatanRules.publicVictoryPoints(s)[0])
|
||||
|
||||
// Equal points do not take it; strictly more does.
|
||||
s = CatanRules.refreshHarbormaster(
|
||||
s.with(1, v[2], BuildingType.SETTLEMENT).with(1, v[3], BuildingType.SETTLEMENT).with(1, v[4], BuildingType.SETTLEMENT),
|
||||
)
|
||||
assertEquals(0, s.harbormaster)
|
||||
s = CatanRules.refreshHarbormaster(s.with(1, v[5], BuildingType.SETTLEMENT))
|
||||
assertEquals(1, s.harbormaster)
|
||||
assertTrue(s.log.any { "taken the title harbormaster from seat 0" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title does not exist in other scenarios or in a Seafarers game`() {
|
||||
var s = game(BaseScenario.RANDOM_MAP).copy(phase = CatanPhase.PLAYING)
|
||||
val v = s.harbourVertices()
|
||||
for (i in 0..3) s = s.with(0, v[i], BuildingType.SETTLEMENT)
|
||||
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engines finish games on the base scenarios, with and without Cities and Knights`() {
|
||||
var titles = 0
|
||||
for (scenario in listOf(BaseScenario.FIRST_ISLAND, BaseScenario.HARBORMASTER)) {
|
||||
for (expansions in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
|
||||
for ((players, seed) in listOf(3 to 31L, 4 to 32L)) {
|
||||
val opts = CatanOptions(players, if (expansions.isEmpty()) 10 else 13, expansions, baseScenario = scenario)
|
||||
var state = CatanRules.initialState(config(opts, seed))
|
||||
val ai = CatanAi()
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("$scenario $expansions seed $seed ran long", moves++ < 20_000)
|
||||
val seat = CatanRules.currentSeat(state)!!
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||
}
|
||||
if (state.harbormaster != null) titles++
|
||||
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue("nobody ever became harbormaster", titles > 0)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ class CatanCkRulesTest {
|
||||
CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 13,
|
||||
ruleSet = if (ck) CatanRuleSet.CITIES_AND_KNIGHTS else CatanRuleSet.BASE,
|
||||
expansions = if (ck) setOf(CatanExpansion.CITIES_AND_KNIGHTS) else emptySet(),
|
||||
),
|
||||
),
|
||||
seed = seed,
|
||||
@@ -138,7 +138,7 @@ class CatanCkRulesTest {
|
||||
val base = CatanRules.initialState(config(ck = false))
|
||||
assertNull(base.ck)
|
||||
assertEquals(25, base.devDeck.size)
|
||||
assertEquals(CatanRuleSet.BASE, base.options.ruleSet)
|
||||
assertEquals(emptySet<CatanExpansion>(), base.options.expansions)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -157,7 +157,21 @@ class CatanCkRulesTest {
|
||||
fun `the options without a rule set still read as the base game`() {
|
||||
val old = """{"playerCount":4,"pointsToWin":10}"""
|
||||
val options = Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(old)
|
||||
assertEquals(CatanRuleSet.BASE, options.ruleSet)
|
||||
assertEquals(emptySet<CatanExpansion>(), options.expansions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `options saved with a rule set still read as the expansion they named`() {
|
||||
val read = { text: String -> Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(text) }
|
||||
assertEquals(
|
||||
setOf(CatanExpansion.CITIES_AND_KNIGHTS),
|
||||
read("""{"playerCount":3,"pointsToWin":13,"ruleSet":"CITIES_AND_KNIGHTS"}""").expansions,
|
||||
)
|
||||
assertEquals(
|
||||
setOf(CatanExpansion.SEAFARERS),
|
||||
read("""{"playerCount":3,"pointsToWin":12,"ruleSet":"SEAFARERS"}""").expansions,
|
||||
)
|
||||
assertEquals(emptySet<CatanExpansion>(), read("""{"ruleSet":"BASE"}""").expansions)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -152,7 +152,7 @@ class CatanCkSelfPlayTest {
|
||||
fun `three engines play Cities and Knights to a finish`() {
|
||||
val reports = (1L..12L).map { seed ->
|
||||
val (state, report) = playOut(
|
||||
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
CatanOptions(playerCount = 3, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
|
||||
seed,
|
||||
)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
@@ -171,7 +171,7 @@ class CatanCkSelfPlayTest {
|
||||
fun `four engines play Cities and Knights to a finish`() {
|
||||
val reports = (101L..108L).map { seed ->
|
||||
val (state, report) = playOut(
|
||||
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
CatanOptions(playerCount = 4, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
|
||||
seed,
|
||||
)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
|
||||
@@ -115,8 +115,9 @@ class CatanRulesTest {
|
||||
// -- Setup ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `setup runs in snake order and then seat zero leads`() {
|
||||
fun `setup runs in snake order from the first seat and then that seat leads`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
val first = state.firstSeat
|
||||
val order = mutableListOf<Int>()
|
||||
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||
val seat = CatanRules.currentSeat(state)!!
|
||||
@@ -126,12 +127,21 @@ class CatanRulesTest {
|
||||
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
|
||||
)
|
||||
}
|
||||
assertEquals(listOf(0, 1, 2, 2, 1, 0), order)
|
||||
val forward = (0 until 3).map { (first + it) % 3 }
|
||||
assertEquals(forward + forward.reversed(), order)
|
||||
assertEquals(CatanPhase.AWAIT_ROLL, state.phase)
|
||||
assertEquals(0, state.turn)
|
||||
assertEquals(first, state.turn)
|
||||
assertEquals(1, state.turnNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first seat comes from the seed and varies between tables`() {
|
||||
val firsts = (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat }
|
||||
assertEquals(firsts, (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat })
|
||||
assertEquals(setOf(0, 1, 2), firsts.toSet())
|
||||
assertEquals(firsts[0], CatanRules.initialState(config(seed = 1L)).turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the second settlement earns one card from each adjacent land`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** Cities & Knights played on a Seafarers map. */
|
||||
class CatanSeaCkTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun options(scenario: SeaScenario = SeaScenario.NEW_SHORES, players: Int = 3) = CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 14,
|
||||
expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS),
|
||||
scenario = scenario,
|
||||
)
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun start(scenario: SeaScenario = SeaScenario.NEW_SHORES, seed: Long = 7L) =
|
||||
CatanRules.initialState(config(options(scenario), seed))
|
||||
|
||||
private fun CatanState.city(vertex: Int, seat: Int) =
|
||||
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(BuildingType.CITY, seat) })
|
||||
|
||||
@Test
|
||||
fun `both expansions switch on together and the state carries both`() {
|
||||
val s = start()
|
||||
assertNotNull(s.ck)
|
||||
assertNotNull(s.sea)
|
||||
assertTrue(s.devDeck.isEmpty())
|
||||
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), s.options.expansions)
|
||||
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ship is on offer in play, and a knight can stand at the end of a ship route on land`() {
|
||||
val s0 = start()
|
||||
val topo = s0.topo
|
||||
// A coastal home town with a sea edge leaving it.
|
||||
val town = (0 until topo.VERTEX_COUNT).first { v ->
|
||||
topo.vertexIsland[v] == s0.sea!!.home &&
|
||||
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] && !topo.edgeLand[it] }
|
||||
}
|
||||
val s = s0.city(town, 0).copy(
|
||||
phase = CatanPhase.PLAYING, turn = 0, turnNumber = 3,
|
||||
resources = List(3) { Resource.entries.associateWith { 5 } },
|
||||
)
|
||||
val moves = CatanRules.legalMoves(s, 0)
|
||||
assertTrue("no ship on offer", moves.any { it is BuildShip })
|
||||
assertTrue("sea vertices are never knight posts", moves.filterIsInstance<HireKnight>().all { topo.vertexLand[it.vertex] })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gold field pays its owner a choice after the roll, then play goes on`() {
|
||||
val s0 = start()
|
||||
val topo = s0.topo
|
||||
val gold = s0.terrains.indexOf(Terrain.GOLD)
|
||||
val number = s0.numbers[gold]
|
||||
val vertex = topo.HEX_CORNERS[gold].first { topo.vertexLand[it] }
|
||||
val dice = (1..6).flatMap { a -> (1..6).map { b -> listOf(a, b) } }.first { it.sum() == number }
|
||||
val s = s0.city(vertex, 0).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 3, robber = 0)
|
||||
val rolled = CatanRules.applyMove(s, 0, RollDice(forced = dice, event = EventFace.TRADE))
|
||||
assertEquals(CatanPhase.GOLD, rolled.phase)
|
||||
assertEquals(listOf(GoldOwed(0, 2)), rolled.sea!!.gold)
|
||||
val pick = CatanRules.legalMoves(rolled, 0).filterIsInstance<PickGold>().first { it.cards == listOf(Resource.ORE, Resource.ORE) }
|
||||
val after = CatanRules.applyMove(rolled, 0, pick)
|
||||
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||
assertEquals(2, after.resources[0].getValue(Resource.ORE) - s.resources[0].getValue(Resource.ORE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `once the barbarians have landed a seven may send the pirate, which robs goods`() {
|
||||
val s0 = start()
|
||||
val topo = s0.topo
|
||||
val seaHex = (0 until topo.HEX_COUNT).first { !topo.land[it] && it != s0.sea!!.pirate &&
|
||||
(0 until topo.EDGE_COUNT).any { e -> it in topo.EDGE_HEXES[e] && topo.edgeLand[e] } }
|
||||
val edge = (0 until topo.EDGE_COUNT).first { seaHex in topo.EDGE_HEXES[it] }
|
||||
val s = s0.copy(
|
||||
phase = CatanPhase.ROBBER, turn = 0, turnNumber = 3,
|
||||
ck = s0.ck!!.copy(attacked = true),
|
||||
sea = s0.sea!!.copy(ships = s0.sea!!.ships.toMutableList().also { it[edge] = 1 }),
|
||||
resources = s0.resources.toMutableList().also { it[1] = it[1] + (Resource.GRAIN to 2) },
|
||||
)
|
||||
val pirate = CatanRules.legalMoves(s, 0).filterIsInstance<MovePirate>()
|
||||
.first { it.hex == topo.HEXES[seaHex] && it.victim == 1 }
|
||||
val after = CatanRules.applyMove(s, 0, pirate)
|
||||
assertEquals(seaHex, after.sea!!.pirate)
|
||||
assertEquals(CatanPhase.PLAYING, after.phase)
|
||||
assertEquals(CatanCk.goodsCount(s, 0) + 1, CatanCk.goodsCount(after, 0))
|
||||
assertEquals(CatanCk.goodsCount(s, 1) - 1, CatanCk.goodsCount(after, 1))
|
||||
// The robber itself is kept off the sea and the fog.
|
||||
val robber = CatanRules.legalMoves(s, 0).filterIsInstance<MoveRobber>()
|
||||
assertTrue(robber.all { topo.land[topo.hexIndex.getValue(it.hex)] })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first landings count toward the target`() {
|
||||
val s0 = start()
|
||||
val sea = s0.sea!!
|
||||
val islet = (0 until s0.topo.islandCount).first { !sea.isStart(it) }
|
||||
val withBonus = s0.copy(sea = sea.copy(islandBonus = sea.islandBonus.toMutableList().also { it[islet] = 2 }))
|
||||
assertEquals(CatanCk.publicVictoryPoints(s0)[2] + SeaRules.ISLAND_BONUS, CatanCk.publicVictoryPoints(withBonus)[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engines finish a Cities and Knights game on three maps`() {
|
||||
var ships = 0
|
||||
var knights = 0
|
||||
var barbarianAttacks = 0
|
||||
for ((scenario, players, seed) in listOf(
|
||||
Triple(SeaScenario.NEW_SHORES, 3, 21L),
|
||||
Triple(SeaScenario.FOUR_ISLANDS, 4, 22L),
|
||||
Triple(SeaScenario.FOG_ISLANDS, 3, 23L),
|
||||
)) {
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(options(scenario, players), seed))
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 40_000)
|
||||
val seat = CatanRules.currentSeat(state) ?: error("$scenario: nobody to move in ${state.phase}; ${state.log.takeLast(4)}")
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
assertTrue("$scenario: no legal move for $seat in ${state.phase}", legal.isNotEmpty())
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("$scenario: illegal $move in ${state.phase}", CatanRules.isLegalMove(state, seat, move))
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
if (moves % 40 == 0) assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||
}
|
||||
assertFalse(state.winner == null)
|
||||
ships += state.sea!!.ships.count { it != NO_PLAYER }
|
||||
knights += state.ck!!.knights.size
|
||||
if (state.ck!!.attacked) barbarianAttacks++
|
||||
}
|
||||
assertTrue("no ships were built", ships > 0)
|
||||
assertTrue("no knights were hired", knights > 0)
|
||||
assertTrue("the barbarians never landed", barbarianAttacks > 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** The scenarios with rules of their own: cloth villages and tribe chips. */
|
||||
class CatanSeaExtrasTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun start(scenario: SeaScenario, seed: Long = 5L, players: Int = 3): CatanState {
|
||||
val options = CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 14,
|
||||
expansions = setOf(CatanExpansion.SEAFARERS),
|
||||
scenario = scenario,
|
||||
)
|
||||
val config = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
return CatanRules.initialState(config).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
|
||||
}
|
||||
|
||||
private fun dice(total: Int): List<Int> = if (total <= 7) listOf(1, total - 1) else listOf(total - 6, 6)
|
||||
|
||||
/** A cloth game with seat 0 and seat 1 both settled beside the first village, the robber elsewhere. */
|
||||
private fun clothGame(): Pair<CatanState, Int> {
|
||||
val s = start(SeaScenario.CLOTH_FOR_CATAN)
|
||||
val village = s.terrains.indexOf(Terrain.VILLAGE)
|
||||
val corners = s.topo.HEX_CORNERS[village]
|
||||
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
|
||||
val staged = s.copy(
|
||||
robber = elsewhere,
|
||||
buildings = s.buildings.toMutableList().also {
|
||||
it[corners[0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
|
||||
it[corners[3]] = CatanBuilding(BuildingType.CITY, 1)
|
||||
},
|
||||
)
|
||||
return staged to village
|
||||
}
|
||||
|
||||
private fun roll(s: CatanState, number: Int) = CatanRules.applyMove(s, s.turn, RollDice(dice(number)))
|
||||
|
||||
@Test
|
||||
fun `a village pays one cloth to every settlement beside it, a city no more`() {
|
||||
val (s, village) = clothGame()
|
||||
val number = s.numbers[village]
|
||||
assertTrue(number in 2..12 && number != 7)
|
||||
val after = roll(s, number)
|
||||
assertEquals(listOf(1, 1, 0), after.sea!!.cloth)
|
||||
assertEquals(SeaBoards.VILLAGE_CLOTH - 2, after.sea!!.villageCloth[village])
|
||||
assertTrue(after.log.any { "village" in it && "cloth" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty village pays nothing and the last token goes to the seat nearest the roller`() {
|
||||
val (s, village) = clothGame()
|
||||
val low = s.copy(sea = s.sea!!.copy(villageCloth = s.sea!!.villageCloth.toMutableList().also { it[village] = 1 }))
|
||||
val after = roll(low.copy(turn = 1), low.numbers[village])
|
||||
assertEquals(listOf(0, 1, 0), after.sea!!.cloth)
|
||||
assertEquals(0, after.sea!!.villageCloth[village])
|
||||
val again = roll(after.copy(phase = CatanPhase.AWAIT_ROLL), after.numbers[village])
|
||||
assertEquals(listOf(0, 1, 0), again.sea!!.cloth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the robber on a village stops it`() {
|
||||
val (s, village) = clothGame()
|
||||
val after = roll(s.copy(robber = village), s.numbers[village])
|
||||
assertEquals(listOf(0, 0, 0), after.sea!!.cloth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every two cloth are a victory point, and the whole thing survives the wire format`() {
|
||||
val (s, _) = clothGame()
|
||||
val rich = s.copy(sea = s.sea!!.copy(cloth = listOf(5, 1, 0)))
|
||||
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 2, CatanRules.publicVictoryPoints(rich)[0])
|
||||
assertEquals(CatanRules.publicVictoryPoints(s)[1], CatanRules.publicVictoryPoints(rich)[1])
|
||||
assertEquals(rich, CatanRules.decodeState(CatanRules.encodeState(rich)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `villages pay cloth with Cities and Knights too`() {
|
||||
val options = CatanOptions(
|
||||
playerCount = 3,
|
||||
pointsToWin = 14,
|
||||
expansions = setOf(CatanExpansion.SEAFARERS, CatanExpansion.CITIES_AND_KNIGHTS),
|
||||
scenario = SeaScenario.CLOTH_FOR_CATAN,
|
||||
)
|
||||
val config = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = 5L,
|
||||
)
|
||||
val s = CatanRules.initialState(config)
|
||||
.copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
|
||||
val village = s.terrains.indexOf(Terrain.VILLAGE)
|
||||
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
|
||||
val staged = s.copy(
|
||||
robber = elsewhere,
|
||||
buildings = s.buildings.toMutableList().also {
|
||||
it[s.topo.HEX_CORNERS[village][0]] = CatanBuilding(BuildingType.SETTLEMENT, 2)
|
||||
},
|
||||
)
|
||||
val after = roll(staged, staged.numbers[village])
|
||||
assertEquals(1, after.sea!!.cloth[2])
|
||||
}
|
||||
|
||||
// ---- The Forgotten Tribe ----
|
||||
|
||||
/** A tribe game, seat 0 with a road reaching a settlement site beside [chipHex], and plenty of cards. */
|
||||
private fun tribeGame(chip: TribeChip): Triple<CatanState, Int, Int> {
|
||||
val s = start(SeaScenario.FORGOTTEN_TRIBE)
|
||||
val hex = s.sea!!.chips.indexOfFirst { it != TribeChip.NONE }
|
||||
val chips = s.sea!!.chips.toMutableList().also { it[hex] = chip }
|
||||
val topo = s.topo
|
||||
val vertex = topo.HEX_CORNERS[hex].first { v -> topo.VERTEX_NEIGHBORS[v].all { s.buildings[it] == null } }
|
||||
val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeLand[it] }
|
||||
val staged = s.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
roads = s.roads.toMutableList().also { it[edge] = 0 },
|
||||
resources = List(3) { Resource.entries.associateWith { 4 } },
|
||||
sea = s.sea!!.copy(chips = chips),
|
||||
)
|
||||
return Triple(staged, hex, vertex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first settler beside a point chip takes the point`() {
|
||||
val (s, hex, vertex) = tribeGame(TribeChip.POINT)
|
||||
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
|
||||
assertEquals(1, after.sea!!.chipPoints[0])
|
||||
assertEquals(TribeChip.NONE, after.sea!!.chips[hex])
|
||||
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 1 + 1 + 2, CatanRules.publicVictoryPoints(after)[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a harbour chip trades three to one anywhere`() {
|
||||
val (s, _, vertex) = tribeGame(TribeChip.HARBOUR)
|
||||
assertEquals(4, CatanRules.bestPortRatio(s, 0, Resource.WOOL))
|
||||
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
|
||||
assertEquals(listOf(0), after.sea!!.harbourSeats)
|
||||
assertEquals(3, CatanRules.bestPortRatio(after, 0, Resource.WOOL))
|
||||
assertEquals(4, CatanRules.bestPortRatio(after, 1, Resource.WOOL))
|
||||
assertTrue(CatanRules.legalMoves(after, 0).any { it is MaritimeTrade })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gold chip asks for two resources of the settler's choice`() {
|
||||
val (s, _, vertex) = tribeGame(TribeChip.GOLD)
|
||||
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
|
||||
assertEquals(CatanPhase.GOLD, after.phase)
|
||||
assertEquals(listOf(GoldOwed(0, 2)), after.sea!!.gold)
|
||||
val done = CatanRules.applyMove(after, 0, CatanRules.legalMoves(after, 0).first())
|
||||
assertEquals(CatanPhase.PLAYING, done.phase)
|
||||
}
|
||||
|
||||
// ---- Pirate Islands ----
|
||||
|
||||
private fun fortGame(): Triple<CatanState, Int, Int> {
|
||||
val s = start(SeaScenario.PIRATE_ISLANDS).copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
resources = List(3) { Resource.entries.associateWith { 6 } },
|
||||
)
|
||||
val fort = s.terrains.indexOf(Terrain.FORTRESS)
|
||||
val edge = (0 until s.topo.EDGE_COUNT).first { s.topo.edgeSea[it] && fort in s.topo.EDGE_HEXES[it] }
|
||||
return Triple(s, fort, edge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody settles a fortress island until the fortress falls`() {
|
||||
val (s, fort, _) = fortGame()
|
||||
val corner = s.topo.HEX_CORNERS[fort].first()
|
||||
assertTrue(SeaRules.fortressHolds(s, corner))
|
||||
assertTrue(!CatanRules.vertexOpenForSettlement(s, corner))
|
||||
val fort2 = s.terrains.indices.first { s.terrains[it] == Terrain.FORTRESS && it != fort }
|
||||
assertTrue(s.topo.islandOf[fort] != s.topo.islandOf[fort2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a raid needs a ship beside the fortress and three cards, and three raids take it`() {
|
||||
val (s, fort, edge) = fortGame()
|
||||
val coord = s.topo.HEXES[fort]
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is RaidFortress })
|
||||
var state = s.copy(sea = s.sea!!.copy(ships = s.sea!!.ships.toMutableList().also { it[edge] = 0 }))
|
||||
assertTrue(RaidFortress(coord) in CatanRules.legalMoves(state, 0))
|
||||
val before = state.resources[0].getValue(Resource.ORE)
|
||||
repeat(3) { state = CatanRules.applyMove(state, 0, RaidFortress(coord)) }
|
||||
assertEquals(before - 3, state.resources[0].getValue(Resource.ORE))
|
||||
assertEquals(Terrain.DESERT, state.terrains[fort])
|
||||
assertEquals(2, state.sea!!.raidPoints[0])
|
||||
assertTrue(!SeaRules.fortressHolds(state, s.topo.HEX_CORNERS[fort].first()))
|
||||
assertTrue(CatanRules.legalMoves(state, 0).none { it is RaidFortress })
|
||||
// Seat 1 has no ship there and cannot raid.
|
||||
assertTrue(CatanRules.legalMoves(state.copy(turn = 1), 1).none { it is RaidFortress })
|
||||
}
|
||||
|
||||
// ---- Wonders of Catan ----
|
||||
|
||||
private fun wonderGame(city: Boolean = true): CatanState {
|
||||
val s = start(SeaScenario.WONDERS_OF_CATAN).copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
resources = List(3) { Resource.entries.associateWith { 9 } },
|
||||
)
|
||||
return if (!city) s else s.copy(
|
||||
buildings = s.buildings.toMutableList().also { it[0] = CatanBuilding(BuildingType.CITY, 0) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wonder needs a city, each stage is a point, and the fourth stage wins`() {
|
||||
assertTrue(CatanRules.legalMoves(wonderGame(city = false), 0).none { it is BuildWonder })
|
||||
var s = wonderGame()
|
||||
val base = CatanRules.publicVictoryPoints(s)[0]
|
||||
for (stage in 1..3) {
|
||||
assertTrue(BuildWonder in CatanRules.legalMoves(s, 0))
|
||||
s = CatanRules.applyMove(s, 0, BuildWonder)
|
||||
assertEquals(stage, s.sea!!.wonders[0])
|
||||
assertEquals(base + stage, CatanRules.publicVictoryPoints(s)[0])
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
s = CatanRules.applyMove(s, 0, BuildWonder)
|
||||
assertEquals(CatanPhase.GAME_OVER, s.phase)
|
||||
assertEquals(0, s.winner)
|
||||
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wonder stage is not offered without the cards`() {
|
||||
val s = wonderGame().copy(resources = List(3) { Resource.entries.associateWith { 0 } })
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildWonder })
|
||||
}
|
||||
|
||||
// ---- Treasure Islands, Oases ----
|
||||
|
||||
@Test
|
||||
fun `chests lie under the fog and are hidden from other views`() {
|
||||
for (scenario in listOf(SeaScenario.TREASURE_ISLANDS, SeaScenario.OASES)) {
|
||||
val s = start(scenario)
|
||||
val chests = s.sea!!.treasures.indices.filter { s.sea!!.treasures[it] != Treasure.NONE }
|
||||
assertEquals(minOf(5, s.terrains.count { it == Terrain.FOG }), chests.size)
|
||||
assertTrue(chests.all { s.terrains[it] == Terrain.FOG })
|
||||
assertTrue(CatanRules.viewFor(s, 1).sea!!.treasures.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an oasis map starts everyone on the desert island, never beside an oasis`() {
|
||||
val s = start(SeaScenario.OASES)
|
||||
assertEquals(37, s.topo.islandOf.count { it == s.sea!!.home })
|
||||
val moves = CatanRules.legalMoves(s.copy(phase = CatanPhase.SETUP_FIRST), s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.isNotEmpty())
|
||||
assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } })
|
||||
}
|
||||
|
||||
/** A chest of [kind] under the first fog hex, with a road of seat 0's laid up to the edge that will reveal it. */
|
||||
private fun chestGame(kind: Treasure): Triple<CatanState, Int, Int> {
|
||||
val s = start(SeaScenario.OASES).copy(phase = CatanPhase.PLAYING)
|
||||
val topo = s.topo
|
||||
val hex = s.terrains.indexOf(Terrain.FOG)
|
||||
val chests = List(topo.HEX_COUNT) { if (it == hex) kind else Treasure.NONE }
|
||||
// Seat 0 owns the town beside a road that reaches the oasis; building the road finds it.
|
||||
val edge = topo.EDGE_VERTICES.indices.first { e -> hex in topo.EDGE_HEXES[e] && topo.edgeLand[e] }
|
||||
val staged = s.copy(
|
||||
resources = List(3) { Resource.entries.associateWith { 5 } },
|
||||
sea = s.sea!!.copy(treasures = chests),
|
||||
)
|
||||
return Triple(staged, hex, edge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opening a chest pays its reward`() {
|
||||
val kinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.ROADS, Treasure.DEV_CARD, Treasure.CHOICE)
|
||||
for (kind in kinds) {
|
||||
val (s, _, _) = chestGame(kind)
|
||||
// Stage the find directly: reveal as if a road had just reached the hex.
|
||||
val topo = s.topo
|
||||
val hex = s.terrains.indexOf(Terrain.FOG)
|
||||
val edge = (0 until topo.EDGE_COUNT).first { hex in topo.EDGE_HEXES[it] && topo.edgeLand[it] }
|
||||
val after = SeaRules.reveal(s, 0, edge)
|
||||
assertTrue("$kind: the chest is announced", after.log.any { "treasure chest" in it })
|
||||
when (kind) {
|
||||
Treasure.DEV_CARD -> assertEquals(1, after.devHands[0].size)
|
||||
Treasure.CHOICE -> assertTrue(after.phase == CatanPhase.GOLD || after.log.any { "uncovers" in it })
|
||||
Treasure.ROADS -> assertTrue(after.resources[0].getValue(Resource.LUMBER) >= 7)
|
||||
else -> assertTrue(after.resources[0].getValue(Resource.WOOL) >= 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The Great Canal ----
|
||||
|
||||
private fun canalGame(): Triple<CatanState, Int, CatanState> {
|
||||
val s = start(SeaScenario.GREAT_CANAL).copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
resources = List(3) { Resource.entries.associateWith { 9 } },
|
||||
)
|
||||
val tile = s.sea!!.canalNumbers.indexOfFirst { it != 0 }
|
||||
val staged = s.copy(
|
||||
buildings = s.buildings.toMutableList().also {
|
||||
it[s.topo.HEX_CORNERS[tile][0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
|
||||
},
|
||||
)
|
||||
return Triple(staged, tile, s)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a canal tile hides its number, and three digs turn it into fields`() {
|
||||
var (s, tile, _) = canalGame()
|
||||
assertEquals(0, s.numbers[tile])
|
||||
assertEquals(Terrain.DESERT, s.terrains[tile])
|
||||
val number = s.sea!!.canalNumbers[tile]
|
||||
val dig = DigCanal(s.topo.HEXES[tile])
|
||||
assertTrue(dig in CatanRules.legalMoves(s, 0))
|
||||
assertTrue(CatanRules.legalMoves(s.copy(turn = 1), 1).none { it is DigCanal })
|
||||
val before = CatanRules.publicVictoryPoints(s)[0]
|
||||
repeat(2) { s = CatanRules.applyMove(s, 0, dig) }
|
||||
assertEquals(Terrain.DESERT, s.terrains[tile])
|
||||
assertEquals(before + 1, CatanRules.publicVictoryPoints(s)[0])
|
||||
s = CatanRules.applyMove(s, 0, dig)
|
||||
assertEquals(Terrain.FIELDS, s.terrains[tile])
|
||||
assertEquals(number, s.numbers[tile])
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is DigCanal && it.hex == dig.hex })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** What every Seafarers map owes the engine, and the shape of each one. */
|
||||
class CatanSeaScenariosTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun options(scenario: SeaScenario, players: Int = 3) = CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 12,
|
||||
expansions = setOf(CatanExpansion.SEAFARERS),
|
||||
scenario = scenario,
|
||||
)
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun sizes(scenario: SeaScenario): List<Int> {
|
||||
val topo = SeaBoards.topology(scenario)
|
||||
return (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each map has the islands it is named for`() {
|
||||
assertEquals(listOf(2, 3, 3, 21), sizes(SeaScenario.NEW_SHORES))
|
||||
assertEquals(listOf(1, 1, 6, 6, 6, 6), sizes(SeaScenario.FOUR_ISLANDS))
|
||||
assertEquals(listOf(1, 1, 1, 2, 25), sizes(SeaScenario.THROUGH_THE_DESERT))
|
||||
assertEquals(listOf(2, 13, 14), sizes(SeaScenario.FOG_ISLANDS))
|
||||
assertEquals(listOf(1, 3, 4, 15), sizes(SeaScenario.TREASURE_ISLANDS))
|
||||
assertEquals(listOf(37), sizes(SeaScenario.OASES))
|
||||
assertEquals(listOf(2, 25), sizes(SeaScenario.GREAT_CANAL))
|
||||
assertEquals(listOf(4, 5, 18), sizes(SeaScenario.PIRATE_ISLANDS))
|
||||
assertEquals(listOf(1, 3, 12, 13), sizes(SeaScenario.WONDERS_OF_CATAN))
|
||||
assertEquals(listOf(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN))
|
||||
assertEquals(listOf(2, 3, 5, 17), sizes(SeaScenario.FORGOTTEN_TRIBE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every map deals numbers, harbours and a pirate at sea, with the reds apart`() {
|
||||
for (scenario in SeaScenario.entries) {
|
||||
repeat(5) { i ->
|
||||
val s = CatanRules.initialState(config(options(scenario), 300L + i))
|
||||
val topo = s.topo
|
||||
val deserts = s.terrains.count { it == Terrain.DESERT }
|
||||
for (h in 0 until topo.HEX_COUNT) {
|
||||
val land = s.terrains[h] != Terrain.SEA
|
||||
assertEquals("$scenario hex $h", topo.land[h], land)
|
||||
val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG ||
|
||||
s.terrains[h] == Terrain.FORTRESS
|
||||
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
|
||||
}
|
||||
val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN &&
|
||||
scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS &&
|
||||
scenario != SeaScenario.TREASURE_ISLANDS
|
||||
assertTrue("$scenario has no desert", deserts > 0 || !deserted)
|
||||
// Under fog every hex is face down with no number; the tiles are in the pile.
|
||||
val fog = s.terrains.indices.filter { s.terrains[it] == Terrain.FOG }
|
||||
assertEquals(fog.size, s.sea!!.fogDeck.size)
|
||||
fog.forEach { assertEquals(0, s.numbers[it]) }
|
||||
assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate])
|
||||
assertEquals("$scenario harbours", 9, s.ports.size)
|
||||
val reds = (0 until topo.HEX_COUNT).filter { s.numbers[it] == 6 || s.numbers[it] == 8 }
|
||||
for (a in reds) for (b in reds) if (a < b) assertFalse("$scenario reds touch", topo.hexesAdjacent(a, b))
|
||||
assertEquals(scenario, s.sea!!.scenario)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four islands start on any main island and the islets earn the bonus`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.FOUR_ISLANDS), 5L))
|
||||
val sea = s.sea!!
|
||||
assertEquals(4, sea.starts.size)
|
||||
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertEquals(sea.starts.toSet(), moves.map { s.topo.vertexIsland[it.vertex] }.toSet())
|
||||
for (island in 0 until s.topo.islandCount) assertEquals(island in sea.starts, sea.isStart(island))
|
||||
// A settlement on a starting island scores no first-landing bonus.
|
||||
val onMain = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] == sea.starts.first() }
|
||||
assertEquals(s, SeaRules.claimIsland(s, 0, onMain))
|
||||
val islet = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] >= 0 && s.topo.vertexIsland[it] !in sea.starts }
|
||||
assertEquals(0, SeaRules.bonusPoints(s, 0))
|
||||
assertEquals(0, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, onMain), 0))
|
||||
assertEquals(SeaRules.ISLAND_BONUS, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, islet), 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `through the desert starts everyone on the one big island`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.THROUGH_THE_DESERT), 5L))
|
||||
assertEquals(listOf(s.sea!!.home), s.sea!!.starts)
|
||||
assertEquals(25, s.topo.islandOf.count { it == s.sea!!.home })
|
||||
assertTrue(s.terrains.count { it == Terrain.DESERT } >= 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save from before there were several starts still starts on the home island`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.NEW_SHORES), 5L))
|
||||
val old = s.copy(sea = s.sea!!.copy(starts = emptyList()))
|
||||
assertTrue(old.sea!!.isStart(old.sea!!.home))
|
||||
val moves = CatanRules.legalMoves(old, old.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.all { old.topo.vertexIsland[it.vertex] == old.sea!!.home })
|
||||
}
|
||||
|
||||
private fun fogGame(seed: Long = 5L) = CatanRules.initialState(config(options(SeaScenario.FOG_ISLANDS), seed)).let {
|
||||
it.copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1, setupIndex = 6)
|
||||
}
|
||||
|
||||
/** A town site on the home island, ships already laid toward the fog, and the one edge that will reach it. */
|
||||
private data class FogRoute(val town: Int, val laid: List<Int>, val last: Int)
|
||||
|
||||
private fun CatanState.fogRoute(): FogRoute {
|
||||
val topo = topo
|
||||
fun touchesFog(v: Int) = topo.VERTEX_HEXES[v].any { terrains[it] == Terrain.FOG }
|
||||
val seen = mutableSetOf<Int>()
|
||||
for (start in 0 until topo.VERTEX_COUNT) {
|
||||
if (topo.vertexIsland[start] != sea!!.home || touchesFog(start)) continue
|
||||
if (topo.VERTEX_EDGES[start].none { topo.edgeSea[it] && !topo.edgeLand[it] }) continue
|
||||
val queue = ArrayDeque<Pair<Int, List<Int>>>().apply { add(start to emptyList()) }
|
||||
seen.clear()
|
||||
while (queue.isNotEmpty()) {
|
||||
val (v, path) = queue.removeFirst()
|
||||
if (!seen.add(v)) continue
|
||||
for (e in topo.VERTEX_EDGES[v]) {
|
||||
if (!topo.edgeSea[e] || topo.edgeLand[e]) continue
|
||||
val far = topo.otherEnd(e, v)
|
||||
if (touchesFog(far)) return FogRoute(start, path, e)
|
||||
if (path.size < 6) queue.add(far to path + e)
|
||||
}
|
||||
}
|
||||
}
|
||||
error("no ship route from home reaches the fog")
|
||||
}
|
||||
|
||||
/** [fogRoute] laid on the board: the town and all but the last ship. */
|
||||
private fun CatanState.onTheShore(route: FogRoute): CatanState = copy(
|
||||
buildings = buildings.toMutableList().also { it[route.town] = CatanBuilding(BuildingType.SETTLEMENT, 0) },
|
||||
sea = sea!!.copy(ships = sea!!.ships.toMutableList().also { l -> route.laid.forEach { l[it] = 0 } }),
|
||||
resources = List(3) { Resource.entries.associateWith { 3 } },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fog is hidden at the start, never a place to begin, and keeps the robber off`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.FOG_ISLANDS), 5L))
|
||||
assertFalse(s.sea!!.isStart(s.topo.islandOf.indices.first { s.terrains[it] == Terrain.FOG }.let { s.topo.islandOf[it] }))
|
||||
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } })
|
||||
val robbing = fogGame().copy(phase = CatanPhase.ROBBER)
|
||||
val targets = CatanRules.legalMoves(robbing, 0).filterIsInstance<MoveRobber>()
|
||||
assertTrue(targets.isNotEmpty())
|
||||
assertTrue(targets.none { robbing.terrains[robbing.topo.hexIndex.getValue(it.hex)] == Terrain.FOG })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ship that reaches the fog turns the hex over and the finder takes a card`() {
|
||||
val base = fogGame()
|
||||
val route = base.fogRoute()
|
||||
val edge = route.last
|
||||
val s = base.onTheShore(route)
|
||||
val tile = s.sea!!.fogDeck.first()
|
||||
val after = CatanRules.applyMove(s, 0, BuildShip(edge))
|
||||
val found = s.topo.EDGE_VERTICES[edge].toList().flatMap { s.topo.VERTEX_HEXES[it] }
|
||||
.filter { s.terrains[it] == Terrain.FOG }.distinct().sorted()
|
||||
val hex = found.first()
|
||||
assertEquals(tile.terrain, after.terrains[hex])
|
||||
assertEquals(tile.number, after.numbers[hex])
|
||||
assertEquals(s.sea!!.fogDeck.size - found.size, after.sea!!.fogDeck.size)
|
||||
val produced = tile.terrain.produces
|
||||
if (produced != null) assertEquals(s.resources[0].getValue(produced) - (if (produced in SeaRules.SHIP_COST) 1 else 0) + 1,
|
||||
after.resources[0].getValue(produced) - (found.drop(1).count { after.terrains[it].produces == produced }))
|
||||
if (tile.terrain == Terrain.GOLD) assertEquals(CatanPhase.GOLD, after.phase)
|
||||
// The find is public: the log names it.
|
||||
assertTrue(after.log.last { "uncovers" in it }.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gold find during road building asks for the choice and then goes back to the free roads`() {
|
||||
val base = fogGame()
|
||||
val route = base.fogRoute()
|
||||
val edge = route.last
|
||||
val gold = FogTile(Terrain.GOLD, 6)
|
||||
val shore = base.onTheShore(route)
|
||||
val s = shore.copy(
|
||||
phase = CatanPhase.FREE_ROADS,
|
||||
pendingFreeRoads = 2,
|
||||
sea = shore.sea!!.copy(fogDeck = List(shore.sea!!.fogDeck.size) { gold }),
|
||||
)
|
||||
val after = CatanRules.applyMove(s, 0, BuildShip(edge))
|
||||
assertEquals(CatanPhase.GOLD, after.phase)
|
||||
val pick = CatanRules.legalMoves(after, 0).filterIsInstance<PickGold>().first()
|
||||
val resumed = CatanRules.applyMove(after, 0, pick)
|
||||
assertEquals(CatanPhase.FREE_ROADS, resumed.phase)
|
||||
assertEquals(1, resumed.pendingFreeRoads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other seats do not see the face-down tiles and the state survives its wire format`() {
|
||||
val s = fogGame()
|
||||
assertTrue(s.sea!!.fogDeck.isNotEmpty())
|
||||
assertTrue(CatanRules.viewFor(s, 1).sea!!.fogDeck.isEmpty())
|
||||
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engines finish a game on every map at three and four players`() {
|
||||
var fogFound = 0
|
||||
var clothPaid = 0
|
||||
var chipsTaken = 0
|
||||
var fortsTaken = 0
|
||||
var wonderStages = 0
|
||||
var chestsOpened = 0
|
||||
var canalDug = 0
|
||||
for (scenario in SeaScenario.entries) {
|
||||
for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) {
|
||||
val opts = options(scenario, players)
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(opts, seed))
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 20_000)
|
||||
val seat = CatanRules.currentSeat(state) ?: error("$scenario seed $seed: nobody to move in ${state.phase}")
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
assertTrue("$scenario seed $seed: no legal move for $seat in ${state.phase}", legal.isNotEmpty())
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("$scenario seed $seed: illegal $move", CatanRules.isLegalMove(state, seat, move))
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
if (scenario == SeaScenario.FOG_ISLANDS) fogFound += 14 - state.sea!!.fogDeck.size
|
||||
if (scenario == SeaScenario.CLOTH_FOR_CATAN) clothPaid += state.sea!!.cloth.sum()
|
||||
if (scenario == SeaScenario.PIRATE_ISLANDS) fortsTaken += state.sea!!.raidPoints.sum() / 2
|
||||
if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) {
|
||||
chestsOpened += state.log.count { "treasure chest" in it }
|
||||
}
|
||||
if (scenario == SeaScenario.GREAT_CANAL) canalDug += state.sea!!.canalDug.sum()
|
||||
if (scenario == SeaScenario.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum()
|
||||
if (scenario == SeaScenario.FORGOTTEN_TRIBE) {
|
||||
chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE }
|
||||
}
|
||||
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||
}
|
||||
}
|
||||
assertTrue("no engine ever uncovered a fog hex", fogFound > 0)
|
||||
assertTrue("no village ever paid cloth", clothPaid > 0)
|
||||
assertTrue("no tribe chip was ever taken", chipsTaken > 0)
|
||||
assertTrue("no fortress ever fell", fortsTaken > 0)
|
||||
assertTrue("no chest was ever opened", chestsOpened > 0)
|
||||
assertTrue("no canal unit was ever dug", canalDug > 0)
|
||||
assertTrue("no wonder stage was ever built", wonderStages > 0)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class CatanSeafarersTest {
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun options(players: Int = 3) =
|
||||
CatanOptions(playerCount = players, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
|
||||
CatanOptions(playerCount = players, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS))
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
@@ -96,7 +96,7 @@ class CatanSeafarersTest {
|
||||
@Test
|
||||
fun `setup places both settlements on the home island, with roads rather than ships`() {
|
||||
val s = start()
|
||||
val moves = CatanRules.legalMoves(s, 0).filterIsInstance<SetupPlace>()
|
||||
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.isNotEmpty())
|
||||
assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home })
|
||||
assertTrue(moves.all { s.topo.edgeLand[it.edge] })
|
||||
|
||||
@@ -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()))
|
||||
@@ -171,7 +171,7 @@ class SaveRoundTripAllGamesTest {
|
||||
GameCatalog.CATAN,
|
||||
CatanRules,
|
||||
CatanAi(),
|
||||
opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)),
|
||||
opts(CatanOptions(3, 13, setOf(org.prolibertate.games.game.catan.CatanExpansion.CITIES_AND_KNIGHTS))),
|
||||
steps = 600,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.ChaseRobber
|
||||
@@ -69,7 +69,7 @@ class CatanCkInteractionTest {
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(
|
||||
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
CatanOptions(players, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
|
||||
),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.ProgressType
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
@@ -16,7 +16,7 @@ import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.ui.catanDefaultPoints
|
||||
import org.prolibertate.games.ui.catanPointsChoices
|
||||
import org.prolibertate.games.ui.catanSwitchRules
|
||||
import org.prolibertate.games.ui.catanToggleExpansion
|
||||
|
||||
class CatanCkTextTest {
|
||||
|
||||
@@ -28,7 +28,7 @@ class CatanCkTextTest {
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(
|
||||
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
CatanOptions(3, 13, setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
|
||||
),
|
||||
seed = 31L,
|
||||
)
|
||||
@@ -56,20 +56,27 @@ class CatanCkTextTest {
|
||||
|
||||
@Test
|
||||
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
|
||||
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
|
||||
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
|
||||
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
|
||||
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(emptySet()))
|
||||
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(setOf(CatanExpansion.CITIES_AND_KNIGHTS)))
|
||||
assertEquals(10, catanDefaultPoints(emptySet()))
|
||||
assertEquals(13, catanDefaultPoints(setOf(CatanExpansion.CITIES_AND_KNIGHTS)))
|
||||
|
||||
val base = CatanOptions()
|
||||
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
|
||||
val ck = catanToggleExpansion(base, CatanExpansion.CITIES_AND_KNIGHTS, true)
|
||||
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS), ck.expansions)
|
||||
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
|
||||
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
|
||||
assertEquals(10, catanToggleExpansion(ck, CatanExpansion.CITIES_AND_KNIGHTS, false).pointsToWin)
|
||||
|
||||
// A short game picked on purpose is kept when both menus have it.
|
||||
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
|
||||
val short = catanToggleExpansion(base.copy(pointsToWin = 8), CatanExpansion.CITIES_AND_KNIGHTS, true)
|
||||
assertEquals(8, short.pointsToWin)
|
||||
assertEquals(3, ck.playerCount)
|
||||
|
||||
// Expansions stack: both on has its own menu, and turning one off leaves the other.
|
||||
val both = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true)
|
||||
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS, CatanExpansion.SEAFARERS), both.expansions)
|
||||
assertEquals(listOf(10, 12, 14, 16), catanPointsChoices(both.expansions))
|
||||
assertEquals(14, both.pointsToWin)
|
||||
assertEquals(setOf(CatanExpansion.SEAFARERS), catanToggleExpansion(both, CatanExpansion.CITIES_AND_KNIGHTS, false).expansions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ internal object CatanFixtures {
|
||||
|
||||
/** A fresh table, on the first setup placement. */
|
||||
fun start(players: Int = 3, seed: Long = 7L): CatanState =
|
||||
CatanRules.initialState(config(players, seed))
|
||||
// These tests are about the screen, not who starts: pin seat 0 first.
|
||||
CatanRules.initialState(config(players, seed)).let { it.copy(firstSeat = 0, turn = 0) }
|
||||
|
||||
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
|
||||
fun playing(
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import org.prolibertate.games.game.catan.CatanAi
|
||||
import org.prolibertate.games.game.catan.CatanBuilding
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanPhase
|
||||
import org.prolibertate.games.game.catan.CatanRuleSet
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.CatanRules
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.MovePirate
|
||||
@@ -29,7 +29,7 @@ import org.prolibertate.games.game.engine.TableConfig
|
||||
class CatanSeafarersInteractionTest {
|
||||
|
||||
private fun fresh(): CatanState {
|
||||
val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
|
||||
val options = CatanOptions(playerCount = 3, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS))
|
||||
return CatanRules.initialState(
|
||||
TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
|
||||
@@ -66,7 +66,13 @@ A full-width panel that slides up over the bottom of the screen (`Animator`, col
|
||||
* **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a
|
||||
road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`.
|
||||
* **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are
|
||||
**not** part of the physical Seafarers rules; they are out of scope here.
|
||||
**not** part of the physical Seafarers rules. Names and texts only (rule values are compiled code):
|
||||
`TreasureRule` with chest outcomes (bricks, wool, grain; a resource; a development card; two roads),
|
||||
`SeafarerOasis` (no rule class of its own, base-game rules), and `GreatCanalRule` with `BuildCanal`,
|
||||
`CanalVictoryPoints`, "The canal was completely dug. Now grain can be grown." and a target of 21 / 18 points.
|
||||
The base-game scenario `baseharbormaster`: "2 victory points. This card goes to the first player to acquire at
|
||||
least 3 harbors. If another player acquires more, he will immediately receive this card."
|
||||
Implemented as inferred rules; see `docs/RULES-catan-seafarers.md`.
|
||||
* **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip`
|
||||
(number tokens placed per scenario), `Shuffle_Harbors`.
|
||||
* **Island bonus:** special victory points for the first settlement on a new island
|
||||
|
||||
@@ -220,3 +220,10 @@ game in `CatanCkSelfPlayTest` ends in a win at thirteen points.
|
||||
- No animation for the event die, the barbarian landing or a knight's move.
|
||||
- The Commercial Harbor offers a single resource type (see above).
|
||||
- The AI is not a strong opponent and makes no claim to be.
|
||||
|
||||
## On a Seafarers map
|
||||
|
||||
Cities & Knights can be played on a Seafarers map; the rules for how the two meet are in
|
||||
[RULES-catan-seafarers.md](RULES-catan-seafarers.md#with-cities--knights). The code reads the board from
|
||||
`state.topo` throughout, so nothing here assumes the 19-hex island.
|
||||
|
||||
|
||||
+145
-10
@@ -1,20 +1,47 @@
|
||||
# Katan: Seafarers
|
||||
|
||||
The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. It is the base
|
||||
Seafarers is an expansion switched on at setup (`CatanOptions.expansions`; the old single `ruleSet` in saves is still read) and its map is chosen beside it (`CatanOptions.scenario`). It is the base
|
||||
game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to
|
||||
settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map),
|
||||
`CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea`
|
||||
(null in the other rule sets). It cannot be combined with Cities & Knights.
|
||||
(null in the other rule sets). It can be combined with Cities & Knights; see *With Cities & Knights* below.
|
||||
|
||||
Where the published rules leave room, or where this game differs, it says so below.
|
||||
|
||||
## The map
|
||||
## The maps
|
||||
|
||||
One map, *Heading for New Shores*: a large **home island** (21 hexes, one desert) and three small
|
||||
islands (3, 3 and 2 hexes) across the water, inside a ring of sea. Which land hexes are gold, which
|
||||
are the desert and where the sea lies is fixed; the **resources on the other hexes, the number tokens
|
||||
Each map is a fixed arrangement of land and sea; the **resources on the plain hexes, the number tokens
|
||||
and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring
|
||||
hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14).
|
||||
Pick the map at setup; each scenario names where the starting settlements may go (`SeaStart`).
|
||||
|
||||
* **Heading for New Shores** (default). A large home island (21 hexes, one desert) and three small islands (3, 3
|
||||
and 2 hexes) across the water, with gold fields on two of the small ones. Start on the home island.
|
||||
* **Four Islands.** Four equal islands (six hexes each) and two one-hex gold islets between them. Each player
|
||||
may start on **any** of the four main islands, so a table can spread out or crowd one; the islets are the
|
||||
prize for the first ship to arrive.
|
||||
* **Through the Desert.** One 25-hex island cut across by a broad desert (seven hexes that pay nothing and
|
||||
hold the robber), with three islets and gold to sail to. Everyone starts on the island. The desert is
|
||||
land, so a road can cross it, but nothing beside it pays, which is what makes the far side a race.
|
||||
|
||||
* **The Fog Islands.** A 13-hex home island (one desert), a two-hex islet and a 14-hex bank of fog.
|
||||
See *Fog* below.
|
||||
|
||||
* **Cloth for Catan.** A 17-hex main island, an eastern isle, two islets, and four **villages** on the coast.
|
||||
See *Cloth* below.
|
||||
* **The Forgotten Tribe.** A 17-hex home island and three isles carrying four tribe chips. See *The Forgotten
|
||||
Tribe* below.
|
||||
|
||||
* **Pirate Islands.** An 18-hex home island and two isles (five and four hexes) each held by a pirate
|
||||
fortress. See *Pirate Islands* below.
|
||||
* **Wonders of Catan.** A 13-hex island with a desert, a 12-hex island, and gold islets. See *Wonders* below.
|
||||
|
||||
* **The Treasure Islands** *(Catan Universe extra)*. A 15-hex home island and four fog isles, eight hexes of fog in
|
||||
all. See *Catan Universe extras* below.
|
||||
* **The Oases** *(Catan Universe extra)*. One 37-hex island that is mostly desert, with three oases hidden in it.
|
||||
* **The Great Canal** *(Catan Universe extra)*. A 25-hex island split by a row of five canal tiles, and gold islets.
|
||||
|
||||
A *new island* for the bonus is any island that is not a starting island for that map.
|
||||
|
||||
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
|
||||
a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching
|
||||
@@ -56,16 +83,124 @@ through an opponent's town and changes between road and ship only at a town of y
|
||||
|
||||
Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game.
|
||||
|
||||
## Fog
|
||||
|
||||
Fog hexes (`Terrain.FOG`) are land that is face down. Under them is a shuffled pile of tiles
|
||||
(`SeaState.fogDeck`): resources, with one gold field in every five, each with its number token. The
|
||||
pile is hidden from every seat but the host's, like the development deck.
|
||||
|
||||
* **Finding.** When a road or ship is built (or a ship sails) and an end of it touches a fog hex, the hex is
|
||||
turned over, taking the next tile and its number. Several hexes can be found by one piece.
|
||||
* **The find pays.** The finder takes one card of what lies there, supply permitting; a gold field lets the
|
||||
finder choose any resource (the gold choice, resuming Road Building if that is what was under way).
|
||||
* **Until then** nothing produces there, nobody may start beside it, and the robber cannot go on it.
|
||||
* **Differences from the published scenario.** The fog here is always land: the physical tiles can turn out to
|
||||
be sea, but the board's land and sea are fixed in the geometry, so a found hex never turns to water. Fog is
|
||||
also on one bank rather than between several islands, and the number tokens are dealt with the tiles, so two
|
||||
red numbers can end up side by side once found.
|
||||
|
||||
## Cloth
|
||||
|
||||
The published scenario puts cloth villages between the islands and pays cloth when a village's number is rolled.
|
||||
This implementation is the author's reading of that, not a transcription of the printed rules:
|
||||
|
||||
* A **village** is a land hex with a number token and a stock of cloth (6). It produces no cards and nothing may
|
||||
be built on it, but its corners can hold settlements like any other hex's.
|
||||
* When its number rolls, the village pays **one cloth token** to each settlement or city beside it (a city is
|
||||
worth no more than a settlement here), until the stock is gone. If the stock is short, the seats nearest the
|
||||
roller, in turn order, take what is left. The robber on a village stops it; the pirate does not.
|
||||
* **Two cloth are a victory point** (an odd token is worth nothing alone). Tokens are scored, not spent, and not
|
||||
tradeable.
|
||||
* Works with Cities & Knights: the village pays after the dice, before the aqueduct and gold.
|
||||
|
||||
## The Forgotten Tribe
|
||||
|
||||
Also the author's reading. A chip lies face up on four hexes of the small isles (the hexes are marked on the
|
||||
board). The chips are dealt at the start of the game, one of each kind in turn:
|
||||
|
||||
* **The first settlement** built on a crossing beside a chip hex takes that chip; it is gone for everyone else.
|
||||
(The setup settlements are never beside one, since the home island is the only start.)
|
||||
* A **point** chip is one victory point. A **harbour** chip lets that seat trade 3:1 at the bank, wherever it is.
|
||||
A **gold** chip pays two resources of the settler's choice, taken at once like gold.
|
||||
* The chips come on top of the island bonus for being first to a new island.
|
||||
|
||||
## Pirate Islands
|
||||
|
||||
The author's reading of the scenario; the printed one uses knights and a roaming pirate fleet, which this does not.
|
||||
|
||||
* A **fortress** is a land hex with no number and a strength of 3. Nobody may build a settlement on any crossing of
|
||||
an island that still has a standing fortress. Roads and ships may go there.
|
||||
* A seat with a **ship beside** a fortress may **raid** it in its turn for 1 ore, 1 wool and 1 grain, as many times as
|
||||
it can pay. Each raid takes one point of strength.
|
||||
* The raid that takes the last point **takes the fortress**: the hex becomes desert (no number, robber may sit there),
|
||||
the island is open to settle for everyone, and the taker gets **2 victory points**. The robber never sits on a
|
||||
standing fortress.
|
||||
* The island still counts as a new island for the first-landing bonus.
|
||||
|
||||
## Wonders
|
||||
|
||||
Also the author's reading. Every seat may build one wonder in four stages, once it has a **city** anywhere on the
|
||||
board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain; 2: 1 brick, 2 ore, 1 wool; 3: 3 ore,
|
||||
1 grain, 1 lumber; 4: 3 ore, 2 grain, 1 brick). Each stage built is **one victory point**, and the seat that
|
||||
builds the fourth stage **wins at once**, whatever the point target. Wonders are not shared: each seat has its own.
|
||||
|
||||
## Catan Universe extras (inferred)
|
||||
|
||||
These three exist in the Catan Universe app but not in the physical set. Only their names, menu texts and log
|
||||
lines could be read (`docs/RE-catan-universe.md`); the rule values are compiled code that was not recovered. **Every
|
||||
rule below is inferred from those names and texts**, not copied.
|
||||
|
||||
* **Treasure Islands.** The app's text: "Treasures await the Catanians on neighboring, fog-covered islands.
|
||||
Discoveries are rewarded!" Its chest rewards (log texts and action names) are bricks, wool and grain; a resource of
|
||||
choice; a development card; two roads. Here: up to five chests are dealt face down to random fog hexes at the
|
||||
start. A seat that uncovers the hex (the fog rules) opens the chest at once and takes: *a brick, a wool and a grain*;
|
||||
*one resource of its choice* (a gold pick); *a development card* (a resource of choice if the deck is empty or it is
|
||||
Cities & Knights, which has no deck); or *two lumber and two brick* standing in for two free roads (the free-roads
|
||||
phase would have to nest inside a ship move). The app's Cities & Knights version gives a progress card; that is
|
||||
not modelled. The app's fixed treasure bonus (`SEAFARER_TREASURE_BONUS`) is not modelled either.
|
||||
* **The Oases.** The app's text: an enormous desert with rumoured oases and treasures, base-game rules. Here: the
|
||||
oases are fog hexes inside the desert, uncovered by a road reaching them, and hold the same chests as above.
|
||||
There is no ship rule on the map itself.
|
||||
* **The Great Canal.** The app's text, for Cities & Knights plus Seafarers: build a canal; first to 21 (3 players) or
|
||||
18 (4 players) wins; the canal's parts earn victory points; when a tile is dug out, grain grows there. Its knights dig
|
||||
in the app; here digging is cheaper to implement and play without Cities & Knights: a canal tile is a desert hex with
|
||||
its number token hidden. A seat with a settlement or city on a corner of the tile digs one **unit** for 1 brick and
|
||||
1 ore; **three units** finish the tile, which becomes **fields** with the hidden number. Every **two units** a seat
|
||||
has dug are a **victory point**. The app's gold river, harbour changes and target scores are not modelled.
|
||||
|
||||
## With Cities & Knights
|
||||
|
||||
Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map
|
||||
(`CatanState.ck` and `CatanState.sea` both set; the target menu is 10, 12, 14 or 16, default 14).
|
||||
Where the two meet, this game decides as follows:
|
||||
|
||||
* **Ships** cost 1 lumber and 1 wool as before and are built and sailed in a turn like a road, including
|
||||
with the Road Building progress card (roads and ships, as in Seafarers).
|
||||
* **Knights** stand on land crossings at the end of one of your roads *or ships*, and walk along that same
|
||||
network. A crossing that touches only sea is never a knight post.
|
||||
* **The pirate** sleeps with the robber: only after the first barbarian attack does a seven let the player
|
||||
move the robber (to land) *or* the pirate (to sea). The pirate robs one good, resource or commodity, from a
|
||||
seat with a ship beside it. The bishop moves the robber only.
|
||||
* **Gold** pays after the roll and before the aqueduct and any other chore: a settlement beside it chooses one
|
||||
resource, a city two. (A city on gold takes two resources, not a resource and a commodity.)
|
||||
* **Scoring.** First-landing bonus points are added to a seat's points like any other; the barbarians count
|
||||
cities wherever they stand, island or not.
|
||||
* **Merchant** goes only on a land hex; **Fog** works as in Seafarers (a find pays a resource card).
|
||||
|
||||
## Not included
|
||||
|
||||
The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**:
|
||||
fog islands, treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations,
|
||||
the scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map
|
||||
is a new text map and a new enum entry.
|
||||
the Explorers & Pirates combination,
|
||||
the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships.
|
||||
Drought and the app's other scenarios (Six Islands, Oceania, Great Catan, Enchanted Land, the Inka and Big Game sets)
|
||||
are not built.
|
||||
A map-only scenario is a new text map and a new `SeaScenario` entry.
|
||||
|
||||
## How the AI plays it
|
||||
|
||||
It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its
|
||||
open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that
|
||||
bonus is in play. It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
|
||||
bonus is in play. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is
|
||||
worth more (`CatanAi.scenarioLure`), a ship that heads for a standing fortress is worth building, and it raids and builds
|
||||
wonder stages whenever it can pay (`CatanAi.FORT_LURE`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
|
||||
not sail ships it has already launched. See `CatanAi.shipValue`.
|
||||
|
||||
@@ -23,6 +23,26 @@ A table plays one of two rule sets, chosen on the setup screen
|
||||
building, robber and trade rules and changes or replaces the rest; its state lives
|
||||
behind `CatanState.ck`, which is `null` in the base game.
|
||||
|
||||
## Base-island scenarios
|
||||
|
||||
Without Seafarers the setup screen offers an **Island** choice (`CatanOptions.baseScenario`; it works with Cities &
|
||||
Knights too). Both extras are the author's reading, not transcriptions of the printed scenarios:
|
||||
|
||||
- **Random map** (default) — the usual shuffled island.
|
||||
- **First Island** — the same island every game: one fixed shuffle (`CatanBoard.FIRST_ISLAND_SEED`) of the standard
|
||||
tiles, numbers and harbours, so a first game or a rematch has no surprise set-up. It is not the printed beginner
|
||||
layout. The development deck and the seating still follow the table's own seed.
|
||||
- **The Harbormaster** — the Catan Universe menu text is "Because trade becomes more and more important, harbours
|
||||
have an ever increasing significance." Its card: 2 victory points to the first player with at least three harbour
|
||||
points; another player with more takes it at once. What a harbour point is was not in the app's texts, so it is
|
||||
inferred: a settlement on a harbour counts **1**, a city **2**, for each harbour it touches. The title is checked after
|
||||
every settlement, city and set-up placement; **equal points do not take it**, only strictly more (like Longest
|
||||
Road); it is **+2 victory points** to the holder (`CatanState.harbormaster`). The AI values a harbour spot a
|
||||
little more here (`CatanAi.HARBOR_LURE`).
|
||||
|
||||
Seafarers games choose a map instead (see [RULES-catan-seafarers.md](RULES-catan-seafarers.md)); the Harbormaster does not apply there.
|
||||
The app's other base scenario, Ore for Wool, is not built: its rules are not in the texts that could be read.
|
||||
|
||||
## Table
|
||||
|
||||
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).
|
||||
|
||||
Reference in New Issue
Block a user