Compare commits

..
Author SHA1 Message Date
Claude 961ac96689 Merge remote-tracking branch 'origin/dev' into feature/ai-difficulty
CI / Build and check (pull_request) Successful in 11m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-08 05:14:49 +00:00
Claude e1c325825f Computer difficulty levels and hidden per-seat strategies
Every game with computer opponents gains a setup choice (Easy, Medium, Hard,
Mixed), carried in TableConfig.aiDifficulty and through the host lobby. Each
computer seat gets a level and a hidden style (balanced, aggressive, cautious,
spoiler) derived from the table seed, so saves and hosted tables reproduce it.
Medium plays exactly as each AI did before. Easy plays weaker and slips
plausibly; Hard searches deeper or counts cards, never using hidden
information. Covers Chess, Checkers, Morris, Backgammon, Pirates, Katan (base,
Cities & Knights, Seafarers), Euchre, Kaiser, Wizard, Hearts, President, Crazy
Eights, Golf, Cribbage, Sequence, Tayu and Yahtzee.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-08 05:14:49 +00:00
devadmin f967c31368 Merge pull request 'Production: ship a non-debuggable release build' (#29) from fix/prod-release-build into dev
CI / Build and check (push) Successful in 9m0s
CI / Deploy to Cloudflare Pages (push) Successful in 32s
CI / Build and check (pull_request) Successful in 2m8s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-07 14:28:26 -06:00
Claude 4ed110e68c Production release: build and ship a non-debuggable release APK
CI / Build and check (pull_request) Successful in 12m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
release.yml shipped app-debug.apk, so production was debuggable. It now runs
assembleRelease, signed with the same keystore (identical certificate, so
updates still install over existing ones), with isDebuggable = false.
Minification stays off: R8 cannot be tried on a device here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-07 20:15:24 +00:00
devadmin b818997699 Merge pull request 'End game on every card game; Resume game menu' (#27) from feature/end-game-and-resume into dev
CI / Build and check (push) Successful in 9m35s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
CI / Build and check (pull_request) Successful in 9m21s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-11-07 12:35:01 -06:00
Claude aeda2924f4 End-game button on every card game; Resume game menu
CI / Build and check (pull_request) Successful in 9m33s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
President, Crazy Eights, Cribbage, Euchre, Golf, Kaiser, Sequence and Wizard
gain the "End game" button and the left-the-table dialog the other games have.
The main menu's inline Continue list becomes a Resume game button opening a
screen of all saved games, each with Resume and Delete (confirmed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-07 18:25:11 +00:00
devadmin 2e234c7f20 Merge pull request 'Katan: barbarians and improvement levels in the wide HUD' (#26) from fix/katan-landscape-hud into dev
CI / Build and check (push) Successful in 9m4s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-10-29 10:12:26 -06:00
Claude f4f53a93b6 Katan: show barbarian strip and improvement levels in the wide HUD
CI / Build and check (pull_request) Successful in 8m47s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-29 16:03:31 +00:00
devadmin bce9b68ece Merge pull request 'Katan: Seafarers rule set' (#14) from Seafarers into dev
CI / Build and check (push) Successful in 9m12s
CI / Build and check (pull_request) Successful in 9m16s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 32s
2026-10-28 21:27:34 -06:00
Claude 68cd799f89 Merge dev into Seafarers
CI / Build and check (pull_request) Successful in 9m1s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-29 03:04:13 +00:00
devadmin 6c549a8b76 Merge pull request 'Yahtzee: end-of-game stats dialog' (#23) from yahtzee-stats into dev
CI / Build and check (push) Successful in 8m46s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
CI / Build and check (pull_request) Successful in 8m46s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-26 22:36:19 -06:00
Claude ebab7a8415 Yahtzee: end-of-game stats dialog compared with previous games
CI / Build and check (pull_request) Successful in 8m51s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 04:26:52 +00:00
devadmin a0b15b3140 Merge pull request 'Yahtzee: two-column score card, larger text' (#22) from yahtzee-card-layout into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 30s
2026-10-26 22:00:12 -06:00
Claude 8990dde363 Yahtzee: two-column score card with larger text, scrolls sideways when needed
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 03:50:51 +00:00
devadmin c2b21a07f5 Merge pull request 'Yahtzee: pip dice, confirm zero scores; text size setting' (#21) from yahtzee-improvements into dev
CI / Build and check (push) Successful in 8m43s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-10-26 20:03:26 -06:00
Claude e98afbfd8d Yahtzee: pip dice, confirm zero scores; add text size setting
CI / Build and check (pull_request) Successful in 8m45s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0177JQTtqM7EzJyXYiZYEJTX
2026-10-27 01:52:15 +00:00
devadmin 2c90de66d1 Merge pull request 'Release Katan and Meowdoku to production' (#19) from fix/release-katan-meowdoku into dev
CI / Build and check (push) Successful in 8m50s
CI / Build and check (pull_request) Successful in 8m50s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 34s
2026-10-26 12:23:15 -06:00
Claude f93eba88e5 Release Katan and Meowdoku: open the production gate
CI / Build and check (pull_request) Successful in 8m43s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01S8evMuUxrft6TQj6NdAoqQ
2026-10-26 18:14:12 +00:00
devadmin 36ab518309 Merge pull request 'Scorekeeper: name players at setup; refocus after a round' (#17) from feature/scorekeeper-setup-names into dev
CI / Build and check (push) Successful in 9m36s
CI / Build and check (pull_request) Successful in 9m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
CI / Deploy to Cloudflare Pages (push) Successful in 36s
2026-10-26 10:35:39 -06:00
Claude 401ebe0118 Docs index: Seafarers
CI / Build and check (pull_request) Successful in 8m59s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
2026-10-24 17:19:11 +00:00
Claude 3c0469fc81 Seafarers: interaction tests, rules and UI docs 2026-10-24 17:18:59 +00:00
Claude 5d36b7bd62 Seafarers: rules tests and engine self-play, AI heads for new islands 2026-10-24 17:15:16 +00:00
Claude b5b4afa731 Merge remote-tracking branch 'origin/dev' into Seafarers 2026-10-24 17:13:14 +00:00
Claude f88099f4e2 WIP Seafarers engine, board and screen plumbing 2026-10-24 17:13:14 +00:00
88 changed files with 6946 additions and 720 deletions
+3 -3
View File
@@ -49,8 +49,8 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v4
- name: Build debug APK
run: ./gradlew assembleDebug --no-daemon --stacktrace
- name: Build release APK
run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: Stage APK for release
id: stage
@@ -65,7 +65,7 @@ jobs:
test -n "$offset"
# Same arithmetic as build.gradle.kts, so the tag names the APK's own number.
version="v${series}.$(( ${{ github.run_number }} + offset ))"
src="app/build/outputs/apk/debug/app-debug.apk"
src="app/build/outputs/apk/release/app-release.apk"
dest="ProLibertateGames-${version}.apk"
cp "$src" "$dest"
# The same build again under a name that never changes. GitHub's
+8 -2
View File
@@ -52,9 +52,15 @@ 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`) and Cities & Knights
sets chosen at setup: 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). The screen is layered; see
`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
`CatanBoard` directly, because a Seafarers map is not the 19-hex island
(Cities & Knights is always the standard island and still uses `CatanBoard`). 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.
## Bash output is filtered before it reaches the model
+5
View File
@@ -104,6 +104,11 @@ android {
buildTypes {
release {
// Signed with the same key as the debug builds, so a release installs
// over what is already on a device; a different key would make the
// updater's installs fail with a signature mismatch.
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -10,6 +10,7 @@ import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.settings.YahtzeeStatsRepository
import org.prolibertate.games.ui.AppEnv
import org.prolibertate.games.ui.AppRoot
import org.prolibertate.games.ui.theme.ProLibertateTheme
@@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() {
),
peerId = peerId,
saves = SavedGameStore(File(filesDir, "saved-games")),
yahtzeeStats = YahtzeeStatsRepository(applicationContext),
)
setContent {
@@ -362,12 +362,7 @@ object GameCatalog {
maxPlayers = 4,
teamBased = false,
blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
// Engine and screen exist but have not been verified on two real
// devices yet. Dev builds can open it as a *Preview*; a production
// build does not list it. Flip `available` on, and `devPreview`
// off, once two-device play has been verified.
available = false,
devPreview = true,
available = true,
),
GameDescriptor(
id = MEOWDOKU,
@@ -378,8 +373,7 @@ object GameCatalog {
teamBased = false,
blurb = "One cat in every row, column and colour, and no two touching. " +
"Wrong taps cost a heart; the board only ever accepts a cat that belongs.",
available = false,
devPreview = true,
available = true,
),
)
@@ -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,34 +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 }
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
// Gold: take what the seat wants most.
CatanPhase.GOLD -> chooseGold(state, seat, legal, profile)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>(), profile)
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>(), profile)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal, profile)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal, profile)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, seat, legal, profile)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal, profile)
CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
@@ -55,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
}
/**
@@ -70,9 +143,10 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* worth is what it reaches past it.
*/
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
val far = CatanBoard.otherEnd(place.edge, place.vertex)
return CatanBoard.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
val topo = state.topo
val far = topo.otherEnd(place.edge, place.vertex)
return topo.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in topo.VERTEX_NEIGHBORS[place.vertex] }
.maxOfOrNull { spotValue(state, it) }
?: 0.0
}
@@ -81,56 +155,145 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// Dice, discards and the robber
// -----------------------------------------------------------------------
private fun chooseDiscard(state: CatanState, seat: Int, discards: List<Discard>): Discard {
private fun chooseDiscard(
state: CatanState,
seat: Int,
discards: List<Discard>,
profile: AiProfile,
): Discard {
require(discards.isNotEmpty()) { "No legal discard" }
// Throw away what it wants least; grain and ore are the city, wool
// is mostly a dev-card tax.
return discards.minByOrNull { discard ->
discard.cards.sumOf { want(it) }
} ?: discards.first()
return when (profile.level) {
AiLevel.EASY -> profile.pick(discards.sortedBy { d -> d.cards.sumOf { want(it) } })
// Keep the hand that is closest to paying for something.
AiLevel.HARD -> {
val hand = state.resources[seat]
discards.maxByOrNull { d ->
val left = hand.toMutableMap()
for (c in d.cards) left[c] = (left[c] ?: 0) - 1
progress(left) - 0.01 * d.cards.sumOf { want(it) }
} ?: discards.first()
}
AiLevel.MEDIUM -> discards.minByOrNull { discard ->
discard.cards.sumOf { want(it) }
} ?: discards.first()
}
}
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
/** How close a hand is to the nearest of the things worth buying, a card count over a price, plus one if it pays. */
private fun progress(hand: Map<Resource, Int>): Double = SHOPPING.maxOf { cost ->
val have = cost.entries.sumOf { (r, n) -> minOf(maxOf(hand[r] ?: 0, 0), n) }
have.toDouble() / cost.values.sum() + if (covers(hand, cost)) 1.0 else 0.0
}
private fun chooseGold(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val picks = legal.filterIsInstance<PickGold>()
if (picks.isEmpty()) return legal.first()
return when (profile.level) {
AiLevel.EASY -> profile.pick(picks.sortedByDescending { p -> p.cards.sumOf { want(it) } })
AiLevel.HARD -> picks.maxByOrNull { p -> p.cards.sumOf { need(state, seat, it) } } ?: picks.first()
AiLevel.MEDIUM -> picks.maxByOrNull { pick -> pick.cards.sumOf { want(it) } } ?: legal.first()
}
}
private fun chooseRobber(
state: CatanState,
seat: Int,
moves: List<MoveRobber>,
profile: AiProfile,
): MoveRobber {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val 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
if (building.owner == seat) continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
score += worth * CatanBoard.numberOdds(state.numbers[hex]) / 36.0
val raw = worth * CatanBoard.numberOdds(state.numbers[hex])
if (building.owner == seat) {
// A hard seat will not sit the robber on its own income.
if (hard) score -= 0.8 * raw / 36.0
continue
}
score += raw / 36.0
if (building.owner == leader) score += spite * raw / 36.0
}
if (move.victim != null) {
score += 0.15 * state.resourceCount(move.victim) +
0.3 * points[move.victim]
val cards = state.resourceCount(move.victim)
if (!hard || cards > 0) {
score += 0.15 * cards + victimWeight * points[move.victim]
if (move.victim == leader) score += 0.4 * spite
}
}
score
} ?: moves.first()
return score
}
return if (profile.level == AiLevel.EASY) {
profile.pick(moves.sortedByDescending { score(it) })
} else {
moves.maxByOrNull { score(it) } ?: moves.first()
}
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
// A city doubles the best production the seat already owns.
legal.filterIsInstance<BuildCity>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val easy = profile.level == AiLevel.EASY
val hard = profile.level == AiLevel.HARD
val boldness = profile.style.boldness
val endTurn = legal.lastOrNull { it is EndTurn } ?: legal.first()
legal.filterIsInstance<BuildSettlement>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
// Easy loses the thread now and then and just passes.
if (easy && profile.slips() && profile.random.nextBoolean()) return endTurn
val cities = legal.filterIsInstance<BuildCity>()
val settlements = legal.filterIsInstance<BuildSettlement>()
if (hard && (cities.isNotEmpty() || settlements.isNotEmpty())) {
// A city doubles what the seat has; a settlement spreads it and
// takes a spot nobody else will. Bold seats lean to the spread.
val city = cities.maxByOrNull { spotValue(state, it.vertex) }
val settlement = settlements.maxByOrNull { hardSpot(state, seat, it.vertex) }
val cityScore = if (city != null) spotValue(state, city.vertex) + 0.15 else Double.NEGATIVE_INFINITY
val settleScore = if (settlement != null) {
hardSpot(state, seat, settlement.vertex) + 0.3 + 0.1 * boldness
} else {
Double.NEGATIVE_INFINITY
}
if (city != null && cityScore >= settleScore) return city
if (settlement != null) return settlement
} else {
// A city doubles the best production the seat already owns.
pickBest(cities, profile) { spotValue(state, it.vertex) }?.let { return it }
pickBest(settlements, profile) { spotValue(state, it.vertex) }?.let { return it }
}
// Seafarers: a ship toward an island nobody has landed on is worth more than a card.
legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.5 }
.maxByOrNull { shipValue(state, seat, it.edge) }
?.let { return it }
// 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.
@@ -140,11 +303,25 @@ 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.
legal.filterIsInstance<BuildShip>()
.filter { shipValue(state, seat, it.edge) > 0.15 }
.maxByOrNull { shipValue(state, seat, it.edge) }
?.let { return it }
// 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 }
@@ -155,16 +332,94 @@ class CatanAi : GameAi<CatanState, CatanMove> {
.maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 }
?.let { return it }
offer(state, seat)?.let { return it }
if (hard) {
// Pile of one card, short of another: convert before it is stuck in hand.
legal.filterIsInstance<MaritimeTrade>()
.filter { surplusTrade(state, seat, it) }
.minByOrNull { CatanRules.bestPortRatio(state, seat, it.give) }
?.let { return it }
}
return legal.lastOrNull { it is EndTurn } ?: legal.first()
// Stay under the seven: a cautious (or careful) seat sells down a big hand.
if ((hard || boldness < 0) && state.resourceCount(seat) > DISCARD_LIMIT) {
legal.filterIsInstance<MaritimeTrade>()
.maxByOrNull { t ->
(state.resources[seat][t.give] ?: 0) * 10.0 -
CatanRules.bestPortRatio(state, seat, t.give) * 3.0 + need(state, seat, t.get)
}
?.let { return it }
}
if (!(easy && profile.slips())) {
offer(state, seat, profile)?.let { return it }
}
return endTurn
}
/** Picks the best of [items] by [score]; easy sometimes takes a near miss. */
private fun <T> pickBest(items: List<T>, profile: AiProfile, score: (T) -> Double): T? {
if (items.isEmpty()) return null
if (profile.level != AiLevel.EASY) return items.maxByOrNull(score)
return profile.pick(items.sortedByDescending(score))
}
/** The cheapest things worth saving for, as the shopping list a hard seat weighs hands against. */
private val SHOPPING = listOf(
CatanRules.CITY_COST,
CatanRules.SETTLEMENT_COST,
CatanRules.DEV_COST,
CatanRules.ROAD_COST,
)
/** The shopping list entry the hand is closest to paying for, among a city, a settlement and a card. */
private fun nearestTarget(hand: Map<Resource, Int>): Map<Resource, Int> =
listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST, CatanRules.DEV_COST)
.minByOrNull { cost -> cost.entries.sumOf { (r, n) -> maxOf(0, n - (hand[r] ?: 0)) } }
?: CatanRules.CITY_COST
/** What one card of [res] is worth to this seat right now: more if the next build lacks it, less if it is piled up. */
private fun need(state: CatanState, seat: Int, res: Resource): Double {
val hand = state.resources[seat]
var v = want(res)
if ((hand[res] ?: 0) < (nearestTarget(hand)[res] ?: 0)) v += 0.6
if ((hand[res] ?: 0) >= 4) v -= 0.4
return v
}
/** Whether this bank trade turns a pile (beyond any price) into a card the next build lacks. */
private fun surplusTrade(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
val hand = state.resources[seat]
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
val keep = SHOPPING.maxOf { it[move.give] ?: 0 }
val target = nearestTarget(hand)
return (hand[move.give] ?: 0) - keep >= ratio &&
(hand[move.get] ?: 0) < (target[move.get] ?: 0)
}
/** Whether [seat] is in the hunt for Longest Road: close enough to the holder, or the road is unclaimed. */
private fun roadRace(state: CatanState, seat: Int): Boolean {
val holder = state.longestRoadOwner
if (holder == seat) return false
val mine = CatanRules.longestRoadFor(state, seat)
if (mine < 3) return false
return holder == null || mine + 1 >= CatanRules.longestRoadFor(state, holder)
}
/** The opponent with the most public points, if any. */
private fun leaderOf(state: CatanState, seat: Int): Int? {
val points = CatanRules.publicVictoryPoints(state)
return (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
}
/** Year of Plenty: two of whatever the nearest build is shortest of. */
private fun yearOfPlenty(state: CatanState, legal: List<CatanMove>): PlayYearOfPlenty? {
private fun yearOfPlenty(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): PlayYearOfPlenty? {
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
if (takes.isEmpty()) return null
val shortage = shortage(state) ?: return null
if (profile.level == AiLevel.HARD) {
return takes.maxByOrNull { take -> take.take.sumOf { need(state, seat, it) } }
}
return takes.maxByOrNull { take ->
take.take.count { it == shortage } * 2 + take.take.size
}
@@ -216,7 +471,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* At most one offer a turn: a declined offer changes nothing, so
* re-offering it would only bounce between the same two seats forever.
*/
private fun offer(state: CatanState, seat: Int): OfferTrade? {
private fun offer(state: CatanState, seat: Int, profile: AiProfile): OfferTrade? {
if (offeredThisTurn(state, seat)) return null
val seatHand = state.resources[seat]
val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
@@ -229,11 +484,16 @@ class CatanAi : GameAi<CatanState, CatanMove> {
.filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) }
.sortedByDescending { seatHand[it] ?: 0 }
if (surplus.isEmpty()) continue
val target = (0 until state.playerCount)
val holders = (0 until state.playerCount)
.filter { it != seat }
.filter { (state.resources[it][need] ?: 0) > 0 }
.maxByOrNull { state.resources[it][need] ?: 0 }
?: continue
val target = (if (profile.level == AiLevel.HARD) {
// Do not feed the leader when someone else can make the swap.
val points = CatanRules.publicVictoryPoints(state)
holders.maxByOrNull { (state.resources[it][need] ?: 0) - 0.5 * points[it] }
} else {
holders.maxByOrNull { state.resources[it][need] ?: 0 }
}) ?: continue
val give = listOf(surplus.first(), surplus.first())
val trade = OfferTrade(to = target, give = give, get = listOf(need))
if (CatanRules.isLegalMove(state, seat, trade)) return trade
@@ -256,38 +516,73 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// Free roads and trade responses
// -----------------------------------------------------------------------
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.05 }
.maxByOrNull { roadValue(state, it) }
return roads ?: legal.firstOrNull { it is EndFreeRoads }
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
?: legal.first()
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>().filter { roadValue(state, it) > 0.05 }
val road = if (profile.level == AiLevel.EASY) {
profile.pick(roads.sortedByDescending { roadValue(state, it) }.ifEmpty { return freeRoadFallback(legal) })
} else {
roads.maxByOrNull { roadValue(state, it) }
}
return road ?: freeRoadFallback(legal)
}
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
private fun freeRoadFallback(legal: List<CatanMove>): CatanMove =
legal.firstOrNull { it is EndFreeRoads }
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
?: legal.first()
/**
* How much extra the incoming cards must be worth to take a swap: nothing
* for the standard seat, a hard or spiteful seat wants more from the
* table's leader, and an easy seat is not too fussy.
*/
private fun tradeMargin(state: CatanState, seat: Int, other: Int, profile: AiProfile): Double {
var margin = 0.0
if (profile.level == AiLevel.EASY) margin -= 0.6
val leader = leaderOf(state, seat)
if (leader != null && other == leader) {
if (profile.level == AiLevel.HARD) margin += 0.4
margin += 0.5 * profile.style.spite
}
return margin
}
private fun tradeValue(state: CatanState, seat: Int, res: Resource, profile: AiProfile): Double =
if (profile.level == AiLevel.HARD) need(state, seat, res) else want(res)
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull()
val offer = state.pendingTrade
if (accept == null || offer == null) return legal.first { it is DeclineTrade }
// Accept when what arrives (the offerer's give) is worth more than what
// leaves (the offerer's get): a mild table, not a shark.
val incoming = offer.give.sumOf { want(it) }
val outgoing = offer.get.sumOf { want(it) }
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
val incoming = offer.give.sumOf { tradeValue(state, seat, it, profile) }
val outgoing = offer.get.sumOf { tradeValue(state, seat, it, profile) }
return if (incoming > outgoing + tradeMargin(state, seat, offer.by, profile)) {
accept
} else {
legal.first { it is DeclineTrade }
}
}
/**
* Picks the counter-offer that gains the most by the same valuation, or
* turns them all down. The AI never counters itself: it only answers.
*/
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove {
private fun chooseCounter(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val offer = state.pendingTrade ?: return legal.first()
fun gain(c: TradeCounter) = c.give.sumOf { tradeValue(state, seat, it, profile) } -
c.get.sumOf { tradeValue(state, seat, it, profile) }
val best = legal.filterIsInstance<AcceptCounter>()
.map { choice -> choice to offer.counters.first { it.by == choice.seat } }
.maxByOrNull { (_, c) -> c.give.sumOf { want(it) } - c.get.sumOf { want(it) } }
.maxByOrNull { (_, c) -> gain(c) }
if (best != null) {
val (choice, c) = best
if (c.give.sumOf { want(it) } > c.get.sumOf { want(it) }) return choice
if (c.give.sumOf { tradeValue(state, seat, it, profile) } >
c.get.sumOf { tradeValue(state, seat, it, profile) } + tradeMargin(state, seat, c.by, profile)
) {
return choice
}
}
return legal.first { it is DismissCounters }
}
@@ -301,13 +596,13 @@ class CatanAi : GameAi<CatanState, CatanMove> {
if (state.buildings[vertex] != null) return -1.0
var value = 0.0
val kinds = mutableSetOf<Resource>()
for (h in CatanBoard.VERTEX_HEXES[vertex]) {
for (h in state.topo.VERTEX_HEXES[vertex]) {
val resource = state.terrains[h].produces ?: continue
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
kinds += resource
}
value += 0.12 * kinds.size // variety keeps a build plan alive
for (e in CatanBoard.VERTEX_EDGES[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
}
@@ -320,15 +615,52 @@ class CatanAi : GameAi<CatanState, CatanMove> {
*/
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
var best = 0.0
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
val (from, to) = state.topo.EDGE_VERTICES[move.edge]
for (endpoint in listOf(from, to)) {
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
for (near in state.topo.VERTEX_NEIGHBORS[endpoint]) {
best = maxOf(best, spotValue(state, near) * 0.8)
}
}
return best
}
/**
* What a ship on [edge] is heading for: the best open settlement spot reachable by sea from
* its far end (the end the fleet does not already hold), discounted by how far off it is,
* with a bonus for an island nobody has landed on.
*/
internal fun shipValue(state: CatanState, seat: Int, edge: Int): Double {
val topo = state.topo
val sea = state.sea ?: return 0.0
val (a, b) = topo.EDGE_VERTICES[edge]
fun held(v: Int): Boolean =
state.buildings[v]?.owner == seat || topo.VERTEX_EDGES[v].any { sea.ships[it] == seat }
val front = listOf(a, b).filter { !held(it) }.ifEmpty { return 0.0 }
var best = 0.0
val seen = mutableSetOf<Int>()
var frontier = front
var depth = 0
while (frontier.isNotEmpty() && depth <= 5) {
val next = mutableListOf<Int>()
for (v in frontier) {
if (!seen.add(v)) continue
if (CatanRules.vertexOpenForSettlement(state, v)) {
val island = topo.vertexIsland[v]
val fresh = island >= 0 && island != sea.home && 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)
}
for (e in topo.VERTEX_EDGES[v]) {
if (!topo.edgeSea[e] || sea.ships[e] != NO_PLAYER) continue
next += topo.otherEnd(e, v)
}
}
frontier = next
depth++
}
return best
}
/** How much the AI wants to hold one card of [resource]. */
internal fun want(resource: Resource): Double = when (resource) {
Resource.ORE -> 1.20 // the city
@@ -337,4 +669,9 @@ class CatanAi : GameAi<CatanState, CatanMove> {
Resource.BRICK -> 0.95
Resource.WOOL -> 0.85 // abundant and only half of a dev card
}
private companion object {
/** A seven makes a seat holding more than this discard half. */
const val DISCARD_LIMIT = 7
}
}
@@ -5,13 +5,14 @@ import kotlin.math.atan2
import kotlin.random.Random
/**
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
* shared edges, the coast and the nine harbour positions.
* The geometry of one board: its hexes, the shared vertices and edges between
* them, and which hexes are land.
*
* Everything is derived once, deterministically, from axial coordinates —
* there is no randomness and no state here; the layout of a particular
* game (which terrain and number sits on which hex) is [generate], and the
* live state lives in [CatanState].
* game (which terrain and number sits on which hex) is generated elsewhere
* ([CatanBoard.generate], [SeaBoards.generate]), and the live state lives in
* [CatanState].
*
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
* position is kept on an exact integer lattice so vertices shared by
@@ -20,41 +21,25 @@ import kotlin.random.Random
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
* in half-units of the hex grid; rendering scales and flips them (lattice
* +Y is toward the top of the board).
*
* The standard island ([CatanBoard.base]) is the 19-hex hexagon. A Seafarers
* map is a larger frame whose hexes are mostly sea; [land] says which.
*/
object CatanBoard {
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
val HEXES: List<HexCoord> = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
}
class Topology(
/** Every hex of the frame, land and sea alike. */
val HEXES: List<HexCoord>,
/** Whether each hex is land. The base island is all land; Seafarers maps are mostly sea. */
val land: List<Boolean> = List(HEXES.size) { true },
/** The standard 19-hex island: checks its counts and uses the fixed nine-harbour frame. */
private val standardFrame: Boolean = false,
/** Harbours wanted on a non-standard map. */
harbours: Int = 0,
) {
val HEX_COUNT: Int = HEXES.size
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// -----------------------------------------------------------------------
// Vertices and edges
// -----------------------------------------------------------------------
@@ -93,9 +78,28 @@ object CatanBoard {
/** The coastal edges (those with only one hex) in ring order. */
val COASTAL: List<Int>
/** The nine fixed harbour positions, spread around the coast. */
/** The harbour positions: nine on the standard frame. */
val PORT_POSITIONS: List<Int>
// --- Land and sea (all land on the standard island) --------------------
/** A settlement may stand here: at least one of its hexes is land. */
val vertexLand: List<Boolean>
/** A road may lie here: at least one of its hexes is land. */
val edgeLand: List<Boolean>
/** A ship may sail here: at least one of its hexes is sea. */
val edgeSea: List<Boolean>
/** Which island each hex is on (a connected group of land hexes), or -1 for sea. */
val islandOf: List<Int>
val islandCount: Int
/** Which island each vertex touches, or -1 for open water. */
val vertexIsland: List<Int>
init {
// Vertices: assign ids in first-seen order over hexes then corners.
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
@@ -169,27 +173,99 @@ object CatanBoard {
// The coast in walking order: sort the coastal edges by the angle of
// their midpoint around the board centre, which is the lattice
// origin. The board is a hexagon, so this yields one closed ring.
// origin. The standard board is a hexagon, so this yields one closed ring.
COASTAL = EDGE_HEXES.withIndex()
.filter { it.value.size == 1 }
.map { (e, _) -> e }
.sortedBy { e -> angleOf(midpoint(e)) }
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
PORT_POSITIONS = buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
vertexLand = List(VERTEX_COUNT) { v -> VERTEX_HEXES[v].any { land[it] } }
edgeLand = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { land[it] } }
edgeSea = List(EDGE_COUNT) { e -> EDGE_HEXES[e].any { !land[it] } }
// Islands: connected groups of land hexes.
val island = IntArray(HEX_COUNT) { -1 }
var islands = 0
for (h in 0 until HEX_COUNT) {
if (!land[h] || island[h] != -1) continue
val stack = ArrayDeque(listOf(h))
island[h] = islands
while (stack.isNotEmpty()) {
val at = stack.removeLast()
for (n in HEX_NEIGHBORS[at]) {
if (land[n] && island[n] == -1) {
island[n] = islands
stack.addLast(n)
}
}
}
islands++
}
islandOf = island.toList()
islandCount = islands
vertexIsland = List(VERTEX_COUNT) { v ->
VERTEX_HEXES[v].firstOrNull { land[it] }?.let { island[it] } ?: -1
}
PORT_POSITIONS = if (standardFrame) {
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
} else {
seaPorts(harbours)
}
if (standardFrame) {
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
}
}
/**
* Harbour sites on a map with islands: edges where land meets sea, taken
* round the map by angle about the land's centre, [count] of them as evenly
* spaced as the coast allows, never two sharing a corner.
*/
private fun seaPorts(count: Int): List<Int> {
if (count <= 0) return emptyList()
val shore = (0 until EDGE_COUNT).filter { e ->
EDGE_HEXES[e].size == 2 && EDGE_HEXES[e].any { land[it] } && EDGE_HEXES[e].any { !land[it] }
}
if (shore.isEmpty()) return emptyList()
val landCentre = HEXES.indices.filter { land[it] }.let { hs ->
val pts = hs.map { hexCenter(it) }
(pts.sumOf { it.first } / pts.size.toDouble()) to (pts.sumOf { it.second } / pts.size.toDouble())
}
val ring = shore.sortedBy { e ->
val m = midpoint(e)
atan2(m.second - landCentre.second, m.first - landCentre.first)
}
val picked = mutableListOf<Int>()
val used = mutableSetOf<Int>()
val step = ring.size.toDouble() / count
for (i in 0 until count) {
val start = (i * step).toInt()
// The first edge at or after the ideal spot whose corners are free.
val e = (0 until ring.size).map { ring[(start + it) % ring.size] }.firstOrNull { cand ->
val (a, b) = EDGE_VERTICES[cand]
a !in used && b !in used && cand !in picked
} ?: continue
picked += e
val (a, b) = EDGE_VERTICES[e]
used += a
used += b
}
return picked
}
/** The other endpoint of [edge], given one [vertex] on it. */
@@ -207,19 +283,14 @@ object CatanBoard {
/** Whether two hexes share an edge. */
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
/** Hex-grid distance between two axial positions. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
val dq = q1 - q2
val dr = r1 - r2
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
/** Lattice extent of the whole frame: min x, max x, min y, max y. */
val lattice: IntArray by lazy {
intArrayOf(
VERTEX_POS.minOf { it.first }, VERTEX_POS.maxOf { it.first },
VERTEX_POS.minOf { it.second }, VERTEX_POS.maxOf { it.second },
)
}
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
private fun midpoint(edge: Int): Pair<Double, Double> {
val (a, b) = EDGE_VERTICES[edge]
val pa = VERTEX_POS[a]
@@ -230,6 +301,84 @@ object CatanBoard {
private fun angleOf(point: Pair<Double, Double>): Double =
atan2(point.second, point.first)
companion object {
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** Hex-grid distance between two axial positions. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
val dq = q1 - q2
val dr = r1 - r2
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
}
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
}
}
/**
* The standard island: the 19 hexes, the 54 shared vertices, the 72 shared
* edges, the coast and the nine harbour positions. Every table below is the
* standard frame's [Topology]; the rules read the topology of the *state* they
* are working on ([CatanState.topo]), which is this one in every game but Seafarers.
*/
object CatanBoard {
/** The standard island's geometry. */
val base: Topology = Topology(
HEXES = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (Topology.hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
},
standardFrame = true,
)
val HEXES: List<HexCoord> get() = base.HEXES
val HEX_COUNT: Int get() = base.HEX_COUNT
val hexIndex: Map<HexCoord, Int> get() = base.hexIndex
val VERTEX_POS: List<Pair<Int, Int>> get() = base.VERTEX_POS
val VERTEX_COUNT: Int get() = base.VERTEX_COUNT
val HEX_CORNERS: List<List<Int>> get() = base.HEX_CORNERS
val EDGE_VERTICES: List<Pair<Int, Int>> get() = base.EDGE_VERTICES
val EDGE_COUNT: Int get() = base.EDGE_COUNT
val VERTEX_EDGES: List<List<Int>> get() = base.VERTEX_EDGES
val VERTEX_NEIGHBORS: List<List<Int>> get() = base.VERTEX_NEIGHBORS
val VERTEX_HEXES: List<List<Int>> get() = base.VERTEX_HEXES
val EDGE_HEXES: List<List<Int>> get() = base.EDGE_HEXES
val EDGE_NEIGHBORS: List<List<Int>> get() = base.EDGE_NEIGHBORS
val HEX_NEIGHBORS: List<List<Int>> get() = base.HEX_NEIGHBORS
val COASTAL: List<Int> get() = base.COASTAL
val PORT_POSITIONS: List<Int> get() = base.PORT_POSITIONS
fun otherEnd(edge: Int, vertex: Int): Int = base.otherEnd(edge, vertex)
fun hexCenter(h: Int): Pair<Int, Int> = base.hexCenter(h)
fun hexesAdjacent(a: Int, b: Int): Boolean = base.hexesAdjacent(a, b)
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int = Topology.hexDistance(q1, r1, q2, r2)
fun numberOdds(number: Int): Int = Topology.numberOdds(number)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// -----------------------------------------------------------------------
// Island generation
// -----------------------------------------------------------------------
@@ -273,6 +273,9 @@ object CatanCk {
CatanPhase.FREE_ROADS ->
CatanRules.roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
// Seafarers is never combined with Cities & Knights.
CatanPhase.GOLD -> emptyList()
CatanPhase.TRADE_PENDING -> {
val offer = state.ck!!.goodsOffer
if (offer == null) {
@@ -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,42 @@ 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.GAME_OVER -> legal.firstOrNull()
CatanPhase.DISCARDING, CatanPhase.GOLD, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
@@ -43,18 +61,25 @@ internal class CatanCkAi(private val ai: CatanAi) {
// Chores
// -----------------------------------------------------------------------
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val ck = state.ck!!
return when (val chore = ck.chores.first()) {
is DiscardChore -> {
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
DiscardGoods(CatanCk.goodsList(state, seat).sortedBy { worth(state, seat, it) }.take(n))
val hand = CatanCk.goodsList(state, seat)
// Easy now and then throws away whatever comes to hand.
val order = if (profile.slips()) {
hand.shuffled(profile.random)
} else {
hand.sortedBy { worth(state, seat, it, profile) }
}
DiscardGoods(order.take(n))
}
is GiveChore -> {
val pool = CatanCk.goodsList(state, seat)
.filter { !chore.commoditiesOnly || it.isCommodity }
.sortedBy { worth(state, seat, it) }
.sortedBy { worth(state, seat, it, profile) }
GiveGoods(pool.take(minOf(chore.count, pool.size)))
}
@@ -98,32 +123,57 @@ internal class CatanCkAi(private val ai: CatanAi) {
// The robber
// -----------------------------------------------------------------------
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>, profile: AiProfile): MoveRobber {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val hard = profile.level == AiLevel.HARD
val spite = profile.style.spite
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
val victimWeight = 0.3 + 0.1 * profile.style.boldness
fun score(move: MoveRobber): Double {
val hex = CatanBoard.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
score += if (building.owner == seat) -worth * odds else worth * odds
score += if (building.owner == seat) {
-worth * odds * (if (hard) 1.5 else 1.0)
} else {
worth * odds + if (building.owner == leader) spite * worth * odds else 0.0
}
}
if (move.victim != null) {
score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim]
val cards = CatanCk.goodsCount(state, move.victim)
if (!hard || cards > 0) {
score += 0.15 * cards + victimWeight * points[move.victim]
if (move.victim == leader) score += 0.4 * spite
}
}
score
} ?: moves.first()
return score
}
return if (profile.level == AiLevel.EASY) {
profile.pick(moves.sortedByDescending { score(it) })
} else {
moves.maxByOrNull { score(it) } ?: moves.first()
}
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val ck = state.ck!!
val soon = ck.barbarians <= 3
val easy = profile.level == AiLevel.EASY
val hard = profile.level == AiLevel.HARD
val boldness = profile.style.boldness
// A cautious seat starts getting ready a step earlier.
val soon = ck.barbarians <= (if (boldness < 0) 4 else 3)
val endTurn = legal.lastOrNull { it is EndTurn }
// Easy loses the thread now and then and just passes.
if (easy && endTurn != null && profile.slips() && profile.random.nextBoolean()) return endTurn
// Chase the robber off a hex it is squatting on.
if (ck.attacked) {
@@ -138,11 +188,29 @@ internal class CatanCkAi(private val ai: CatanAi) {
.maxByOrNull { production(state, it.vertex!!) }
?.let { return it }
legal.filterIsInstance<BuildCity>().maxByOrNull { production(state, it.vertex) }
?.let { return it }
val cities = legal.filterIsInstance<BuildCity>()
val settlements = legal.filterIsInstance<BuildSettlement>()
if (hard) {
val city = cities.maxByOrNull { production(state, it.vertex) }
val settlement = settlements.maxByOrNull { ai.hardSpot(state, seat, it.vertex) }
val cityScore = if (city != null) production(state, city.vertex) + 0.15 else Double.NEGATIVE_INFINITY
val settleScore = if (settlement != null) {
ai.hardSpot(state, seat, settlement.vertex) + 0.3 + 0.1 * boldness
} else {
Double.NEGATIVE_INFINITY
}
if (city != null && cityScore >= settleScore) return city
if (settlement != null) return settlement
} else if (easy) {
if (cities.isNotEmpty()) return profile.pick(cities.sortedByDescending { production(state, it.vertex) })
if (settlements.isNotEmpty()) {
return profile.pick(settlements.sortedByDescending { ai.spotValue(state, it.vertex) })
}
} else {
cities.maxByOrNull { production(state, it.vertex) }?.let { return it }
legal.filterIsInstance<BuildSettlement>().maxByOrNull { ai.spotValue(state, it.vertex) }
?.let { return it }
settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it }
}
// Improvements spend cards nothing else needs. Favour the track furthest along.
legal.filterIsInstance<BuyImprovement>()
@@ -150,16 +218,16 @@ internal class CatanCkAi(private val ai: CatanAi) {
?.let { return it }
// Knights: stand ready by the time the barbarians land.
defence(state, seat, legal, soon)?.let { return it }
if (!(easy && profile.slips())) defence(state, seat, legal, soon, profile)?.let { return it }
progress(state, seat, legal)?.let { return it }
if (!(easy && profile.slips())) progress(state, seat, legal)?.let { return it }
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
if (CatanCk.goodsCount(state, seat) >= 7) return it
if (CatanCk.goodsCount(state, seat) >= (if (boldness < 0) 5 else 7)) return it
}
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.1 }
.filter { ai.roadValue(state, it) > 0.1 - 0.04 * boldness }
.maxByOrNull { ai.roadValue(state, it) }
?.let { return it }
@@ -168,18 +236,43 @@ internal class CatanCkAi(private val ai: CatanAi) {
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
?.let { return it }
return legal.lastOrNull { it is EndTurn } ?: legal.first()
// Stay under the seven: a cautious (or careful) seat sells down a big hand.
if ((hard || boldness < 0) && CatanCk.goodsCount(state, seat) > 7) {
legal.filterIsInstance<GoodsBankTrade>()
.maxByOrNull { t ->
held(state, seat, t.give) * 10.0 - CatanCk.bankRatio(state, seat, t.give) * 3.0 +
worth(state, seat, t.get, profile)
}
?.let { return it }
}
return endTurn ?: legal.first()
}
/** Hire, promote and activate so the knights are standing when the barbarians land. */
private fun defence(state: CatanState, seat: Int, legal: List<CatanMove>, soon: Boolean): CatanMove? {
private fun defence(
state: CatanState,
seat: Int,
legal: List<CatanMove>,
soon: Boolean,
profile: AiProfile,
): CatanMove? {
val mine = CatanCk.knightsOf(state, seat)
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
// Hard sizes the defence to the threat: the barbarians bring one strength
// per city on the board, and once the table's active knights outweigh them
// and this seat pulls its weight, the rest of its goods go elsewhere.
if (profile.level == AiLevel.HARD && soon && CatanCk.strength(state, seat) >= 1) {
val threat = state.buildings.count { it?.type == BuildingType.CITY }
val table = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
if (table > threat) return null
}
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
val want = if (soon) 4 else 2
val want = if (soon) 4 else if (profile.style.boldness > 0.5) 3 else 2
if (mine.size < want) {
legal.filterIsInstance<HireKnight>().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire ->
// Keep a grain back for waking it, once the ship is close.
@@ -289,26 +382,44 @@ internal class CatanCkAi(private val ai: CatanAi) {
}
}
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove =
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.05 }
.maxByOrNull { ai.roadValue(state, it) }
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>().filter { ai.roadValue(state, it) > 0.05 }
val road = when {
roads.isEmpty() -> null
profile.level == AiLevel.EASY -> profile.pick(roads.sortedByDescending { ai.roadValue(state, it) })
else -> roads.maxByOrNull { ai.roadValue(state, it) }
}
return road
?: legal.firstOrNull { it is EndFreeRoads }
?: legal.first()
}
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
/** Extra the incoming cards must be worth: easy is not fussy, hard and spiteful seats are tougher on the leader. */
private fun tradeMargin(state: CatanState, seat: Int, other: Int, profile: AiProfile): Double {
var margin = 0.0
if (profile.level == AiLevel.EASY) margin -= 0.6
val points = CatanRules.publicVictoryPoints(state)
val leader = (0 until state.playerCount).filter { it != seat }.maxByOrNull { points[it] }
if (leader != null && other == leader) {
if (profile.level == AiLevel.HARD) margin += 0.4
margin += 0.5 * profile.style.spite
}
return margin
}
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>, profile: AiProfile): CatanMove {
val decline = legal.first { it is DeclineTrade }
val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline
val offer = state.ck?.goodsOffer
if (offer != null) {
val incoming = offer.give.sumOf { worth(state, seat, it) }
val outgoing = offer.get.sumOf { worth(state, seat, it) }
return if (incoming > outgoing) accept else decline
val incoming = offer.give.sumOf { worth(state, seat, it, profile) }
val outgoing = offer.get.sumOf { worth(state, seat, it, profile) }
return if (incoming > outgoing + tradeMargin(state, seat, offer.by, profile)) accept else decline
}
val plain = state.pendingTrade ?: return decline
val incoming = plain.give.sumOf { ai.want(it) }
val outgoing = plain.get.sumOf { ai.want(it) }
return if (incoming > outgoing) accept else decline
return if (incoming > outgoing + tradeMargin(state, seat, plain.by, profile)) accept else decline
}
/** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */
@@ -320,12 +431,22 @@ internal class CatanCkAi(private val ai: CatanAi) {
// -----------------------------------------------------------------------
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
private fun worth(state: CatanState, seat: Int, good: Good): Double =
good.resource?.let { ai.want(it) } ?: when (good) {
private fun worth(state: CatanState, seat: Int, good: Good, profile: AiProfile = AiProfile.DEFAULT): Double {
val base = good.resource?.let { ai.want(it) } ?: when (good) {
Good.PAPER -> 0.95
Good.COIN -> 0.9
else -> 0.85
}
if (profile.level != AiLevel.HARD) return base
// Hard keeps the commodity an improvement is about to need.
var bonus = if (good.resource == Resource.ORE || good.resource == Resource.GRAIN) 0.1 else 0.0
for (track in Track.entries) {
if (Good.of(track.commodity) != good) continue
val level = CatanCk.improvement(state, seat, track)
if (level < IMPROVEMENT_MAX && held(state, seat, good) <= level + 1) bonus += 0.5
}
return base + bonus
}
/** Which track to lean toward when two are level: politics for the knights, then science. */
private fun trackLean(track: Track): Int = when (track) {
@@ -39,6 +39,12 @@ enum class Terrain(val produces: Resource?) {
HILLS(Resource.BRICK),
MOUNTAINS(Resource.ORE),
DESERT(null),
/** Seafarers: open water. Ships sail on it; nothing is built on it and nothing produces. */
SEA(null),
/** Seafarers: a gold field. A settlement beside it takes any resource its owner likes when its number rolls. */
GOLD(null),
}
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
@@ -114,6 +120,9 @@ enum class CatanPhase {
/** The player whose turn it is must roll the dice. */
AWAIT_ROLL,
/** Seafarers: the first seat in [SeaState.gold] is choosing the resources its gold fields paid. */
GOLD,
/** Players holding more than 7 resource cards discard half. */
DISCARDING,
@@ -150,6 +159,9 @@ enum class CatanRuleSet {
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS,
/** Seafarers: ships, a pirate, gold fields and islands to settle (see `docs/RULES-catan-seafarers.md`). */
SEAFARERS,
}
@Serializable
@@ -230,9 +242,17 @@ data class CatanState(
val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null,
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
val sea: SeaState? = null,
) {
val playerCount: Int get() = options.playerCount
/** The geometry this game is played on: the standard island, or a Seafarers map. */
val topo: Topology get() = sea?.let { SeaBoards.topology(it.scenario) } ?: CatanBoard.base
/** Whether this table plays Seafarers. */
val isSea: Boolean get() = sea != null
/** Whether this table plays Cities & Knights. */
val isCk: Boolean get() = ck != null
@@ -377,3 +397,48 @@ data object EndTurn : CatanMove
@Serializable
@SerialName("endFreeRoads")
data object EndFreeRoads : CatanMove
/**
* Seafarers state: the ships, the pirate and the islands, alongside the base game's
* roads, robber and buildings (which keep working exactly as before).
*/
@Serializable
data class SeaState(
val scenario: SeaScenario,
/** Owning seat per edge, or [NO_PLAYER]: a ship on that edge. Roads stay in [CatanState.roads]. */
val ships: List<Int>,
/** The hex the pirate occupies: always a sea hex. */
val pirate: Int,
/** Ships built this turn (they may not sail until the next) and whether a ship has sailed this turn. */
val builtThisTurn: List<Int> = emptyList(),
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. */
val home: Int,
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
val gold: List<GoldOwed> = emptyList(),
)
@Serializable
data class GoldOwed(val seat: Int, val count: Int)
/** Build a ship on [edge]: 1 lumber and 1 wool, on a sea edge joined to your fleet or a harbour town. */
@Serializable
@SerialName("ship")
data class BuildShip(val edge: Int) : CatanMove
/** Sail the ship on [from] to [to]: once a turn, never the turn it was built, only from the open end of a route. */
@Serializable
@SerialName("moveShip")
data class MoveShip(val from: Int, val to: Int) : CatanMove
/** Move the pirate to the sea hex [hex] and rob [victim], a seat with a ship beside it, if there is one. */
@Serializable
@SerialName("pirate")
data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
/** Choose the [cards] a gold field paid. */
@Serializable
@SerialName("gold")
data class PickGold(val cards: List<Resource>) : CatanMove
@@ -54,7 +54,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
val board = CatanBoard.generate(config.seed)
val sea = options.ruleSet == CatanRuleSet.SEAFARERS
val scenario = SeaScenario.NEW_SHORES
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
?: CatanBoard.generate(config.seed)
// A different random stream than the island, so the same seed still
// produces both a fixed island and a fixed deck.
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
@@ -79,8 +84,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
numbers = board.numbers,
robber = board.robber,
ports = board.ports,
buildings = List(CatanBoard.VERTEX_COUNT) { null },
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER },
buildings = List(topo.VERTEX_COUNT) { null },
roads = List(topo.EDGE_COUNT) { NO_PLAYER },
resources = List(options.playerCount) {
Resource.entries.associateWith { 0 }
},
@@ -98,10 +103,19 @@ object CatanRules : GameRules<CatanState, CatanMove> {
rngStep = 0,
winner = null,
log = listOf(
"Catan${if (ck) " — Cities & Knights" else ""} — ${options.playerCount} players, " +
"Catan${if (ck) " — Cities & Knights" else if (sea) " — Seafarers: ${scenario.title}" else ""} — ${options.playerCount} players, " +
"first to ${options.pointsToWin} victory points.",
),
ck = if (ck) CatanCk.initial(options, config.seed) else null,
sea = seaBoard?.let {
SeaState(
scenario = scenario,
ships = List(topo.EDGE_COUNT) { NO_PLAYER },
pirate = it.pirate,
islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home,
)
},
)
}
@@ -114,6 +128,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
CatanPhase.TRADE_COUNTERS -> state.turn
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
CatanPhase.GOLD -> state.sea?.gold?.firstOrNull()?.seat
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn
@@ -162,12 +177,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.DISCARDING -> discardMoves(state, seat)
CatanPhase.ROBBER -> robberMoves(state, seat)
CatanPhase.ROBBER -> robberMoves(state, seat) +
(if (state.isSea) SeaRules.pirateMoves(state, seat) else emptyList())
CatanPhase.PLAYING -> playingMoves(state, seat)
CatanPhase.FREE_ROADS ->
roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
roadMoves(state, seat) +
(if (state.isSea) SeaRules.buildShipMoves(state, seat) else emptyList()) +
listOf<CatanMove>(EndFreeRoads)
CatanPhase.GOLD -> SeaRules.goldMoves(state)
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
@@ -179,10 +199,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
internal fun setupMoves(state: CatanState): List<SetupPlace> {
val moves = mutableListOf<SetupPlace>()
for (v in 0 until CatanBoard.VERTEX_COUNT) {
for (v in 0 until state.topo.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue
for (e in CatanBoard.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER) continue
// Seafarers: everyone starts on the home island.
if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
moves += SetupPlace(v, e)
}
}
@@ -215,18 +237,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val moves = mutableListOf<MoveRobber>()
for (h in 0 until CatanBoard.HEX_COUNT) {
if (h == state.robber) continue
val victims = CatanBoard.HEX_CORNERS[h]
for (h in 0 until state.topo.HEX_COUNT) {
if (h == state.robber || !state.topo.land[h]) continue
val victims = state.topo.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] }
.map { it.owner }
.filter { it != seat }
.distinct()
.filter { state.resourceCount(it) > 0 }
if (victims.isEmpty()) {
moves += MoveRobber(CatanBoard.HEXES[h])
moves += MoveRobber(state.topo.HEXES[h])
} else {
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
victims.forEach { moves += MoveRobber(state.topo.HEXES[h], it) }
}
}
return moves
@@ -245,6 +267,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
moves += BuyDevCard
}
if (state.isSea) {
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
moves += SeaRules.moveShipMoves(state, seat)
}
if (!state.devCardPlayedThisTurn) {
for (card in state.devHands[seat]) {
@@ -301,8 +327,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList()
} else {
(0 until CatanBoard.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
(0 until state.topo.EDGE_COUNT)
.filter {
state.roads[it] == NO_PLAYER && state.topo.edgeLand[it] &&
state.sea?.ships?.get(it).let { ship -> ship == null || ship == NO_PLAYER } &&
roadConnects(state, seat, it)
}
.map { BuildRoad(it) }
}
@@ -316,9 +346,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
(0 until state.topo.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
state.topo.VERTEX_EDGES[v].any { state.roads[it] == seat || state.sea?.ships?.get(it) == seat }
}
}
@@ -326,7 +356,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
(0 until state.topo.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
}
}
@@ -373,8 +403,8 @@ 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) &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/**
* A road on [edge] would join [seat]'s network of roads and buildings.
@@ -385,7 +415,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* of your own roads ending at the same crossing.
*/
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
val (a, b) = state.topo.EDGE_VERTICES[edge]
return networkReaches(state, seat, a) || networkReaches(state, seat, b)
}
@@ -397,7 +427,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (knight != null && knight.owner != seat) return false
// Your own building is always a way in; anyone else's is a wall.
if (building != null) return building.owner == seat
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
return state.topo.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
}
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
@@ -423,6 +453,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is RollDice -> applyRoll(state, seat, move)
is Discard -> applyDiscard(state, seat, move)
is MoveRobber -> applyRobber(state, seat, move)
is BuildShip -> applyBuildShip(state, seat, move)
is MoveShip -> applyMoveShip(state, seat, move)
is MovePirate -> applyMovePirate(state, seat, move)
is PickGold -> applyPickGold(state, seat, move)
is BuildRoad -> applyBuildRoad(state, seat, move)
is BuildSettlement -> applyBuildSettlement(state, seat, move)
is BuildCity -> applyBuildCity(state, seat, move)
@@ -461,7 +495,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (second) {
// The second settlement earns one card from each adjacent
// producing hex, supply permitting.
val adjacent = CatanBoard.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
val adjacent = state.topo.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) ->
need <= bankSupply(state, res)
}
@@ -517,11 +551,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
for (resource in Resource.entries) {
var demand = 0
for (h in 0 until CatanBoard.HEX_COUNT) {
for (h in 0 until state.topo.HEX_COUNT) {
if (state.numbers[h] != number) continue
if (h == state.robber) continue
if (state.terrains[h].produces != resource) continue
for (v in CatanBoard.HEX_CORNERS[h]) {
for (v in state.topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
val amount = if (building.type == BuildingType.CITY) 2 else 1
gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount
@@ -535,6 +569,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
}
// Seafarers: gold fields pay a card of the owner's choice, asked for after the rest is dealt.
val goldOwed = if (state.isSea) goldOwed(state, number) else emptyList()
var s = state.copy(phase = CatanPhase.PLAYING)
val whoGot = mutableListOf<String>()
for (seat in 0 until s.playerCount) {
@@ -543,10 +580,31 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(resources = withCounts(s.resources, seat, got, 1))
whoGot += "seat $seat takes ${describe(got)}"
}
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ")
return s.copy(
val goldLine = goldOwed.joinToString("") { ", seat ${it.seat} is owed ${it.count} gold" }
val said = if (whoGot.isEmpty() && goldOwed.isEmpty()) "nothing for anyone" else
(whoGot.joinToString(", ") + goldLine).trimStart(',', ' ')
s = s.copy(
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
)
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> {
val topo = state.topo
val owed = IntArray(state.playerCount)
for (h in 0 until topo.HEX_COUNT) {
if (state.terrains[h] != Terrain.GOLD || state.numbers[h] != number || h == state.robber) continue
for (v in topo.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
owed[building.owner] += if (building.type == BuildingType.CITY) 2 else 1
}
}
return (0 until state.playerCount)
.map { (state.turn + it) % state.playerCount }
.filter { owed[it] > 0 }
.map { GoldOwed(it, owed[it]) }
}
private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState {
@@ -565,7 +623,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
if (state.isCk) return CatanCk.applyRobber(state, seat, move)
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)
var stolen: String? = null
@@ -599,6 +657,68 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
}
private fun applyBuildShip(state: CatanState, seat: Int, move: BuildShip): CatanState {
val free = state.phase == CatanPhase.FREE_ROADS
val sea = state.sea!!
var s = state.copy(
sea = sea.copy(
ships = sea.ships.toMutableList().also { it[move.edge] = seat },
builtThisTurn = sea.builtThisTurn + move.edge,
),
)
if (!free) s = payCost(s, seat, SeaRules.SHIP_COST)
s = refreshLongestRoad(s)
if (free) {
val left = s.pendingFreeRoads - 1
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}."))
}
private fun applyMoveShip(state: CatanState, seat: Int, move: MoveShip): CatanState {
val sea = state.sea!!
val ships = sea.ships.toMutableList().also {
it[move.from] = NO_PLAYER
it[move.to] = seat
}
// The ship is on a new edge, but it has sailed for the turn and may not sail again.
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}.")
})
}
private fun applyMovePirate(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 stolen = ""
if (move.victim != null) {
val rng = Random(state.seed * 1_000_003L + state.rngStep)
val victimHand = state.handOf(move.victim)
val card = victimHand[rng.nextInt(victimHand.size)]
val less = withCards(s.resources, move.victim, listOf(card), -1)
s = s.copy(resources = withCards(less, seat, listOf(card), 1))
stolen = " and takes a ${card.name.lowercase()} from seat ${move.victim}"
}
return s.copy(
phase = CatanPhase.PLAYING,
log = s.log + "Seat $seat sends the pirate to (${move.hex.q},${move.hex.r})$stolen.",
)
}
private fun applyPickGold(state: CatanState, seat: Int, move: PickGold): CatanState {
val sea = state.sea!!
val s = state.copy(
resources = withCards(state.resources, seat, move.cards, 1),
sea = sea.copy(gold = sea.gold.drop(1)),
log = state.log + "Seat $seat takes ${describe(countOf(move.cards))} from the gold.",
)
return SeaRules.settleGold(s)
}
private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState {
var s = state.copy(
buildings = state.buildings.toMutableList().also {
@@ -606,6 +726,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
},
)
s = payCost(s, seat, SETTLEMENT_COST)
s = SeaRules.claimIsland(s, seat, move.vertex)
// A settlement can break another player's road, so the award is
// recomputed for everyone.
s = refreshLongestRoad(s)
@@ -831,6 +952,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
phase = CatanPhase.AWAIT_ROLL,
devCardPlayedThisTurn = false,
pendingTrade = null,
sea = state.sea?.copy(builtThisTurn = emptyList(), shipMoved = false),
)
// Someone can cross the target on another player's turn (a broken
// Longest Road re-awarded to a third player); they win when their
@@ -869,6 +991,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
state.longestRoadOwner?.let { points[it] += 5 }
state.largestArmyOwner?.let { points[it] += 5 }
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
return points.toList()
}
@@ -927,16 +1050,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* one road, not two — the intersection is a branch).
*/
fun longestRoadFor(state: CatanState, seat: Int): Int {
if (state.isSea) return SeaRules.longestRouteFor(state, seat)
var best = 0
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT)
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val blocked = BooleanArray(state.topo.VERTEX_COUNT)
for (v in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[v]
if (building != null && building.owner != seat) blocked[v] = true
}
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
for (start in 0 until CatanBoard.VERTEX_COUNT) {
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
for (start in 0 until state.topo.VERTEX_COUNT) {
if (state.topo.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(state.topo.VERTEX_COUNT)
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
}
return best
@@ -951,9 +1075,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
): Int {
visited[v] = true
var best = 0
for (e in CatanBoard.VERTEX_EDGES[v]) {
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != seat) continue
val w = CatanBoard.otherEnd(e, v)
val w = state.topo.otherEnd(e, v)
if (visited[w]) continue
if (blocked[w]) {
// The segment may run up to an opponent's building, but not
@@ -1039,7 +1163,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
for (port in state.ports) {
if (port.resource != null && port.resource != give) continue
if (port.ratio >= best) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
if (mine) best = port.ratio
}
@@ -1050,7 +1174,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
fun genericPortRatio(state: CatanState, seat: Int): Int {
for (port in state.ports) {
if (port.resource != null) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
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
@@ -0,0 +1,144 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.Serializable
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) {
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.",
),
}
/**
* The Seafarers maps as geometry plus a generator for one game's tiles.
*
* A map is drawn below as text: `.` is sea, `#` a land hex that gets a random resource,
* `$` a gold field, `D` the desert. Odd rows are shifted half a hex to the right (the usual
* "odd-r" offset layout). The outermost ring is always sea, so every coast is closed.
*/
object SeaBoards {
private val NEW_SHORES_MAP = listOf(
"..........",
"...##.....",
"..####.$#.",
".#####..#.",
".##D###...",
"..####..#.",
".......##.",
".$#.......",
"..........",
)
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
}
private val topologies = HashMap<SeaScenario, Topology>()
/** The frame of [scenario]: every hex, with its land mask. Built once and shared. */
fun topology(scenario: SeaScenario): Topology = synchronized(topologies) {
topologies.getOrPut(scenario) { build(scenario) }
}
/** Hex (row, col) of the text map as an axial coordinate (odd-r offset). */
private fun coord(row: Int, col: Int): HexCoord = HexCoord(col - (row - (row and 1)) / 2, row)
private fun build(scenario: SeaScenario): Topology {
val map = rows(scenario)
val hexes = mutableListOf<HexCoord>()
val land = mutableListOf<Boolean>()
map.forEachIndexed { row, text ->
text.forEachIndexed { col, ch ->
hexes += coord(row, col)
land += ch != '.'
}
}
return Topology(hexes, land, standardFrame = false, harbours = 9)
}
/** Terrain kind the map asks of each hex: sea, gold, desert, or "pick a resource". */
private fun kinds(scenario: SeaScenario): List<Char> = rows(scenario).flatMap { it.toList() }
/** One game's tiles on a Seafarers map. */
@Serializable
data class Generated(
val terrains: List<Terrain>,
val numbers: List<Int>,
val robber: Int,
val pirate: Int,
val ports: List<Port>,
val home: Int,
)
private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
)
/** Number tokens: the standard 18 plus ten more, so every land hex but the desert has one. */
private val TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 6, 6, 8, 8, 9, 9, 10, 10, 11, 11, 12) +
listOf(3, 4, 5, 6, 8, 9, 10, 11, 5, 9)
fun generate(scenario: SeaScenario, seed: Long): Generated {
val topo = topology(scenario)
val kinds = kinds(scenario)
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 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)]
else -> Terrain.SEA
}
}
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT }
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
val tokens = TOKENS.take(numbered.size).shuffled(random)
val numbers = IntArray(terrains.size)
var placed = false
var attempt = 0
while (!placed && attempt < 200) {
val order = numbered.shuffled(random)
val assigned = IntArray(terrains.size)
order.forEachIndexed { i, h -> assigned[h] = tokens[i] }
val reds = numbered.filter { assigned[it] == 6 || assigned[it] == 8 }
val clash = reds.any { a -> reds.any { b -> a < b && topo.hexesAdjacent(a, b) } }
if (!clash || attempt == 199) {
assigned.copyInto(numbers)
placed = true
}
attempt++
}
val portTypes = (
listOf(Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE) + List(4) { null }
).shuffled(random)
val ports = topo.PORT_POSITIONS.mapIndexed { i, edge ->
val resource = portTypes[i % portTypes.size]
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
}
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
// 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 ->
val c = topo.HEXES[h]
terrains.indices.filter { terrains[it] != Terrain.SEA }.minOfOrNull {
Topology.hexDistance(c.q, c.r, topo.HEXES[it].q, topo.HEXES[it].r)
} ?: 0
} ?: sea.first()
return Generated(terrains, numbers.toList(), desert, pirate, ports, home)
}
}
@@ -0,0 +1,205 @@
package org.prolibertate.games.game.catan
/**
* The rules Seafarers adds to the base game, kept apart from [CatanRules] so the base
* game's code reads as it always did. [CatanRules] calls in here only when a game has
* [CatanState.sea]. See `docs/RULES-catan-seafarers.md`.
*/
internal object SeaRules {
const val MAX_SHIPS = 15
/** Points for being the first to settle an island other than the home island. */
const val ISLAND_BONUS = 2
val SHIP_COST: Map<Resource, Int> = mapOf(Resource.LUMBER to 1, Resource.WOOL to 1)
// -----------------------------------------------------------------------
// Ships
// -----------------------------------------------------------------------
private fun fleetOf(state: CatanState, seat: Int): Int = state.sea!!.ships.count { it == seat }
/**
* Whether [seat]'s ships and towns reach [vertex], so a ship may start there. A town
* of anyone else is a wall; [ignoring] is a ship that is being lifted off the board.
*/
private fun shipReaches(state: CatanState, seat: Int, vertex: Int, ignoring: Int): Boolean {
val building = state.buildings[vertex]
if (building != null) return building.owner == seat
val sea = state.sea!!
return state.topo.VERTEX_EDGES[vertex].any { it != ignoring && sea.ships[it] == seat }
}
/** A sea edge a ship could sit on, joined to [seat]'s fleet or a town of theirs. */
private fun shipSites(state: CatanState, seat: Int, ignoring: Int = -1): List<Int> {
val topo = state.topo
val sea = state.sea!!
return (0 until topo.EDGE_COUNT).filter { e ->
topo.edgeSea[e] &&
state.roads[e] == NO_PLAYER &&
(sea.ships[e] == NO_PLAYER || e == ignoring) &&
sea.pirate !in topo.EDGE_HEXES[e] &&
topo.EDGE_VERTICES[e].let { (a, b) ->
shipReaches(state, seat, a, ignoring) || shipReaches(state, seat, b, ignoring)
}
}
}
fun buildShipMoves(state: CatanState, seat: Int): List<BuildShip> =
if (fleetOf(state, seat) >= MAX_SHIPS) emptyList() else shipSites(state, seat).map { BuildShip(it) }
/** Whether the ship on [edge] sits at the open end of a route: one end touches nothing else of [seat]'s. */
private fun atOpenEnd(state: CatanState, seat: Int, edge: Int): Boolean {
val topo = state.topo
val sea = state.sea!!
return topo.EDGE_VERTICES[edge].toList().any { v ->
state.buildings[v] == null &&
topo.VERTEX_EDGES[v].none { it != edge && (state.roads[it] == seat || sea.ships[it] == seat) }
}
}
/** A ship may sail once a turn, never the turn it was built, only from the open end of a route. */
fun moveShipMoves(state: CatanState, seat: Int): List<MoveShip> {
val sea = state.sea!!
if (sea.shipMoved) return emptyList()
val moves = mutableListOf<MoveShip>()
for (e in sea.ships.indices) {
if (sea.ships[e] != seat || e in sea.builtThisTurn || !atOpenEnd(state, seat, e)) continue
shipSites(state, seat, ignoring = e).filter { it != e }.forEach { moves += MoveShip(e, it) }
}
return moves
}
// -----------------------------------------------------------------------
// Pirate
// -----------------------------------------------------------------------
/** Seats with a ship beside [hex] that hold at least one card: whom the pirate can rob there. */
private fun pirateVictims(state: CatanState, thief: Int, hex: Int): List<Int> {
val topo = state.topo
val sea = state.sea!!
return (0 until topo.EDGE_COUNT).filter { hex in topo.EDGE_HEXES[it] }
.map { sea.ships[it] }
.filter { it != NO_PLAYER && it != thief }
.distinct()
.filter { state.resourceCount(it) > 0 }
}
fun pirateMoves(state: CatanState, seat: Int): List<MovePirate> {
val topo = state.topo
val sea = state.sea!!
val moves = mutableListOf<MovePirate>()
for (h in 0 until topo.HEX_COUNT) {
if (topo.land[h] || h == sea.pirate) continue
val victims = pirateVictims(state, seat, h)
if (victims.isEmpty()) {
moves += MovePirate(topo.HEXES[h])
} else {
victims.forEach { moves += MovePirate(topo.HEXES[h], it) }
}
}
return moves
}
// -----------------------------------------------------------------------
// Gold
// -----------------------------------------------------------------------
/** Every way the seat at the front of the gold queue can choose its cards, supply permitting. */
fun goldMoves(state: CatanState): List<PickGold> {
val owed = state.sea!!.gold.firstOrNull() ?: return emptyList()
val supply = Resource.entries.associateWith { CatanRules.bankSupply(state, it) }
val take = minOf(owed.count, supply.values.sum())
val picks = mutableListOf<PickGold>()
fun walk(index: Int, left: Int, acc: MutableList<Resource>) {
if (left == 0) {
picks += PickGold(acc.toList())
return
}
if (index == Resource.entries.size) return
val resource = Resource.entries[index]
for (n in 0..minOf(left, supply.getValue(resource))) {
repeat(n) { acc += resource }
walk(index + 1, left - n, acc)
repeat(n) { acc.removeAt(acc.size - 1) }
}
}
walk(0, take, mutableListOf())
return picks
}
/** Drops queue entries the bank can no longer pay, and leaves the gold phase once nobody is owed. */
fun settleGold(state: CatanState): CatanState {
val sea = state.sea!!
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)
} else {
state.copy(sea = sea.copy(gold = queue), phase = CatanPhase.GOLD)
}
}
// -----------------------------------------------------------------------
// 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
/** 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
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
return state.copy(
sea = sea.copy(islandBonus = bonus),
log = state.log + "Seat $seat is the first to settle a new island: +$ISLAND_BONUS victory points.",
)
}
// -----------------------------------------------------------------------
// Longest route
// -----------------------------------------------------------------------
/**
* The seat's longest route over roads *and* ships. A route is a simple path of the seat's
* pieces that never runs through an opponent's town; it may change between road and ship
* only at a town of the seat's own.
*/
fun longestRouteFor(state: CatanState, seat: Int): Int {
val topo = state.topo
val sea = state.sea!!
val blocked = BooleanArray(topo.VERTEX_COUNT) { v ->
state.buildings[v]?.let { it.owner != seat } == true
}
fun owns(e: Int) = state.roads[e] == seat || sea.ships[e] == seat
fun kind(e: Int) = if (state.roads[e] == seat) 0 else 1
fun walk(v: Int, arrivedBy: Int, visited: BooleanArray): Int {
visited[v] = true
var best = 0
val ownTown = state.buildings[v]?.owner == seat
for (e in topo.VERTEX_EDGES[v]) {
if (!owns(e)) continue
val k = kind(e)
if (arrivedBy != -1 && k != arrivedBy && !ownTown) continue
val w = topo.otherEnd(e, v)
if (visited[w]) continue
best = maxOf(best, if (blocked[w]) 1 else 1 + walk(w, k, visited))
}
visited[v] = false
return best
}
var best = 0
for (start in 0 until topo.VERTEX_COUNT) {
if (topo.VERTEX_EDGES[start].none { owns(it) }) continue
best = maxOf(best, walk(start, -1, BooleanArray(topo.VERTEX_COUNT)))
}
return best
}
}
@@ -1,6 +1,9 @@
package org.prolibertate.games.game.checkers
import kotlin.random.Random
import kotlin.math.roundToInt
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -17,22 +20,49 @@ import org.prolibertate.games.game.engine.GameAi
* recapture as its only legal move, so the search plays it whether it wanted to
* or not.
*/
class CheckersAi(private val level: CheckersLevel? = null) :
GameAi<CheckersState, CheckersMove> {
class CheckersAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: CheckersLevel? = null,
) : GameAi<CheckersState, CheckersMove> {
private var nodeBudget = 0
private var nodesUsed = 0
// Evaluation weights and viewpoint for the move being chosen. At the default
// (balanced) style they equal the plain constants, so Medium is unchanged.
private var rootSeat = 0
private var wAdvance = ADVANCE
private var wEdge = EDGE
private var wBackRow = BACK_ROW
private var wTheirMobility = MOBILITY
override fun chooseMove(
state: CheckersState,
seat: Int,
legal: List<CheckersMove>,
): CheckersMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
nodeBudget = NODE_BUDGET
val bold = profile.style.boldness
val spite = profile.style.spite
rootSeat = seat
// Bold seats push men forward and care less about the edge and back row;
// cautious ones do the reverse. Spiteful seats weigh the opponent's
// freedom of movement more heavily.
wAdvance = ADVANCE + (bold * 2).roundToInt()
wEdge = EDGE - (bold * 2).roundToInt()
wBackRow = BACK_ROW - (bold * 3).roundToInt()
wTheirMobility = MOBILITY + (spite * 2).roundToInt()
val maxDepth = when (profile.level) {
AiLevel.EASY -> maxOf(2, setting.depth - 2)
AiLevel.MEDIUM -> setting.depth
AiLevel.HARD -> setting.depth + 2
}
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
nodesUsed = 0
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
@@ -40,7 +70,7 @@ class CheckersAi(private val level: CheckersLevel? = null) :
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
for (depth in 1..maxDepth) {
val results = mutableListOf<Pair<CheckersMove, Int>>()
val ordered = order(legal, preferred = best)
for (move in ordered) {
@@ -61,7 +91,16 @@ class CheckersAi(private val level: CheckersLevel? = null) :
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
val chosen = acceptable[random.nextInt(acceptable.size)].first
if (profile.level != AiLevel.EASY) return chosen
// Easy now and then settles for another sound-looking move: only ones
// within a man and a half of the best, never a real blunder.
val ranked = listOf(chosen) + scored
.filter { it.first != chosen && it.second >= bestScore - EASY_TOLERANCE }
.sortedByDescending { it.second }
.map { it.first }
return profile.pick(ranked)
}
private fun CheckersLevel.slack(): Int = when (this) {
@@ -126,29 +165,32 @@ class CheckersAi(private val level: CheckersLevel? = null) :
*/
private fun evaluate(state: CheckersState): Int {
val us = state.turn
val them = other(us)
var score = 0
val me = rootSeat
for (square in 0 until SQUARES) {
val piece = state.board[square] ?: continue
val sign = if (piece.seat == us) 1 else -1
val sign = if (piece.seat == me) 1 else -1
score += sign * if (piece.king) KING else MAN
if (!piece.king) {
// How many rows it has come, out of the seven it needs.
val advanced = if (piece.seat == BLACK_SEAT) rowOf(square) else 7 - rowOf(square)
score += sign * advanced * ADVANCE
score += sign * advanced * wAdvance
}
// Sitting on the edge cannot be jumped, which is worth a little in
// a game whose entire tactic is being jumped.
if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * EDGE
if (columnOf(square) == 0 || columnOf(square) == 7) score += sign * wEdge
if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * BACK_ROW
if (rowOf(square) == crownRowOf(other(piece.seat))) score += sign * wBackRow
}
score += MOBILITY * (mobility(state, us) - mobility(state, them))
return score
val mine = mobility(state, me)
val theirs = mobility(state, other(me))
score += MOBILITY * mine - wTheirMobility * theirs
// Scored from the root seat's side; flip for the side to move.
return if (us == me) score else -score
}
/**
@@ -178,6 +220,8 @@ class CheckersAi(private val level: CheckersLevel? = null) :
const val INFINITY = 1_000_000
const val WIN = 100_000
const val NODE_BUDGET = 200_000
const val HARD_NODE_BUDGET = 450_000
const val EASY_TOLERANCE = 150
const val MAN = 100
const val KING = 175
@@ -2,6 +2,9 @@ package org.prolibertate.games.game.chess
import kotlin.math.abs
import kotlin.random.Random
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -16,7 +19,14 @@ import org.prolibertate.games.game.engine.GameAi
* so the setup screen sets the strength without the caller having to thread it
* through.
*/
class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessMove> {
class ChessAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<ChessState, ChessMove> {
/** Fixed strength that overrides the table's option (used by tests and tools). */
private var level: ChessLevel? = null
constructor(level: ChessLevel) : this(AiProfiles.DEFAULT) {
this.level = level
}
/**
* Ceiling on how much thinking any one move gets. A phone should not stall
@@ -28,10 +38,22 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
override fun chooseMove(state: ChessState, seat: Int, legal: List<ChessMove>): ChessMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
nodeBudget = NODE_BUDGET
// Easy looks one move less far ahead; Hard looks one further and gets
// roughly twice the node budget (still a couple of seconds on a phone).
val maxDepth = when (profile.level) {
AiLevel.EASY -> maxOf(1, setting.depth - 1)
AiLevel.MEDIUM -> setting.depth
AiLevel.HARD -> setting.depth + 1
}
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
aiWhite = state.whiteToMove
styleActive = profile.style.boldness != 0.0 || profile.style.spite != 0.0
styleBoldness = profile.style.boldness
styleSpite = profile.style.spite
nodesUsed = 0
// Deterministic per position, so a host and a client replaying the same
@@ -42,7 +64,7 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
for (depth in 1..maxDepth) {
val results = mutableListOf<Pair<ChessMove, Int>>()
// Search the previous iteration's best first, so the sub-searches
// below it have a decent bound to prune against.
@@ -74,9 +96,56 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
// makes them beatable without making them play nonsense.
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
val chosen = acceptable[random.nextInt(acceptable.size)].first
if (profile.level != AiLevel.EASY) return chosen
// Easy sometimes settles for a worse move, but only one within a modest
// margin of the best, so it never throws a piece away or walks into mate.
val ranked = scored.filter { it.second >= bestScore - setting.slack() - EASY_EXTRA_SLACK }
.sortedByDescending { it.second }.map { it.first }
val ordered = listOf(chosen) + ranked.filter { it != chosen }
return profile.pick(ordered)
}
// Per-move style state, set at the start of chooseMove.
private var aiWhite = true
private var styleActive = false
private var styleBoldness = 0.0
private var styleSpite = 0.0
/**
* A small temperament term (well under a pawn) from the mover's point of
* view. Aggressive seats like pieces near the enemy king, cautious seats
* like a sheltered own king, spiteful seats like the enemy being badly
* placed. Zero for the default style, so default play is unchanged.
*/
private fun styleTerm(state: ChessState): Int {
val enemyKing = state.kingSquare(white = !aiWhite)
val myKing = state.kingSquare(white = aiWhite)
var attackers = 0
var shield = 0
var enemyPlacement = 0
for (square in 0 until 64) {
val piece = state.board[square] ?: continue
if (piece.white == aiWhite) {
if (piece.kind == PieceKind.KING) continue
if (enemyKing != null && piece.kind != PieceKind.PAWN && distance(square, enemyKing) <= 3) attackers++
if (myKing != null && distance(square, myKing) <= 1) shield++
} else {
val table = tableFor(piece.kind)
enemyPlacement += table[if (piece.white) square else square xor 56]
}
}
var term = 0.0
if (styleBoldness > 0) term += styleBoldness * 4 * attackers
if (styleBoldness < 0) term += -styleBoldness * 5 * shield
term -= styleSpite * 0.1 * enemyPlacement
val own = term.toInt().coerceIn(-40, 40)
return if (state.whiteToMove == aiWhite) own else -own
}
private fun distance(a: Int, b: Int): Int = maxOf(abs(fileOf(a) - fileOf(b)), abs(rankOf(a) - rankOf(b)))
private fun ChessLevel.slack(): Int = when (this) {
ChessLevel.CASUAL -> 70
ChessLevel.CLUB -> 20
@@ -116,7 +185,7 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
*/
private fun quiescence(state: ChessState, alpha: Int, beta: Int, ply: Int): Int {
nodesUsed++
val standPat = evaluate(state)
val standPat = evaluate(state) + if (styleActive) styleTerm(state) else 0
if (standPat >= beta) return beta
var localAlpha = maxOf(alpha, standPat)
if (ply >= MAX_QUIESCENCE_PLY) return localAlpha
@@ -205,6 +274,8 @@ class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessM
const val INFINITY = 1_000_000
const val MATE = 100_000
const val NODE_BUDGET = 120_000
const val HARD_NODE_BUDGET = 240_000
const val EASY_EXTRA_SLACK = 60
const val MAX_QUIESCENCE_PLY = 24
/**
@@ -1,52 +1,135 @@
package org.prolibertate.games.game.crazyeights
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Crazy 8s opponent.
* A Crazy 8s opponent whose strength and temperament come from the seat's profile.
*
* It sheds expensive cards first, keeps its eights back for when it is stuck,
* and when it does play one it names the suit it holds most of.
* MEDIUM (the default) sheds expensive cards first, keeps its eights back for
* when it is stuck, and when it does play one it names the suit it holds most of.
*
* EASY plays the same way but sometimes sheds a cheaper card or wastes an eight.
* HARD counts the cards on the discard pile to know what is still out, plays to
* keep a long suit it can run, and when someone is close to going out it turns
* the game to a suit that is nearly exhausted so they cannot follow.
*/
class CrazyEightsAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class CrazyEightsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<CrazyEightsState, CrazyEightsMove> {
enum class Difficulty(
/** Play an eight anyway once the hand is down to this size. */
val eightEndgame: Int,
) {
EASY(eightEndgame = 99),
NORMAL(eightEndgame = 3),
CAUTIOUS(eightEndgame = 2),
}
override fun chooseMove(
state: CrazyEightsState,
seat: Int,
legal: List<CrazyEightsMove>,
): CrazyEightsMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val plays = legal.filterIsInstance<PlayCard>()
if (plays.isEmpty()) return legal.first()
val move: CrazyEightsMove = when (profile.level) {
AiLevel.MEDIUM -> standardRanking(state, seat, plays, EIGHT_ENDGAME).first()
AiLevel.EASY -> profile.pick(standardRanking(state, seat, plays, EASY_EIGHT_ENDGAME))
AiLevel.HARD -> hard(state, seat, plays, profile)
}
return if (move in legal) move else legal.first()
}
/**
* Ordinary cards first, most expensive leading, then eights with the suit
* this hand holds most of named first.
*/
private fun standardRanking(
state: CrazyEightsState,
seat: Int,
plays: List<PlayCard>,
eightEndgame: Int,
): List<PlayCard> {
val hand = state.hands[seat]
val ordinary = plays.filter { !isWild(it.card) }
// An eight is the only card that always plays, so it is worth more held
// than spent — unless the hand is nearly gone, or nothing else works.
val useEight = ordinary.isEmpty() || hand.size <= difficulty.eightEndgame
val useEight = ordinary.isEmpty() || hand.size <= eightEndgame
val pool = if (useEight) plays else ordinary
val best = pool
.filter { !isWild(it.card) }
.maxByOrNull { crazyEightsPenalty(it.card) }
if (best != null) return best
val ranked = pool.filter { !isWild(it.card) }.sortedByDescending { crazyEightsPenalty(it.card) }
if (ranked.isNotEmpty() && !useEight) return ranked
// Playing an eight: switch the game to whatever we hold most of.
val strongest = Suit.entries.maxByOrNull { suit -> hand.count { it.suit == suit } }
?: Suit.SPADES
return pool.firstOrNull { it.nominatedSuit == strongest } ?: pool.first()
val suits = Suit.entries.sortedByDescending { suit -> hand.count { it.suit == suit } }
val wilds = suits.flatMap { suit -> pool.filter { isWild(it.card) && it.nominatedSuit == suit } }
return ranked + wilds.ifEmpty { pool.filter { isWild(it.card) } }
}
// -----------------------------------------------------------------------
// Hard
// -----------------------------------------------------------------------
private fun hard(
state: CrazyEightsState,
seat: Int,
plays: List<PlayCard>,
profile: AiProfile,
): CrazyEightsMove {
val hand = state.hands[seat]
val options = state.options
// Cards this seat can account for: its own hand and everything on the pile.
val known = HashSet<Card>().also { it.addAll(hand); it.addAll(state.discard) }
val unseen = FULL_DECK.filter { it !in known }
fun unseenIn(suit: Suit): Int = unseen.count { it.suit == suit }
val next = (seat + 1) % options.playerCount
val nextCount = state.handCounts[next]
val smallestOpponent = (0 until options.playerCount).filter { it != seat }
.minOf { state.handCounts[it] }
// How hard to squeeze the suits: more when somebody is about to go out.
val urgency = 1.0 + profile.style.spite +
(if (nextCount <= 2) 1.0 else 0.0) + (if (smallestOpponent <= 2) 0.5 else 0.0)
/** Value of leaving the game showing [suit], with [remaining] the cards still held. */
fun suitValue(suit: Suit, remaining: List<Card>): Double =
3.0 * remaining.count { it.suit == suit } - 0.7 * urgency * unseenIn(suit)
val ordinary = plays.filter { !isWild(it.card) }
val wilds = plays.filter { isWild(it.card) }
// Aggressive seats spend eights earlier; cautious ones keep them as insurance.
val endgame = (EIGHT_ENDGAME + profile.style.boldness * 2.0)
val blocking = smallestOpponent <= 1 || (nextCount <= 2 && urgency >= 2.0)
val useEight = ordinary.isEmpty() || hand.size <= endgame || (blocking && wilds.isNotEmpty())
if (hand.size == 1) {
plays.firstOrNull()?.let { return it }
}
if (!useEight || wilds.isEmpty()) {
if (ordinary.isNotEmpty()) {
// Cautious seats shed liabilities harder, bold ones care more about position.
val shedWeight = 1.0 - 0.3 * profile.style.boldness
return ordinary.maxBy { play ->
val remaining = hand - play.card
crazyEightsPenalty(play.card) * shedWeight + suitValue(play.card.suit, remaining)
}
}
}
// An eight: name the suit that is best for this hand and worst for the others.
return wilds.maxBy { play ->
val remaining = hand - play.card
suitValue(play.nominatedSuit ?: play.card.suit, remaining)
}
}
private companion object {
const val EIGHT_ENDGAME = 3
const val EASY_EIGHT_ENDGAME = 99
val FULL_DECK: List<Card> = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } }
}
}
@@ -3,8 +3,12 @@ package org.prolibertate.games.game.cribbage
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
import kotlin.math.abs
import kotlin.random.Random
/**
* A cribbage opponent that counts rather than guesses.
@@ -18,8 +22,14 @@ import kotlin.math.abs
*
* The play is a heuristic: take the points on offer, and try not to leave the
* count where a ten-card collects fifteen or thirty-one.
*
* Per seat, from [profiles]: Easy settles for a worse lay-away or card now and
* then; Hard also samples the crib (what the unseen cards might add to it) and
* weighs what a reply could score off each card it plays. Boldness trades the
* two counts-to-avoid and crib ownership; spite weighs the other side's crib.
*/
class CribbageAi : GameAi<CribbageState, CribbageMove> {
class CribbageAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<CribbageState, CribbageMove> {
override fun chooseMove(
state: CribbageState,
@@ -27,30 +37,78 @@ class CribbageAi : GameAi<CribbageState, CribbageMove> {
legal: List<CribbageMove>,
): CribbageMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val layAways = legal.filterIsInstance<LayAway>()
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways)
return choosePeg(state, legal.filterIsInstance<PegCard>())
if (layAways.isNotEmpty()) return chooseLayAway(state, seat, layAways, profile)
val pegs = legal.filterIsInstance<PegCard>()
if (pegs.isNotEmpty()) return choosePeg(state, seat, pegs, profile)
return legal.first()
}
private fun chooseLayAway(
state: CribbageState,
seat: Int,
choices: List<LayAway>,
profile: AiProfile,
): LayAway {
val hand = state.hands[seat]
val held = hand.toSet()
val unseen = Decks.standard52().filter { it !in held }
// A partner's crib is your crib. That is the whole of four-handed play.
val ownCrib = state.options.teamOf(seat) == state.options.teamOf(state.dealer)
val boldness = profile.style.boldness
// Bold seats back their own crib; careful ones fear the other side's.
val cribSign = if (ownCrib) {
1.0 + 0.25 * boldness
} else {
-(1.0 - 0.25 * boldness + 0.3 * profile.style.spite)
}
val trials = if (profile.level == AiLevel.HARD) sampleCribs(hand, unseen, state.options) else null
return choices.maxByOrNull { choice ->
val scored = choices.map { choice ->
val given = choice.cards.toSet()
val keep = hand.filter { it !in given }
val expected = unseen.sumOf { starter ->
CribbageScoring.show(keep, starter, isCrib = false).sumOf { it.points }
}.toDouble() / unseen.size
expected + cribValue(choice.cards) * (if (ownCrib) 1.0 else -1.0)
} ?: choices.first()
val crib = if (trials != null) {
// Heuristic and sampled estimates agree on direction; averaging
// them smooths the sampling noise without losing the structure.
(sampledCribValue(choice.cards, trials) + cribValue(choice.cards)) / 2.0
} else {
cribValue(choice.cards)
}
choice to expected + crib * cribSign
}
// Stable sort: ties resolve to the first choice, as maxByOrNull did.
val ranked = scored.sortedByDescending { it.second }.map { it.first }
return profile.pick(ranked)
}
/** Sets of unseen cards a crib might receive, plus a cut, for Hard to average over. */
private fun sampleCribs(
hand: List<Card>,
unseen: List<Card>,
options: CribbageOptions,
): List<List<Card>> {
val random = Random(hand.sumOf { it.rank.ordinal * 4 + it.suit.ordinal }.toLong())
// Whatever the crib is short of after our own cards, plus the cut.
val others = (CRIB_SIZE - options.layAwaySize).coerceAtLeast(0)
return List(CRIB_SAMPLES) {
val picked = LinkedHashSet<Card>()
while (picked.size < others + 1) picked += unseen[random.nextInt(unseen.size)]
picked.toList()
}
}
private fun sampledCribValue(given: List<Card>, trials: List<List<Card>>): Double {
var total = 0
for (trial in trials) {
val starter = trial.last()
val crib = given + trial.dropLast(1)
total += CribbageScoring.show(crib, starter, isCrib = true).sumOf { it.points }
}
return total.toDouble() / trials.size
}
/**
@@ -83,22 +141,63 @@ class CribbageAi : GameAi<CribbageState, CribbageMove> {
return value
}
private fun choosePeg(state: CribbageState, choices: List<PegCard>): PegCard {
private fun choosePeg(
state: CribbageState,
seat: Int,
choices: List<PegCard>,
profile: AiProfile,
): PegCard {
val count = state.count
val onTheTable = state.series.map { it.card }
val boldness = profile.style.boldness
// Bold seats risk leaving five or twenty-one; careful ones really avoid it.
val countPenalty = 3.0 - boldness * 1.5
val leadFivePenalty = 4.0 - boldness * 2.0
val hard = profile.level == AiLevel.HARD
// Cards a reply could come from: nothing this seat holds, has seen played or was cut.
val unseen = if (hard) {
val known = (state.remaining(seat) + state.played.map { it.card } + listOfNotNull(state.starter)).toSet()
Decks.standard52().filter { it !in known && it !in state.hands[seat] }
} else {
emptyList()
}
return choices.maxByOrNull { choice ->
val scored = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
val scored = choices.map { choice ->
val points = CribbageScoring.peg(onTheTable + choice.card).sumOf { it.points }
val after = count + pipValue(choice.card)
var value = scored * 10.0
var value = points * 10.0
// Five and twenty-one are the two counts to avoid leaving: any of
// the sixteen ten-cards takes fifteen or thirty-one off them.
if (after == 5 || after == 21) value -= 3.0
if (after == 5 || after == 21) value -= countPenalty
// Leading a five is the same present, offered first.
if (count == 0 && choice.card.rank == Rank.FIVE) value -= 4.0
if (count == 0 && choice.card.rank == Rank.FIVE) value -= leadFivePenalty
// Big cards early. Low ones are what let you keep playing at the
// awkward end of a series, where the last card is worth a point.
value + pipValue(choice.card) * 0.1
} ?: choices.first()
value += pipValue(choice.card) * 0.1
if (hard && unseen.isNotEmpty() && after < CRIBBAGE_LIMIT) {
value -= replyThreat(onTheTable + choice.card, after, unseen) * 8.0
}
choice to value
}
val ranked = scored.sortedByDescending { it.second }.map { it.first }
return profile.pick(ranked)
}
/** Average points a reply scores off [series], over the cards still unseen. */
private fun replyThreat(series: List<Card>, after: Int, unseen: List<Card>): Double {
var total = 0
var playable = 0
for (card in unseen) {
if (after + pipValue(card) > CRIBBAGE_LIMIT) continue
playable++
total += CribbageScoring.peg(series + card).sumOf { it.points }
}
// Nothing playable is a go for them, worth a point to whoever last played.
return if (playable == 0) 0.0 else total.toDouble() / unseen.size
}
private companion object {
const val CRIB_SIZE = 4
const val CRIB_SAMPLES = 60
}
}
@@ -0,0 +1,123 @@
package org.prolibertate.games.game.engine
import kotlinx.serialization.Serializable
import kotlin.random.Random
/** How well one computer seat plays. */
enum class AiLevel {
/** Makes frequent, plausible mistakes and plays without much foresight. */
EASY,
/** The game's standard computer opponent. */
MEDIUM,
/** Plays carefully: more foresight, no deliberate mistakes. */
HARD,
}
/** What the person at setup chooses for the table's computer seats. */
@Serializable
enum class AiDifficulty(val label: String) {
EASY("Easy"),
MEDIUM("Medium"),
HARD("Hard"),
/** Each computer seat gets its own level, picked at random. */
MIXED("Mixed"),
}
/**
* A computer seat's temperament. Never shown to the human players: it only
* shapes how the AI trades off the options in front of it.
*/
enum class AiStyle(
/** -1 plays safe, +1 gambles; 0 is the game's standard balance. */
val boldness: Double,
/** How much the seat cares about stopping opponents rather than building its own position. */
val spite: Double,
) {
BALANCED(0.0, 0.0),
AGGRESSIVE(0.8, 0.3),
CAUTIOUS(-0.8, 0.0),
SPOILER(0.0, 0.8),
}
/**
* One computer seat's level and style, and a source of randomness of its own.
*
* [DEFAULT] is the game's standard opponent: [AiLevel.MEDIUM], [AiStyle.BALANCED],
* and it never slips, so an AI built without a profile plays exactly as it did
* before profiles existed.
*/
class AiProfile(
val level: AiLevel = AiLevel.MEDIUM,
val style: AiStyle = AiStyle.BALANCED,
val random: Random = Random(0),
) {
/**
* Whether this seat should throw away its best option this time. Only
* [AiLevel.EASY] does, and only some of the time, so it still plays like a
* person who is not concentrating rather than like a random-move generator.
*/
fun slips(): Boolean = level == AiLevel.EASY && random.nextDouble() < EASY_SLIP_CHANCE
/**
* Picks from [ranked] (best first). Easy sometimes settles for a worse choice,
* drawn from the better half so it stays plausible; other levels take the best.
*/
fun <T> pick(ranked: List<T>): T {
require(ranked.isNotEmpty()) { "Nothing to pick from" }
if (!slips() || ranked.size == 1) return ranked.first()
val pool = ranked.take(maxOf(2, (ranked.size + 1) / 2))
return pool[random.nextInt(pool.size)]
}
/** [base] nudged by this seat's boldness: a gambler raises it, a cautious seat lowers it. */
fun bolder(base: Double, strength: Double = 1.0): Double = base + style.boldness * strength
companion object {
const val EASY_SLIP_CHANCE = 0.3
val DEFAULT = AiProfile()
}
}
/**
* The profile for every computer seat at a table, worked out from the table's
* difficulty setting and its seed alone.
*
* Derived rather than stored, so a saved game, a rematch from the same config
* and a guest's copy of a hosted table all give the same seat the same
* personality without it travelling anywhere.
*/
class AiProfiles(
private val difficulty: AiDifficulty,
private val seed: Long,
) {
private val cache = HashMap<Int, AiProfile>()
constructor(config: TableConfig) : this(config.aiDifficulty, config.seed)
@Synchronized
fun of(seat: Int): AiProfile = cache.getOrPut(seat) {
val picker = Random(seed * 31 + seat * 7919L + 17)
val level = when (difficulty) {
AiDifficulty.EASY -> AiLevel.EASY
AiDifficulty.MEDIUM -> AiLevel.MEDIUM
AiDifficulty.HARD -> AiLevel.HARD
AiDifficulty.MIXED -> AiLevel.entries[picker.nextInt(AiLevel.entries.size)]
}
// The standard opponent is balanced by definition; the others each get a
// temperament, so no two computer seats feel alike.
val style = if (difficulty == AiDifficulty.MEDIUM) {
AiStyle.BALANCED
} else {
AiStyle.entries[picker.nextInt(AiStyle.entries.size)]
}
AiProfile(level, style, Random(picker.nextLong()))
}
companion object {
/** Standard opponents, for a caller that has no table to ask. */
val DEFAULT = AiProfiles(AiDifficulty.MEDIUM, 0L)
}
}
@@ -43,6 +43,8 @@ data class TableConfig(
val optionsJson: String,
/** Shared shuffle seed so a hand is reproducible from its state. */
val seed: Long,
/** How hard the computer seats play. Old saves and old hosts omit it and get the standard opponent. */
val aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
)
/**
@@ -3,6 +3,10 @@ package org.prolibertate.games.game.euchre
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -13,7 +17,7 @@ import org.prolibertate.games.game.engine.GameAi
* That is enough to be a reasonable table opponent without pretending to be a
* solver.
*/
class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class EuchreAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<EuchreState, EuchreMove> {
/**
@@ -22,25 +26,50 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
* means every seat bids and the card is never turned down — see
* EuchreBiddingRateTest, which pins the resulting order-up rate.
*/
enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) {
/** Bids loosely and plays straightforwardly. */
EASY(bidThreshold = 21, aloneThreshold = 32),
private companion object {
const val BID_THRESHOLD = 24.0
const val ALONE_THRESHOLD = 32.0
}
NORMAL(bidThreshold = 24, aloneThreshold = 32),
/** Cards this AI has watched played in the current hand, per seat it plays. */
private val memory = HashMap<Int, SeenCards>()
/** Bids tighter and is harder to sneak a trick past. */
CAUTIOUS(bidThreshold = 27, aloneThreshold = 31),
private class SeenCards {
var key: Long = Long.MIN_VALUE
val cards = HashSet<Card>()
}
/** Public information only: cards that have appeared on the table this hand. */
private fun observe(state: EuchreState, seat: Int): Set<Card> = synchronized(memory) {
val seen = memory.getOrPut(seat) { SeenCards() }
val key = state.seed * 1_000_003L + state.handNumber
if (key != seen.key || state.phase == EuchrePhase.BID_ROUND_1) {
seen.key = key
seen.cards.clear()
}
state.trick.forEach { seen.cards.add(it.card) }
state.completedTrick.forEach { seen.cards.add(it.card) }
state.turnedDown?.let { seen.cards.add(it) }
seen.cards.removeAll(state.hands[seat].toSet())
seen.cards.toSet()
}
override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal)
EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal)
EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal)
EuchrePhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
val profile = profiles.of(seat)
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
val move = try {
when (state.phase) {
EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal, profile)
EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal, profile)
EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal, profile)
EuchrePhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
else -> legal.first()
}
} catch (e: RuntimeException) {
legal.first()
}
return if (move in legal) move else legal.first()
}
// -----------------------------------------------------------------------
@@ -75,27 +104,61 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return value
}
/** The five-card hand left after the dealer picks up [upCard] and discards the least useful card. */
private fun bestAfterPickup(hand: List<Card>, upCard: Card, trump: Suit): Int {
val six = hand + upCard
return six.maxOf { dropped -> handValue(six - dropped, trump) }
}
/** A hand worth playing without a partner: real trump length with a bower to lead it. */
private fun soundAlone(hand: List<Card>, trump: Suit): Boolean {
val trumps = hand.count { isTrumpCard(it, trump) }
val bower = hand.any { isRightBower(it, trump) || isLeftBower(it, trump) }
return trumps >= 3 && bower
}
/** Easy misjudges how good its hand is. */
private fun misjudge(value: Int, profile: AiProfile): Int =
if (profile.level == AiLevel.EASY) value + profile.random.nextInt(-5, 6) else value
private fun chooseBidRound1(
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
profile: AiProfile,
): EuchreMove {
val upCard = state.upCard ?: return Pass
val trump = upCard.suit
var hand = state.hands[seat]
val isDealer = seat == state.dealer
val partnerDealer = partnerOf(seat) == state.dealer
val hard = profile.level == AiLevel.HARD
// If our partner is the dealer they gain the turn card, and if we are
// the dealer we gain it ourselves — both make the hand stronger than it
// looks. Ordering up into an opponent dealer instead hands them a card.
if (seat == state.dealer) hand = hand + upCard
var value = handValue(hand, trump)
if (partnerOf(seat) == state.dealer) value += 2
if (seat != state.dealer && partnerOf(seat) != state.dealer) value -= 2
val owned = if (isDealer) state.hands[seat] + upCard else state.hands[seat]
var value = when {
isDealer && hard -> bestAfterPickup(state.hands[seat], upCard, trump) + 3
else -> handValue(owned, trump)
}
if (partnerDealer) value += 2
if (!isDealer && !partnerDealer) {
value -= 2
// An opposing dealer who picks up a jack gets a bower out of our bid.
if (hard && upCard.rank == Rank.JACK) value -= 2
}
value = misjudge(value, profile)
val bidThreshold = profile.bolder(BID_THRESHOLD, -3.0)
var aloneThreshold = profile.bolder(ALONE_THRESHOLD, -3.0)
if (profile.level == AiLevel.EASY) aloneThreshold += 4.0
val alone = OrderUp(alone = true)
if (value >= difficulty.aloneThreshold && legal.contains(alone)) return alone
if (value >= aloneThreshold && legal.contains(alone) && (!hard || soundAlone(owned, trump))) {
return alone
}
val order = OrderUp(alone = false)
if (value >= difficulty.bidThreshold && legal.contains(order)) return order
if (value >= bidThreshold && legal.contains(order)) return order
return if (legal.contains(Pass)) Pass else legal.first()
}
@@ -103,37 +166,64 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
profile: AiProfile,
): EuchreMove {
val hand = state.hands[seat]
val callable = legal.filterIsInstance<CallTrump>()
if (callable.isEmpty()) return legal.first()
val plain = callable.filter { !it.alone }
val best = callable
.filter { !it.alone }
.maxByOrNull { handValue(hand, it.suit) }
?: callable.first()
val bestValue = handValue(hand, best.suit)
val best: CallTrump
val bestValue: Int
if (profile.level == AiLevel.EASY) {
val noisy = plain.associateWith { handValue(hand, it.suit) + profile.random.nextInt(-5, 6) }
val ranked = plain.sortedByDescending { noisy.getValue(it) }
best = if (ranked.isEmpty()) callable.first() else profile.pick(ranked)
bestValue = noisy[best] ?: handValue(hand, best.suit)
} else {
best = plain.maxByOrNull { handValue(hand, it.suit) } ?: callable.first()
bestValue = handValue(hand, best.suit)
}
val bidThreshold = profile.bolder(BID_THRESHOLD, -3.0)
var aloneThreshold = profile.bolder(ALONE_THRESHOLD, -3.0)
if (profile.level == AiLevel.EASY) aloneThreshold += 4.0
val alone = callable.firstOrNull { it.alone && it.suit == best.suit }
if (alone != null && bestValue >= difficulty.aloneThreshold) return alone
if (bestValue >= difficulty.bidThreshold) return best
val alonePlausible = profile.level != AiLevel.HARD || soundAlone(hand, best.suit)
if (alone != null && bestValue >= aloneThreshold && alonePlausible) return alone
if (bestValue >= bidThreshold) return best
// Stuck as dealer with a bad hand: name the least-bad suit anyway.
return if (legal.contains(Pass)) Pass else best
}
private fun chooseDiscard(state: EuchreState, legal: List<EuchreMove>): EuchreMove {
private fun chooseDiscard(
state: EuchreState,
legal: List<EuchreMove>,
profile: AiProfile,
): EuchreMove {
val trump = requireNotNull(state.trump)
val discards = legal.filterIsInstance<Discard>()
// Prefer to void a side suit entirely; otherwise throw the lowest card.
val hand = state.hands[state.dealer]
val suitCounts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount()
return discards.minByOrNull { discard ->
fun keepScore(discard: Discard): Int {
val card = discard.card
val suit = effectiveSuit(card, trump)
val trumpPenalty = if (suit == trump) 1000 else 0
val singletonBonus = if (suitCounts[suit] == 1 && card.rank != Rank.ACE) -20 else 0
trumpPenalty + singletonBonus + card.rank.order
} ?: discards.first()
return trumpPenalty + singletonBonus + card.rank.order
}
return when (profile.level) {
AiLevel.HARD -> discards
.sortedWith(
compareByDescending<Discard> { handValue(hand - it.card, trump) }
.thenBy { keepScore(it) },
)
.first()
AiLevel.EASY -> profile.pick(discards.sortedBy { keepScore(it) })
AiLevel.MEDIUM -> discards.minByOrNull { keepScore(it) } ?: discards.first()
}
}
// -----------------------------------------------------------------------
@@ -144,12 +234,40 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
state: EuchreState,
seat: Int,
legal: List<EuchreMove>,
profile: AiProfile,
seen: Set<Card>,
): EuchreMove {
val trump = requireNotNull(state.trump)
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, trump))
val maker = state.maker
val defending = maker != null && teamOf(maker) != teamOf(seat)
val makerSide = maker != null && teamOf(maker) == teamOf(seat)
// A spoiler on the defence leads trump to cut the maker's trump down.
val spiteful = defending && profile.style.spite >= 0.5
val best = if (profile.level == AiLevel.HARD) {
hardPlay(state, seat, cards, trump, seen, makerSide, spiteful)
} else {
standardPlay(state, seat, cards, trump, spiteful, profile)
}
if (profile.level != AiLevel.EASY) return PlayCard(best)
// Easy sometimes burns a high card it should have kept.
val ranked = listOf(best) + (cards - best).sortedByDescending { trumpStrength(it, trump) }
return PlayCard(profile.pick(ranked))
}
private fun standardPlay(
state: EuchreState,
seat: Int,
cards: List<Card>,
trump: Suit,
spiteful: Boolean,
profile: AiProfile,
): Card {
if (state.trick.isEmpty()) return chooseLead(cards, trump, spiteful)
val ledSuit = effectiveSuit(state.trick.first().card, trump)
val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
@@ -157,22 +275,120 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
val lastToPlay = state.trick.size == state.activeSeatCount - 1
// Partner already has it and we are last: never waste a good card.
if (partnerWinning && lastToPlay) return PlayCard(lowest(cards, trump, ledSuit))
// (Easy sometimes forgets and overtakes anyway.)
if (partnerWinning && lastToPlay && !profile.slips()) {
return lowest(cards, trump, ledSuit)
}
val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit)
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
return when {
// Take it as cheaply as we can.
winners.isNotEmpty() && (!partnerWinning || !lastToPlay) ->
PlayCard(winners.minBy { trickStrength(it, trump, ledSuit) })
winners.isNotEmpty() && (!partnerWinning || !lastToPlay || profile.level == AiLevel.EASY) ->
winners.minBy { trickStrength(it, trump, ledSuit) }
else -> PlayCard(lowest(cards, trump, ledSuit))
else -> lowest(cards, trump, ledSuit)
}
}
private fun chooseLead(cards: List<Card>, trump: Suit): Card {
/**
* The strong player's choice. It remembers which cards have gone, so it
* knows when a card is the best one left, and it lets a partner who is
* already safe take the trick.
*/
private fun hardPlay(
state: EuchreState,
seat: Int,
cards: List<Card>,
trump: Suit,
seen: Set<Card>,
makerSide: Boolean,
spiteful: Boolean,
): Card {
val hand = state.hands[seat]
// Cards that could still be in somebody else's hand.
val unseen = Decks.euchre(state.options.deckSize).filter { it !in seen && it !in hand }
val unseenTrump = unseen.count { isTrumpCard(it, trump) }
/** No unseen card of its suit beats it; trump is ignored for a side-suit card. */
fun isBoss(card: Card): Boolean {
val led = effectiveSuit(card, trump)
val mine = trickStrength(card, trump, led)
val cardIsTrump = isTrumpCard(card, trump)
return unseen.none { other ->
(cardIsTrump || !isTrumpCard(other, trump)) && trickStrength(other, trump, led) > mine
}
}
if (state.trick.isEmpty()) {
val myTrump = cards.filter { isTrumpCard(it, trump) }
val off = cards.filter { !isTrumpCard(it, trump) }
val bossTrump = myTrump.filter { isBoss(it) }.maxByOrNull { trumpStrength(it, trump) }
if (unseenTrump > 0 && bossTrump != null && (makerSide || myTrump.size >= 2)) return bossTrump
if (unseenTrump > 0 && makerSide && myTrump.size >= 3) {
return myTrump.maxBy { trumpStrength(it, trump) }
}
if (unseenTrump > 0 && spiteful && myTrump.size >= 2) {
return myTrump.maxBy { trumpStrength(it, trump) }
}
val bossOff = off.filter { isBoss(it) }.maxByOrNull { it.rank.order }
if (bossOff != null) return bossOff
if (bossTrump != null) return bossTrump
if (off.isEmpty()) return cards.maxBy { trumpStrength(it, trump) }
if (makerSide) return chooseLead(cards, trump, false)
// On the defence with nothing sure: lead low from the shortest suit,
// which keeps the aces and sets up a ruff.
val counts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount()
return off.minWith(
compareBy<Card>({ counts[effectiveSuit(it, trump)] ?: 0 }, { it.rank.order }),
)
}
val ledSuit = effectiveSuit(state.trick.first().card, trump)
val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
val partnerWinning = partnerOf(bestSoFar.seat) == seat
val remaining = state.activeSeatCount - state.trick.size - 1
val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit)
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
fun safe(card: Card): Boolean =
remaining == 0 ||
unseen.none { trickStrength(it, trump, ledSuit) > trickStrength(card, trump, ledSuit) }
fun throwAway(): Card {
// A card that neither follows nor trumps costs nothing; shed the
// lowest one that is not a sure winner later.
val junk = cards.filter { trickStrength(it, trump, ledSuit) == 0 }
if (junk.isNotEmpty()) {
return junk.minBy { it.rank.order + if (isBoss(it)) 100 else 0 }
}
return lowest(cards, trump, ledSuit)
}
if (partnerWinning) {
val partnerSecure = remaining == 0 ||
unseen.none { trickStrength(it, trump, ledSuit) > bestStrength }
if (partnerSecure) return throwAway()
// Partner may yet be beaten: back them with a side-suit card, but do
// not spend trump on top of their lead.
val backing = winners.filter { !isTrumpCard(it, trump) }
.minByOrNull { trickStrength(it, trump, ledSuit) }
return backing ?: throwAway()
}
if (winners.isNotEmpty()) {
val sure = winners.filter { safe(it) }
return (sure.ifEmpty { winners }).minBy { trickStrength(it, trump, ledSuit) }
}
return throwAway()
}
private fun chooseLead(cards: List<Card>, trump: Suit, spiteTrump: Boolean): Card {
// Right bower up front pulls trump and protects the left.
cards.firstOrNull { isRightBower(it, trump) }?.let { return it }
if (spiteTrump) {
val trumps = cards.filter { isTrumpCard(it, trump) }
if (trumps.size >= 2) return trumps.maxBy { trumpStrength(it, trump) }
}
// Off-suit aces are most likely to survive early.
cards.filter { !isTrumpCard(it, trump) && it.rank == Rank.ACE }
.maxByOrNull { it.rank.order }
@@ -1,49 +1,78 @@
package org.prolibertate.games.game.golf
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Golf opponent.
* A Golf opponent whose strength and temperament come from the seat's profile.
*
* It takes a card off the discard pile when it is cheap or completes a column,
* replaces its worst known card, and turns cards over when it has nothing
* better to do. It never peeks at a face-down card — including its own, which
* it genuinely does not know.
* MEDIUM (the default) takes a card off the discard pile when it is cheap or
* completes a column, replaces its worst known card, and turns cards over when
* it has nothing better to do. It never peeks at a face-down card — including
* its own, which it genuinely does not know.
*
* EASY is pickier about the discard pile, closes the hole from a poor position
* and sometimes makes a worse play than it saw. HARD keeps count of every card
* it has seen (turned-over grids and the discard pile), values its unknown
* cards at what is still unseen, and chooses each draw and placement by how
* much it lowers its expected score, including the chance of completing a
* column and whether closing the hole would leave it ahead.
*/
class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class GolfAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<GolfState, GolfMove> {
enum class Difficulty(
/** The tuning the standard plan runs on. */
private class Params(
/** Take a discard worth this or less rather than gambling on the stock. */
val discardAppetite: Int,
/** Only close the hole out when the visible score is at or under this. */
val closeOutBelow: Int,
) {
EASY(discardAppetite = 2, closeOutBelow = 30),
NORMAL(discardAppetite = 5, closeOutBelow = 16),
CAUTIOUS(discardAppetite = 7, closeOutBelow = 12),
}
)
override fun chooseMove(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
GolfPhase.SETUP -> chooseReveal(state, seat, legal)
GolfPhase.DRAW -> chooseDraw(state, seat, legal)
GolfPhase.PLACE -> choosePlace(state, seat, legal)
val profile = profiles.of(seat)
val move = when (state.phase) {
GolfPhase.SETUP -> chooseReveal(state, seat, legal, profile)
GolfPhase.DRAW -> chooseDraw(state, seat, legal, profile)
GolfPhase.PLACE -> choosePlace(state, seat, legal, profile)
else -> legal.first()
}
return if (move in legal) move else legal.first()
}
private fun paramsFor(level: AiLevel): Params = when (level) {
AiLevel.EASY -> EASY
else -> NORMAL
}
// -----------------------------------------------------------------------
// Setup
// -----------------------------------------------------------------------
/**
* Opening reveals are spread across columns. Two cards in the same column
* tell you whether that column can be cancelled but nothing about the rest
* of the board; one in each column leaves more of the grid legible.
*/
private fun chooseReveal(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
private fun chooseReveal(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
val reveals = legal.filterIsInstance<RevealCard>()
if (reveals.isEmpty()) return legal.first()
val options = state.options
// A distracted player turns over whatever is nearest.
if (profile.slips()) return reveals[profile.random.nextInt(reveals.size)]
val untouchedColumn = reveals.firstOrNull { move ->
val column = move.index % options.cols
(0 until options.rows).none { row ->
@@ -53,17 +82,61 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return untouchedColumn ?: reveals.first()
}
private fun chooseDraw(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
// -----------------------------------------------------------------------
// Drawing
// -----------------------------------------------------------------------
private fun chooseDraw(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
if (profile.level == AiLevel.HARD) return hardDraw(state, seat, legal, profile)
val params = paramsFor(profile.level)
val top = state.discard.lastOrNull()
var take = false
if (top != null && legal.contains(DrawFromDiscard)) {
val worthTaking = golfValue(top) <= difficulty.discardAppetite ||
take = golfValue(top) <= params.discardAppetite ||
completesColumn(state, seat, top) != null
if (worthTaking) return DrawFromDiscard
}
// Easy sometimes flips its own decision.
if (profile.slips() && legal.contains(DrawFromDiscard) && legal.contains(DrawFromStock)) take = !take
if (take && legal.contains(DrawFromDiscard)) return DrawFromDiscard
return if (legal.contains(DrawFromStock)) DrawFromStock else legal.first()
}
private fun choosePlace(state: GolfState, seat: Int, legal: List<GolfMove>): GolfMove {
// -----------------------------------------------------------------------
// Placing
// -----------------------------------------------------------------------
private fun choosePlace(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
if (profile.level == AiLevel.HARD) return hardPlace(state, seat, legal, profile)
val best = standardPlace(state, seat, legal, paramsFor(profile.level))
if (profile.level != AiLevel.EASY || !profile.slips()) return best
// A lapse: turn something over or cover a face-down card instead.
val alternatives = buildList<GolfMove> {
addAll(legal.filterIsInstance<DiscardAndFlip>())
addAll(legal.filterIsInstance<ReplaceCard>().filter { !state.revealed[seat][it.index] })
}.filter { it != best }
if (alternatives.isEmpty()) return best
return alternatives[profile.random.nextInt(alternatives.size)]
}
private fun standardPlace(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
params: Params,
): GolfMove {
val drawn = state.drawn ?: return legal.first()
val replacements = legal.filterIsInstance<ReplaceCard>()
val flips = legal.filterIsInstance<DiscardAndFlip>()
@@ -90,7 +163,7 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
val showing = state.grids[seat].indices
.filter { state.revealed[seat][it] }
.sumOf { golfValue(state.grids[seat][it]) }
if (showing > difficulty.closeOutBelow) return DiscardOnly
if (showing > params.closeOutBelow) return DiscardOnly
}
// Nothing worth swapping: throw it and turn something over instead,
@@ -106,7 +179,7 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
private fun completesColumn(
state: GolfState,
seat: Int,
card: org.prolibertate.games.game.cards.Card,
card: Card,
): Int? {
val options = state.options
for (col in 0 until options.cols) {
@@ -122,4 +195,229 @@ class GolfAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
}
return null
}
// -----------------------------------------------------------------------
// Hard
// -----------------------------------------------------------------------
/**
* What this seat knows about the hole: the cards in every turned-over cell,
* the discard pile and (on its own turn) the card in its hand. Everything
* else — face-down cells and the stock — is valued at the average of the
* cards not yet seen.
*/
private class Board(private val state: GolfState, seat: Int) {
val options = state.options
private val counts = HashMap<Rank, Int>()
private val unseenTotal: Int
val unknownValue: Double
private val lines: List<List<Int>>
init {
val copies = if (options.playerCount * options.gridSize > 24) 2 else 1
Rank.standard.forEach { counts[it] = 4 * copies }
fun see(card: Card) {
counts[card.rank] = maxOf(0, (counts[card.rank] ?: 0) - 1)
}
state.grids.forEachIndexed { player, grid ->
grid.forEachIndexed { index, card -> if (state.revealed[player][index]) see(card) }
}
state.discard.forEach { see(it) }
if (seat == state.turn) state.drawn?.let { see(it) }
unseenTotal = counts.values.sum()
unknownValue = if (unseenTotal == 0) {
5.0
} else {
counts.entries.sumOf { (rank, n) -> n * valueOf(rank).toDouble() } / unseenTotal
}
val columns = (0 until options.cols).map { col -> (0 until options.rows).map { it * options.cols + col } }
val rows = if (options.rows == 3) {
(0 until options.rows).map { row -> (0 until options.cols).map { row * options.cols + it } }
} else {
emptyList()
}
lines = columns + rows
}
fun valueOf(rank: Rank): Int = golfValue(Card(rank, Suit.SPADES))
private fun chanceOf(rank: Rank): Double =
if (unseenTotal == 0) 0.0 else (counts[rank] ?: 0).toDouble() / unseenTotal
/** A seat's cells as far as this seat can see them; null is a face-down card. */
fun cellsOf(player: Int): List<Card?> =
state.grids[player].mapIndexed { index, card -> if (state.revealed[player][index]) card else null }
/** Expected score of a grid, counting the chance that an unknown cell completes a line. */
fun expected(cells: List<Card?>): Double {
val cancelled = BooleanArray(cells.size)
for (line in lines) {
val line0 = line.map { cells[it] }
if (line0.all { it != null } && line0.map { it!!.rank }.distinct().size == 1) {
line.forEach { cancelled[it] = true }
}
}
var total = 0.0
for (index in cells.indices) {
if (cancelled[index]) continue
val card = cells[index]
total += if (card != null) golfValue(card).toDouble() else unknownValue
}
for (line in lines) {
if (line.any { cancelled[it] }) continue
val unknown = line.filter { cells[it] == null }
if (unknown.size != 1) continue
val known = line.mapNotNull { cells[it] }
if (known.isEmpty() || known.map { it.rank }.distinct().size != 1) continue
val rank = known.first().rank
total -= chanceOf(rank) * (known.sumOf { golfValue(it) } + valueOf(rank))
}
return total
}
/** Unseen ranks and how many of each remain, for averaging over a stock draw. */
fun unseenRanks(): List<Pair<Rank, Int>> = counts.entries.filter { it.value > 0 }.map { it.key to it.value }
/** How likely turning over [index] is to finish a line that already shows one rank. */
fun matchHope(cells: List<Card?>, index: Int): Double {
var best = 0.0
for (line in lines) {
if (index !in line) continue
val others = line.filter { it != index }
val known = others.mapNotNull { cells[it] }
if (known.size != others.size || known.isEmpty()) continue
if (known.map { it.rank }.distinct().size != 1) continue
val rank = known.first().rank
best = maxOf(best, chanceOf(rank) * (known.sumOf { golfValue(it) } + valueOf(rank)))
}
return best
}
}
private fun effectiveBoldness(state: GolfState, seat: Int, profile: AiProfile): Double {
// Well behind late in the match: take more chances.
val holesLeft = state.options.holes - state.hole - 1
val best = (0 until state.options.playerCount).filter { it != seat }.minOf { state.scores[it] }
val behind = state.scores[seat] - best
return profile.style.boldness + if (holesLeft <= 2 && behind > 8) 0.3 else 0.0
}
/** Scores one way of using the card in hand: lower grid score, more information, less risk. */
private class Planner(
val state: GolfState,
val seat: Int,
val board: Board,
val boldness: Double,
val spite: Double,
) {
private val cells = board.cellsOf(seat)
private val before = board.expected(cells)
private val hidden = cells.count { it == null }
private val finalTurn = state.closedBy != null
private val rivalBest: Double = (0 until state.options.playerCount)
.filter { it != seat }
.minOfOrNull { board.expected(board.cellsOf(it)) } ?: before
private fun closePenalty(after: Double): Double {
if (finalTurn) return 0.0
val margin = 2.0 - 2.0 * boldness
return maxOf(0.0, after - (rivalBest - margin)) * 0.8
}
private fun handoff(drawn: Card): Double =
maxOf(0.0, 3.0 - golfValue(drawn)) * 0.15 * (1.0 + spite)
private fun flipBonus(index: Int): Double =
if (finalTurn || hidden <= 1) 0.0 else 0.35 + 0.5 * board.matchHope(cells, index)
/** Utility of [move] with [drawn] in hand; higher is better. */
fun utility(move: GolfMove, drawn: Card): Double = when (move) {
is ReplaceCard -> {
val after = cells.toMutableList().also { it[move.index] = drawn }
val closes = hidden == 1 && cells[move.index] == null
val afterScore = board.expected(after)
before - afterScore - if (closes) closePenalty(afterScore) else 0.0
}
is DiscardAndFlip -> {
val closes = hidden == 1
flipBonus(move.index) - handoff(drawn) - if (closes) closePenalty(before) else 0.0
}
is DiscardOnly -> -handoff(drawn)
else -> Double.NEGATIVE_INFINITY
}
/** The moves open to a card in hand, standing in for the legal list during look-ahead. */
fun candidates(fromDiscard: Boolean, putt: Boolean): List<GolfMove> = buildList {
for (index in cells.indices) add(ReplaceCard(index))
if (!fromDiscard) {
for (index in cells.indices) if (cells[index] == null) add(DiscardAndFlip(index))
if (putt && hidden == 1) add(DiscardOnly)
}
}
}
private fun planner(state: GolfState, seat: Int, profile: AiProfile): Planner {
val board = Board(state, seat)
return Planner(state, seat, board, effectiveBoldness(state, seat, profile), profile.style.spite)
}
private fun hardDraw(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
val top = state.discard.lastOrNull()
val canStock = legal.contains(DrawFromStock)
val canDiscard = legal.contains(DrawFromDiscard) && top != null
if (!canDiscard) return if (canStock) DrawFromStock else legal.first()
if (!canStock) return DrawFromDiscard
val plan = planner(state, seat, profile)
val putt = state.options.lineUpFinalPutt
// The discard has to be used, so it is only as good as its best replacement.
val takeUtility = plan.candidates(fromDiscard = true, putt = putt)
.maxOf { plan.utility(it, top!!) } +
profile.style.spite * 0.3 * maxOf(0, 4 - golfValue(top!!))
// The stock is a gamble on the average of what is left, but can be thrown back.
val ranks = plan.board.unseenRanks()
val total = ranks.sumOf { it.second }
val stockUtility = if (total == 0) {
Double.NEGATIVE_INFINITY
} else {
val moves = plan.candidates(fromDiscard = false, putt = putt)
ranks.sumOf { (rank, n) ->
val sample = Card(rank, Suit.SPADES)
n * moves.maxOf { plan.utility(it, sample) }
} / total
}
// A bold seat wants a clear edge before it passes up the unknown card.
val take = takeUtility - plan.boldness * 0.4 > stockUtility
return if (take) DrawFromDiscard else DrawFromStock
}
private fun hardPlace(
state: GolfState,
seat: Int,
legal: List<GolfMove>,
profile: AiProfile,
): GolfMove {
val drawn = state.drawn ?: return legal.first()
val plan = planner(state, seat, profile)
val options = legal.filter { it is ReplaceCard || it is DiscardAndFlip || it is DiscardOnly }
if (options.isEmpty()) return legal.first()
return options.maxBy { plan.utility(it, drawn) }
}
private companion object {
private val EASY = Params(discardAppetite = 2, closeOutBelow = 30)
private val NORMAL = Params(discardAppetite = 5, closeOutBelow = 16)
}
}
@@ -3,35 +3,98 @@ package org.prolibertate.games.game.hearts
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
* A cautious Hearts player.
* A Hearts player whose strength and temperament come from the seat's profile.
*
* It plays to avoid points rather than to shoot the moon: it sheds the queen of
* spades and the guards above her at the first opportunity, ducks under whoever
* is winning a trick when it can, and takes a trick as cheaply as possible when
* it cannot get out of the way. That is enough to make it an opponent worth
* beating without pretending to read the table.
* MEDIUM (the default) avoids points rather than shooting the moon: it sheds
* the queen of spades and the guards above her at the first opportunity, ducks
* under whoever is winning a trick when it can, and takes a trick as cheaply as
* possible when it cannot get out of the way.
*
* EASY plays the same plan but sometimes settles for a worse card in each
* decision. HARD counts the penalty cards and spades that have been taken,
* leads by risk, sheds by what is still dangerous, shoots the moon when its
* hand and the table allow it and tries to break a moon somebody else is on.
* It only uses its own hand and what the table has shown.
*/
object HeartsAi : GameAi<HeartsState, HeartsMove> {
class HeartsAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<HeartsState, HeartsMove> {
override fun chooseMove(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
HeartsPhase.PASSING -> choosePass(state, seat, legal)
else -> choosePlay(state, seat, legal)
val profile = profiles.of(seat)
val move = when (state.phase) {
HeartsPhase.PASSING -> choosePass(state, seat, legal, profile)
else -> choosePlay(state, seat, legal, profile)
}
return if (move in legal) move else legal.first()
}
private fun choosePass(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
// -----------------------------------------------------------------------
// Passing
// -----------------------------------------------------------------------
private fun choosePass(
state: HeartsState,
seat: Int,
legal: List<HeartsMove>,
profile: AiProfile,
): HeartsMove {
val hand = state.hands[seat]
val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
val wanted: Set<Card> = when (profile.level) {
AiLevel.MEDIUM -> hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
AiLevel.EASY -> easyPass(hand, profile)
AiLevel.HARD -> hardPass(state, hand, profile)
}
// Prefer the exact bundle the heuristic asked for, but fall back rather
// than fail: the caller's list is what the host will validate against.
return legal.firstOrNull { it is PassCards && it.cards.toSet() == wanted } ?: legal.first()
}
private fun easyPass(hand: List<Card>, profile: AiProfile): Set<Card> {
val ranked = hand.sortedByDescending { passDanger(it, hand) }
val chosen = ranked.take(PASS_SIZE).toMutableList()
if (profile.slips()) {
// Keeps one of the cards it should have sent.
val spare = ranked.drop(PASS_SIZE).take(4)
if (spare.isNotEmpty()) {
chosen[profile.random.nextInt(chosen.size)] = spare[profile.random.nextInt(spare.size)]
}
}
return chosen.toSet()
}
private fun hardPass(state: HeartsState, hand: List<Card>, profile: AiProfile): Set<Card> {
if (state.options.allowShootTheMoon && moonHand(hand, profile)) {
// Keep every high card and heart, send the three smallest.
val junk = hand.filter { !(it.suit == Suit.HEARTS && it.rank.order >= 10) }
.sortedBy { it.rank.order }
if (junk.size >= PASS_SIZE) return junk.take(PASS_SIZE).toSet()
}
return hand.sortedByDescending { passDanger(it, hand) + shortSuitBonus(it, hand) }
.take(PASS_SIZE).toSet()
}
/** Sending away the last cards of a short suit makes a void, which is worth a lot when discarding. */
private fun shortSuitBonus(card: Card, hand: List<Card>): Int {
if (card.suit == Suit.HEARTS || card.suit == Suit.SPADES) return 0
val length = hand.count { it.suit == card.suit }
return if (length > 3) 0 else (4 - length) * 8
}
/** A hand worth trying to take all twenty-six with: lots of high cards and a heart suit to run. */
private fun moonHand(hand: List<Card>, profile: AiProfile): Boolean {
val hearts = hand.count { it.suit == Suit.HEARTS }
if (hearts < 4) return false
val strength = hand.count { it.rank.order >= Rank.QUEEN.order } +
hand.count { it.suit == Suit.HEARTS && it.rank.order >= 9 }
return strength >= profile.bolder(8.0, -2.0)
}
/**
* How much a card wants to be somebody else's problem.
*
@@ -52,27 +115,41 @@ object HeartsAi : GameAi<HeartsState, HeartsMove> {
}
}
private fun choosePlay(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
// -----------------------------------------------------------------------
// Playing
// -----------------------------------------------------------------------
private fun choosePlay(
state: HeartsState,
seat: Int,
legal: List<HeartsMove>,
profile: AiProfile,
): HeartsMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.isEmpty()) return legal.first()
val chosen = when {
state.trick.isEmpty() -> lead(cards)
else -> follow(state, cards)
val chosen = when (profile.level) {
AiLevel.MEDIUM -> standardRanking(state, cards).first()
AiLevel.EASY -> profile.pick(standardRanking(state, cards))
AiLevel.HARD -> hardPlay(state, seat, cards, profile)
}
return PlayCard(chosen)
return PlayCard(if (chosen in cards) chosen else cards.first())
}
/** The standard plan's preferences, best first. */
private fun standardRanking(state: HeartsState, cards: List<Card>): List<Card> =
if (state.trick.isEmpty()) rankedLead(cards) else rankedFollow(state, cards)
/**
* Leads low, and keeps off spades while the queen is still unaccounted for —
* leading a spade under her is how a seat ends up holding her.
*/
private fun lead(cards: List<Card>): Card {
val offSpades = cards.filter { it.suit != Suit.SPADES }
val pool = if (offSpades.isNotEmpty()) offSpades else cards
return pool.minBy { it.rank.order }
private fun rankedLead(cards: List<Card>): List<Card> {
val offSpades = cards.filter { it.suit != Suit.SPADES }.sortedBy { it.rank.order }
val spades = cards.filter { it.suit == Suit.SPADES }.sortedBy { it.rank.order }
return offSpades + spades
}
private fun follow(state: HeartsState, cards: List<Card>): Card {
private fun rankedFollow(state: HeartsState, cards: List<Card>): List<Card> {
val led = state.trick.first().card.suit
val best = state.trick.maxOf { trickStrength(it.card, led) }
val pot = pointsIn(state.trick.map { it.card })
@@ -82,27 +159,189 @@ object HeartsAi : GameAi<HeartsState, HeartsMove> {
if (ducking.isNotEmpty()) {
// Safe under the current winner, so shed the most expensive card
// that still loses.
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
val rest = (cards - ducking.toSet()).sortedBy { it.rank.order }
return ducking.sortedByDescending { it.rank.order + pointsOf(it) * 2 } + rest
}
val void = cards.none { it.suit == led }
if (void) return discard(cards)
// Every card left would take the trick. Playing last with nothing in the
// pot means it costs nothing, so there is no reason to waste a high card
// on it; otherwise take it as cheaply as possible.
return if (last && pot == 0) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
return if (last && pot == 0) {
cards.sortedByDescending { it.rank.order }
} else {
cards.sortedBy { it.rank.order }
}
}
/** Void in the led suit and free to throw anything, so throw the worst of it. */
private fun discard(cards: List<Card>): Card {
cards.firstOrNull { it == QUEEN_OF_SPADES }?.let { return it }
cards.filter { it.suit == Suit.SPADES && it.rank.order > Rank.QUEEN.order }
.maxByOrNull { it.rank.order }
?.let { return it }
cards.filter { it.suit == Suit.HEARTS }
.maxByOrNull { it.rank.order }
?.let { return it }
return cards.maxBy { it.rank.order }
// -----------------------------------------------------------------------
// Hard play
// -----------------------------------------------------------------------
/** What this seat can legitimately know: its hand, and every card taken or on the table. */
private class Memory(state: HeartsState, seat: Int) {
val hand: List<Card> = state.hands[seat]
val unseen: List<Card>
init {
val seen = HashSet<Card>()
state.taken.forEach { seen.addAll(it) }
state.trick.forEach { seen.add(it.card) }
seen.addAll(hand)
unseen = FULL_DECK.filter { it !in seen }
}
val queenUnseen: Boolean get() = QUEEN_OF_SPADES in unseen
fun unseenIn(suit: Suit): Int = unseen.count { it.suit == suit }
fun unseenHigher(card: Card): Int =
unseen.count { it.suit == card.suit && it.rank.order > card.rank.order }
}
private fun hardPlay(state: HeartsState, seat: Int, cards: List<Card>, profile: AiProfile): Card {
val mem = Memory(state, seat)
if (isShooting(state, seat, mem, profile)) return shootPlay(state, cards, mem)
return if (state.trick.isEmpty()) {
hardLead(cards, mem, profile)
} else {
hardFollow(state, seat, cards, mem, profile)
}
}
/** Holding every point so far, with a hand that can keep winning. */
private fun isShooting(state: HeartsState, seat: Int, mem: Memory, profile: AiProfile): Boolean {
if (!state.options.allowShootTheMoon) return false
val all = pointsIn(state.taken.flatten())
val mine = pointsIn(state.taken[seat])
if (mine != all) return false
if (mine == 0 && !moonHand(mem.hand, profile)) return false
if (mem.hand.isEmpty()) return false
val masters = mem.hand.count { mem.unseenHigher(it) == 0 }
return masters.toDouble() / mem.hand.size >= profile.bolder(0.6, -0.15)
}
private fun shootPlay(state: HeartsState, cards: List<Card>, mem: Memory): Card {
if (state.trick.isEmpty()) {
val masters = cards.filter { mem.unseenHigher(it) == 0 }
if (masters.isNotEmpty()) {
return masters.sortedWith(
compareBy<Card> { it.suit == Suit.HEARTS }.thenByDescending { it.rank.order },
).first()
}
return cards.maxBy { it.rank.order }
}
val led = state.trick.first().card.suit
val best = state.trick.maxOf { trickStrength(it.card, led) }
val remaining = state.options.playerCount - state.trick.size - 1
val winning = cards.filter { trickStrength(it, led) > best }
if (winning.isNotEmpty()) {
if (remaining == 0) return winning.minBy { it.rank.order }
return winning.filter { mem.unseenHigher(it) == 0 }.minByOrNull { it.rank.order }
?: winning.maxBy { it.rank.order }
}
// Cannot win this one: lose it with a card that is not a point.
return cards.minBy { pointsOf(it) * 100 + it.rank.order }
}
/** Another seat that has taken every penalty card so far and looks set to run the table. */
private fun moonThreat(state: HeartsState, seat: Int): Int? {
if (!state.options.allowShootTheMoon) return null
val all = pointsIn(state.taken.flatten())
if (all < 14 && !(state.trickNumber >= 6 && all >= 8)) return null
return (0 until state.options.playerCount).firstOrNull {
it != seat && pointsIn(state.taken[it]) == all
}
}
private fun scoreLeader(state: HeartsState, seat: Int): Int? =
(0 until state.options.playerCount).filter { it != seat }.minByOrNull { state.scores[it] }
private fun hardLead(cards: List<Card>, mem: Memory, profile: AiProfile): Card {
// A cautious seat weighs a trick it might win more heavily than a bold one.
val weight = 1.0 - 0.25 * profile.style.boldness
fun risk(card: Card): Double {
val others = mem.unseenIn(card.suit)
val higher = mem.unseenHigher(card)
val winChance = if (others == 0) 1.0 else Math.pow(1.0 - higher.toDouble() / others, 3.0)
var points = 0.5
if (card.suit == Suit.HEARTS) points += 2.0
if (card.suit == Suit.SPADES && mem.queenUnseen) points += 13 * 0.3
// A suit nobody else holds lets everyone dump on whoever wins it.
if (others == 0) points += 3.0
val shedding = if (winChance < 0.15) 0.05 * card.rank.order else 0.0
return winChance * points * weight - shedding
}
return cards.minBy { risk(it) }
}
private fun hardFollow(
state: HeartsState,
seat: Int,
cards: List<Card>,
mem: Memory,
profile: AiProfile,
): Card {
val trick = state.trick
val led = trick.first().card.suit
val winner = trick.maxBy { trickStrength(it.card, led) }
val best = trickStrength(winner.card, led)
val pot = pointsIn(trick.map { it.card })
val remaining = state.options.playerCount - trick.size - 1
val winning = cards.filter { trickStrength(it, led) > best }
val ducking = cards.filter { trickStrength(it, led) < best }
val threat = moonThreat(state, seat)
// Taking even a few points is far better than letting a moon through.
if (threat != null && pot > 0 && winning.isNotEmpty()) {
return winning.minBy { it.rank.order }
}
val void = cards.none { it.suit == led }
if (void) return hardDischarge(state, seat, cards, mem, profile, winner.seat, threat)
if (ducking.isNotEmpty()) {
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
}
// Forced to win. Last to play, the trick is taken whatever is played,
// so spend the highest card; otherwise win as cheaply as possible.
return if (remaining == 0) {
cards.maxBy { it.rank.order }
} else {
cards.minBy { it.rank.order }
}
}
/** Void in the led suit: throw away whatever is still dangerous to hold. */
private fun hardDischarge(
state: HeartsState,
seat: Int,
cards: List<Card>,
mem: Memory,
profile: AiProfile,
winnerSeat: Int,
threat: Int?,
): Card {
val spoil = profile.style.spite >= 0.5 && winnerSeat == scoreLeader(state, seat)
fun score(card: Card): Double {
var s: Double = when {
card == QUEEN_OF_SPADES -> 1000.0
else -> card.rank.order.toDouble()
}
// The ace and king of spades only matter while the queen is out there.
if (card.suit == Suit.SPADES && card.rank.order > Rank.QUEEN.order && mem.queenUnseen) s += 200
if (card.suit == Suit.HEARTS) s += 100
if (pointsOf(card) == 0 && mem.hand.count { it.suit == card.suit } <= 2) s += 10
if (spoil && pointsOf(card) > 0) s += 300
// Do not feed a seat that is running for the moon.
if (threat != null && winnerSeat == threat && pointsOf(card) > 0) s -= 2000
return s
}
return cards.maxBy { score(it) }
}
private companion object {
val FULL_DECK: List<Card> = Suit.entries.flatMap { suit -> Rank.standard.map { Card(it, suit) } }
}
}
@@ -3,6 +3,10 @@ package org.prolibertate.games.game.kaiser
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -12,25 +16,51 @@ import org.prolibertate.games.game.engine.GameAi
* in play chases the five of hearts while trying not to be the one holding the
* trick the three of spades lands in.
*/
class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class KaiserAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<KaiserState, KaiserMove> {
enum class Difficulty(
private companion object {
/** Hand strength needed before bidding above the floor. */
val bidThreshold: Int,
) {
EASY(bidThreshold = 12),
NORMAL(bidThreshold = 16),
CAUTIOUS(bidThreshold = 19),
const val BID_THRESHOLD = 16.0
const val EASY_BID_THRESHOLD = 15.0
}
/** Cards this AI has watched played in the current hand, per seat it plays. */
private val memory = HashMap<Int, SeenCards>()
private class SeenCards {
var key: Long = Long.MIN_VALUE
val cards = HashSet<Card>()
}
/** Public information only: cards that have appeared on the table this hand. */
private fun observe(state: KaiserState, seat: Int): Set<Card> = synchronized(memory) {
val seen = memory.getOrPut(seat) { SeenCards() }
val key = state.seed * 1_000_003L + state.handNumber
if (key != seen.key || state.phase == KaiserPhase.BIDDING) {
seen.key = key
seen.cards.clear()
}
state.trick.forEach { seen.cards.add(it.card) }
state.completedTrick.forEach { seen.cards.add(it.card) }
seen.cards.removeAll(state.hands[seat].toSet())
seen.cards.toSet()
}
override fun chooseMove(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
KaiserPhase.BIDDING -> chooseBid(state, seat, legal)
KaiserPhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
val profile = profiles.of(seat)
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
val move = try {
when (state.phase) {
KaiserPhase.BIDDING -> chooseBid(state, seat, legal, profile)
KaiserPhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
else -> legal.first()
}
} catch (e: RuntimeException) {
legal.first()
}
return if (move in legal) move else legal.first()
}
/** Trump length and top cards, plus a nod to holding the counters. */
@@ -50,41 +80,137 @@ class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return value
}
private fun chooseBid(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
/**
* A finer rating on the same scale as [suitValue]: it rewards a run of top
* trump, short side suits to ruff with, and only counts the five of hearts
* when the hand can protect it.
*/
private fun hardValue(hand: List<Card>, trump: Suit): Int {
val trumps = hand.filter { it.suit == trump }
var value = trumps.size * 3
for (card in trumps) {
value += when (card.rank) {
Rank.ACE -> 4
Rank.KING -> 3
Rank.QUEEN -> 1
else -> 0
}
}
// A king backed by the ace, or a queen backed by both, is a real trick.
val ranks = trumps.map { it.rank.order }.toSet()
if (Rank.ACE.order in ranks && Rank.KING.order in ranks) value += 2
if (Rank.ACE.order in ranks && Rank.KING.order in ranks && Rank.QUEEN.order in ranks) value += 1
for (card in hand) {
if (card.suit == trump) continue
if (card.rank == Rank.ACE) value += 2
else if (card.rank == Rank.KING && hand.any { it.suit == card.suit && it.rank == Rank.ACE }) value += 1
}
if (trumps.size >= 3) {
value += Suit.entries.filter { it != trump }.count { suit -> hand.none { it.suit == suit } } * 2
}
if (hand.contains(FIVE_OF_HEARTS)) {
val guarded = trump == Suit.HEARTS ||
hand.any { it.suit == Suit.HEARTS && it.rank == Rank.ACE }
value += if (guarded) 3 else -1
}
if (hand.contains(THREE_OF_SPADES)) value -= if (trump == Suit.SPADES) 1 else 2
return value
}
private fun chooseBid(
state: KaiserState,
seat: Int,
legal: List<KaiserMove>,
profile: AiProfile,
): KaiserMove {
val hand = state.hands[seat]
val hard = profile.level == AiLevel.HARD
val easy = profile.level == AiLevel.EASY
val naming = legal.filterIsInstance<NameTrump>()
if (naming.isNotEmpty()) {
val hand = state.hands[seat]
val best = naming
.filter { it.suit != null }
.maxByOrNull { suitValue(hand, it.suit!!) }
return best ?: naming.first()
val named = naming.filter { it.suit != null }
return when {
hard -> named.maxByOrNull { hardValue(hand, it.suit!!) }
easy -> {
val noisy = named.associateWith { suitValue(hand, it.suit!!) + profile.random.nextInt(-4, 5) }
named.sortedByDescending { noisy.getValue(it) }.let { if (it.isEmpty()) null else profile.pick(it) }
}
else -> named.maxByOrNull { suitValue(hand, it.suit!!) }
} ?: naming.first()
}
val hand = state.hands[seat]
val bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
val strength = suitValue(hand, bestSuit)
val bestSuit: Suit
val strength: Int
if (hard) {
bestSuit = Suit.entries.maxBy { hardValue(hand, it) }
strength = hardValue(hand, bestSuit)
} else {
bestSuit = Suit.entries.maxByOrNull { suitValue(hand, it) } ?: Suit.SPADES
strength = suitValue(hand, bestSuit) + if (easy) profile.random.nextInt(-5, 6) else 0
}
val bids = legal.filterIsInstance<MakeBid>().filter { !it.bid.noTrump }
if (strength >= difficulty.bidThreshold && bids.isNotEmpty()) {
var needed = profile.bolder(if (easy) EASY_BID_THRESHOLD else BID_THRESHOLD, -4.0)
// A spoiler steals contracts from the other side.
val bidder = state.highBidder
if (bidder != null && teamOf(bidder) != teamOf(seat) && profile.style.spite >= 0.5) needed -= 2.0
if (hard && bids.isNotEmpty()) {
val cheapest = bids.minBy { it.bid.points }
// A bigger contract needs a bigger hand, and a partner who is
// already bidding is better supported than outbid.
needed += 2.0 * (cheapest.bid.points - state.options.minimumBid)
if (bidder != null && bidder == partnerOf(seat)) needed += 4.0
}
if (strength >= needed && bids.isNotEmpty()) {
// Bid the floor rather than reaching; the contract is what matters.
return bids.minBy { it.bid.points }
}
return legal.firstOrNull { it is PassBid } ?: bids.minByOrNull { it.bid.points } ?: legal.first()
}
private fun choosePlay(state: KaiserState, seat: Int, legal: List<KaiserMove>): KaiserMove {
private fun choosePlay(
state: KaiserState,
seat: Int,
legal: List<KaiserMove>,
profile: AiProfile,
seen: Set<Card>,
): KaiserMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
val bidder = state.highBidder
val defending = bidder != null && teamOf(bidder) != teamOf(seat)
val spiteful = defending && profile.style.spite >= 0.5
val best = if (profile.level == AiLevel.HARD) {
hardPlay(state, seat, cards, seen, bidder != null && !defending)
} else {
standardPlay(state, seat, cards, spiteful, profile)
}
if (profile.level != AiLevel.EASY) return PlayCard(best)
// Easy sometimes throws a high card, or a counter, at the wrong time.
val ranked = listOf(best) + (cards - best).sortedByDescending { it.rank.order }
return PlayCard(profile.pick(ranked))
}
private fun standardPlay(
state: KaiserState,
seat: Int,
cards: List<Card>,
spiteful: Boolean,
profile: AiProfile,
): Card {
if (state.trick.isEmpty()) {
// Leading: pull with the top trump, else lead an ace.
val trump = state.trump
val topTrump = cards.filter { trump != null && it.suit == trump }
.maxByOrNull { it.rank.order }
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return PlayCard(topTrump)
if (topTrump != null && topTrump.rank.order >= Rank.KING.order) return topTrump
val ace = cards.filter { it.rank == Rank.ACE }.maxByOrNull { it.rank.order }
if (ace != null) return PlayCard(ace)
return PlayCard(cards.minBy { it.rank.order })
if (ace != null) return ace
return cards.minBy { it.rank.order }
}
val ledSuit = state.trick.first().card.suit
@@ -96,22 +222,129 @@ class KaiserAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
// The five of hearts is worth more than the trick it rides in on.
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
// A spoiler on the defence will not leave a trick to a partner who may be beaten.
val notLast = state.trick.size < 3
val letPartner = partnerWinning && !threeOnTable &&
!(spiteful && notLast && winners.isNotEmpty()) && !profile.slips()
return when {
// Partner has it and the trick is worth taking: let them have it,
// unless the three of spades is in there.
partnerWinning && !threeOnTable -> PlayCard(cards.minBy { it.rank.order })
letPartner -> cards.minBy { it.rank.order }
fiveOnTable && winners.isNotEmpty() ->
PlayCard(winners.maxBy { trickStrength(it, state.trump, ledSuit) })
winners.maxBy { trickStrength(it, state.trump, ledSuit) }
// Nobody wants the three; duck if the trick is already lost.
threeOnTable && winners.isEmpty() -> PlayCard(cards.minBy { it.rank.order })
threeOnTable && winners.isEmpty() -> cards.minBy { it.rank.order }
winners.isNotEmpty() && !threeOnTable ->
PlayCard(winners.minBy { trickStrength(it, state.trump, ledSuit) })
winners.minBy { trickStrength(it, state.trump, ledSuit) }
else -> PlayCard(cards.minBy { it.rank.order })
else -> cards.minBy { it.rank.order }
}
}
/**
* The strong player's choice: it remembers what has been played, so it
* knows which of its cards are sure winners, feeds the five of hearts to a
* partner who is safe, and buries the three of spades under the other side.
*/
private fun hardPlay(
state: KaiserState,
seat: Int,
cards: List<Card>,
seen: Set<Card>,
bidding: Boolean,
): Card {
val trump = state.trump
val hand = state.hands[seat]
val unseen = Decks.kaiser().filter { it !in seen && it !in hand }
val counters = setOf(FIVE_OF_HEARTS, THREE_OF_SPADES)
fun isBoss(card: Card): Boolean {
val mine = trickStrength(card, trump, card.suit)
val cardIsTrump = trump != null && card.suit == trump
return unseen.none { other ->
(cardIsTrump || trump == null || other.suit != trump) &&
trickStrength(other, trump, card.suit) > mine
}
}
if (state.trick.isEmpty()) {
val plain = cards.filter { it !in counters }.ifEmpty { return cards.minBy { it.rank.order } }
val myTrump = plain.filter { trump != null && it.suit == trump }
val unseenTrump = unseen.count { trump != null && it.suit == trump }
val bossTrump = myTrump.filter { isBoss(it) }.maxByOrNull { it.rank.order }
if (bidding && unseenTrump > 0 && bossTrump != null) return bossTrump
plain.filter { it !in myTrump && isBoss(it) }.maxByOrNull { it.rank.order }?.let { return it }
if (bidding && unseenTrump > 0 && myTrump.size >= 2) return myTrump.maxBy { it.rank.order }
if (bossTrump != null) return bossTrump
val counts = hand.groupingBy { it.suit }.eachCount()
val offSuit = plain.filter { it !in myTrump }.ifEmpty { plain }
// The bidders draw a suit out; the defenders shorten one to ruff.
return if (bidding) {
offSuit.minWith(compareBy<Card>({ -(counts[it.suit] ?: 0) }, { it.rank.order }))
} else {
offSuit.minWith(compareBy<Card>({ counts[it.suit] ?: 0 }, { it.rank.order }))
}
}
val ledSuit = state.trick.first().card.suit
val best = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) }
val partnerWinning = partnerOf(best.seat) == seat
val remaining = 4 - state.trick.size - 1
val bestStrength = trickStrength(best.card, trump, ledSuit)
val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength }
val fiveOnTable = state.trick.any { it.card == FIVE_OF_HEARTS }
val threeOnTable = state.trick.any { it.card == THREE_OF_SPADES }
fun sure(card: Card): Boolean =
remaining == 0 ||
unseen.none { trickStrength(it, trump, ledSuit) > trickStrength(card, trump, ledSuit) }
fun throwAway(): Card {
val plain = cards.filter { it !in counters }
if (plain.isNotEmpty()) return plain.minBy { it.rank.order }
// Only counters left: the five belongs with partner, the three does not.
return if (partnerWinning) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
}
if (partnerWinning) {
if (sure(best.card)) {
// Partner has it: feed them the five of hearts if we can.
if (FIVE_OF_HEARTS in cards && trickStrength(FIVE_OF_HEARTS, trump, ledSuit) <= bestStrength) {
return FIVE_OF_HEARTS
}
return throwAway()
}
if (!threeOnTable) {
winners.filter { sure(it) }.minByOrNull { trickStrength(it, trump, ledSuit) }?.let { return it }
}
return throwAway()
}
// The other side is winning.
if (threeOnTable) return throwAway()
if (fiveOnTable && winners.isNotEmpty()) {
val safe = winners.filter { sure(it) }
return if (safe.isNotEmpty()) {
safe.minBy { trickStrength(it, trump, ledSuit) }
} else {
winners.maxBy { trickStrength(it, trump, ledSuit) }
}
}
// Bury the three under a trick the other side is certain to take.
if (THREE_OF_SPADES in cards &&
trickStrength(THREE_OF_SPADES, trump, ledSuit) <= bestStrength &&
sure(best.card)
) {
return THREE_OF_SPADES
}
if (winners.isNotEmpty()) {
val safe = winners.filter { sure(it) }
return (safe.ifEmpty { winners }).minBy { trickStrength(it, trump, ledSuit) }
}
return throwAway()
}
}
@@ -1,6 +1,7 @@
package org.prolibertate.games.game.mastermind
import kotlin.random.Random
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -16,9 +17,20 @@ import org.prolibertate.games.game.engine.GameAi
* - **Strong** is Knuth's idea one ply deep: among the codes still standing, it
* plays the one whose *worst* possible answer leaves the smallest number of
* candidates behind, so it cannot be given a reply that tells it little.
*
* The table already carries its own three-step level ([MastermindOptions.level]),
* chosen at setup and shown to the player, and it means exactly what an
* [org.prolibertate.games.game.engine.AiLevel] would here. Layering a second
* knob on top would make the two disagree, so the shared [profiles] are
* accepted for a uniform constructor but this game keeps playing at the level
* the table names. [level] overrides that, which is what the tests use.
*/
class MastermindAi(private val level: MastermindLevel? = null) :
GameAi<MastermindState, MastermindMove> {
class MastermindAi(
@Suppress("unused") private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: MastermindLevel? = null,
) : GameAi<MastermindState, MastermindMove> {
constructor(level: MastermindLevel?) : this(AiProfiles.DEFAULT, level)
override fun chooseMove(
state: MastermindState,
@@ -1,6 +1,9 @@
package org.prolibertate.games.game.morris
import kotlin.random.Random
import kotlin.math.roundToInt
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -19,7 +22,20 @@ import org.prolibertate.games.game.engine.GameAi
*
* Strength comes from the table's own options unless a level is passed in.
*/
class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, MorrisMove> {
class MorrisAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: MorrisLevel? = null,
) : GameAi<MorrisState, MorrisMove> {
// Viewpoint and weights for the move being chosen. At the default (balanced)
// style they equal the plain constants, so Medium is unchanged.
private var rootSeat = 0
private var wMill = MILL
private var wAlmost = ALMOST
private var wTheirAlmost = ALMOST
private var wMobility = MOBILITY
private var wTheirMobility = MOBILITY
private var wDanger = DANGER
/**
* Ceiling on how much thinking any one move gets, so a phone does not stall
@@ -32,10 +48,29 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
override fun chooseMove(state: MorrisState, seat: Int, legal: List<MorrisMove>): MorrisMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
nodeBudget = NODE_BUDGET
val bold = profile.style.boldness
val spite = profile.style.spite
rootSeat = seat
// Bold seats value mills and half-built mills more; cautious ones value
// mobility and avoiding the three-piece trap. Spiteful seats weigh the
// opponent's mill threats and freedom of movement more.
wMill = MILL + (bold * 6).roundToInt()
wAlmost = ALMOST + (bold * 3).roundToInt()
wTheirAlmost = ALMOST + (spite * 4).roundToInt() + (-bold * 2).roundToInt()
wMobility = MOBILITY + (-bold * 1).roundToInt()
wTheirMobility = MOBILITY + (spite * 2).roundToInt() + (-bold * 1).roundToInt()
wDanger = DANGER + (-bold * 10).roundToInt()
val maxDepth = when (profile.level) {
AiLevel.EASY -> maxOf(2, setting.depth - 2)
AiLevel.MEDIUM -> setting.depth
AiLevel.HARD -> setting.depth + 1
}
nodeBudget = if (profile.level == AiLevel.HARD) HARD_NODE_BUDGET else NODE_BUDGET
nodesUsed = 0
// Deterministic for a given position, so a host and a client replaying
@@ -46,7 +81,7 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
for (depth in 1..maxDepth) {
val results = mutableListOf<Pair<MorrisMove, Int>>()
val ordered = order(legal, preferred = best)
for (move in ordered) {
@@ -73,7 +108,16 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
val chosen = acceptable[random.nextInt(acceptable.size)].first
if (profile.level != AiLevel.EASY) return chosen
// Easy now and then settles for another sound-looking move: only ones
// within about a piece of the best, never a real blunder.
val ranked = listOf(chosen) + scored
.filter { it.first != chosen && it.second >= bestScore - EASY_TOLERANCE }
.sortedByDescending { it.second }
.map { it.first }
return profile.pick(ranked)
}
/** How far below best a move may score and still be considered. */
@@ -155,16 +199,17 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
/** The position from the point of view of the side to move. */
private fun evaluate(state: MorrisState): Int {
val us = state.turn
val them = other(us)
val me = rootSeat
val them = other(me)
var score = PIECE * (state.remaining(us) - state.remaining(them))
score += MILL * (state.millCount(us) - state.millCount(them))
score += ALMOST * (almostMills(state, us) - almostMills(state, them))
score += MOBILITY * (mobility(state, us) - mobility(state, them))
score += LADDER * (midEdgeCount(state, us) - midEdgeCount(state, them))
score -= DANGER * (onThree(state, us) - onThree(state, them))
return score
var score = PIECE * (state.remaining(me) - state.remaining(them))
score += wMill * (state.millCount(me) - state.millCount(them))
score += wAlmost * almostMills(state, me) - wTheirAlmost * almostMills(state, them)
score += wMobility * mobility(state, me) - wTheirMobility * mobility(state, them)
score += LADDER * (midEdgeCount(state, me) - midEdgeCount(state, them))
score -= wDanger * (onThree(state, me) - onThree(state, them))
// Scored from the root seat's side; flip for the side to move.
return if (state.turn == me) score else -score
}
/**
@@ -215,6 +260,8 @@ class MorrisAi(private val level: MorrisLevel? = null) : GameAi<MorrisState, Mor
const val INFINITY = 1_000_000
const val WIN = 100_000
const val NODE_BUDGET = 120_000
const val HARD_NODE_BUDGET = 280_000
const val EASY_TOLERANCE = 120
const val PIECE = 120
const val MILL = 26
@@ -1,6 +1,10 @@
package org.prolibertate.games.game.pirates
import kotlin.math.roundToInt
import kotlin.random.Random
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -14,17 +18,73 @@ import org.prolibertate.games.game.engine.GameAi
* from the Bulgars' side and negated for the pirates, so the two cannot drift
* apart and start valuing the same position differently.
*/
class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState, PiratesMove> {
class PiratesAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: PiratesLevel? = null,
) : GameAi<PiratesState, PiratesMove> {
private var nodeBudget = 0
private var nodesUsed = 0
// Evaluation weights for the seat now choosing; the standard values unless
// a style or level bends them. Set at the top of each chooseMove.
private var wBulgar = BULGAR
private var wStronghold = IN_STRONGHOLD
private var wAdvance = ADVANCE
private var wExposed = EXPOSED
private var wRoom = PIRATE_ROOM
private fun tune(profile: AiProfile, seat: Int) {
val bold = profile.style.boldness
val spite = profile.style.spite
var bulgar = 1.0
var stronghold = 1.0
var advance = 1.0
var exposed = 1.0
var room = 1.0
if (seat == BULGAR_SEAT) {
// A bold crowd pushes forward and risks being jumped; a cautious one stays tight.
exposed -= 0.3 * bold
advance += 0.3 * bold
// A spoiler squeezes the pirates' room.
room += 0.4 * spite
} else {
// A bold pirate hunts; a cautious one keeps its room to move.
bulgar += 0.15 * bold
room -= 0.3 * bold
// A spoiler pays more attention to stopping the climb.
stronghold += 0.4 * spite
advance += 0.4 * spite
}
if (profile.level == AiLevel.EASY) exposed *= 0.5 // overlooks some threats
wBulgar = (BULGAR * bulgar).roundToInt()
wStronghold = (IN_STRONGHOLD * stronghold).roundToInt()
wAdvance = (ADVANCE * advance).roundToInt()
wExposed = (EXPOSED * exposed).roundToInt()
wRoom = (PIRATE_ROOM * room).roundToInt()
}
override fun chooseMove(state: PiratesState, seat: Int, legal: List<PiratesMove>): PiratesMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
val profile = profiles.of(seat)
val base = level ?: state.options.level
tune(profile, seat)
nodeBudget = NODE_BUDGET
val hard = profile.level == AiLevel.HARD
val easy = profile.level == AiLevel.EASY
val maxDepth = when (profile.level) {
AiLevel.MEDIUM -> base.depth
AiLevel.EASY -> minOf(base.depth, 2)
AiLevel.HARD -> base.depth + 2
}
val slack = when (profile.level) {
AiLevel.MEDIUM -> base.slack()
AiLevel.EASY -> maxOf(base.slack(), 40)
AiLevel.HARD -> 0
}
nodeBudget = if (hard) HARD_NODE_BUDGET else NODE_BUDGET
nodesUsed = 0
val random = Random(state.moveLog.size * 31L + state.positionKey().hashCode())
@@ -32,18 +92,23 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
var best = legal.first()
var scored = legal.map { it to 0 }
for (depth in 1..setting.depth) {
for (depth in 1..maxDepth) {
val results = mutableListOf<Pair<PiratesMove, Int>>()
val ordered = order(legal, preferred = best)
var bestSoFar = -INFINITY
for (move in ordered) {
// A hard seat only needs the exact value of moves that tie or beat
// the best so far, so everything else is searched against a window.
val floor = if (hard && results.isNotEmpty()) bestSoFar - 1 else -INFINITY
val score = -negamax(
state = PiratesRules.advanced(state, move),
depth = depth - 1,
alpha = -INFINITY,
beta = INFINITY,
beta = -floor,
ply = 1,
)
results += move to score
if (score > bestSoFar) bestSoFar = score
if (nodesUsed >= nodeBudget) break
}
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
@@ -51,9 +116,16 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
best = results.maxBy { it.second }.first
}
if (easy) {
val ranked = scored.sortedByDescending { it.second }.map { it.first }
val picked = profile.pick(ranked)
if (picked != ranked.first() && picked in legal) return picked
}
val bestScore = scored.maxOf { it.second }
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
return acceptable[random.nextInt(acceptable.size)].first
val acceptable = scored.filter { it.second >= bestScore - slack }
val chosen = acceptable[random.nextInt(acceptable.size)].first
return if (chosen in legal) chosen else legal.first()
}
private fun PiratesLevel.slack(): Int = when (this) {
@@ -115,22 +187,22 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
private fun evaluate(state: PiratesState): Int {
var bulgarView = 0
bulgarView += state.count(BULGAR_SEAT) * BULGAR
bulgarView += state.stronghold() * IN_STRONGHOLD
bulgarView += state.count(BULGAR_SEAT) * wBulgar
bulgarView += state.stronghold() * wStronghold
// How far up the board the crowd has come. The stronghold is at the
// top, so a low row number is ground gained.
for (point in 0 until POINTS) {
if (state.board[point] != BULGAR_SEAT) continue
bulgarView += (GRID - 1 - rowOf(point)) * ADVANCE
bulgarView += (GRID - 1 - rowOf(point)) * wAdvance
// A Bulgar with an empty point directly behind it on a line is one
// a pirate can jump. Being unjumpable is most of staying alive.
if (isExposed(state, point)) bulgarView -= EXPOSED
if (isExposed(state, point)) bulgarView -= wExposed
}
// Room for the pirates to work in, which is what the Bulgars are
// slowly taking away.
bulgarView -= pirateMobility(state) * PIRATE_ROOM
bulgarView -= pirateMobility(state) * wRoom
return if (state.turn == BULGAR_SEAT) bulgarView else -bulgarView
}
@@ -160,6 +232,7 @@ class PiratesAi(private val level: PiratesLevel? = null) : GameAi<PiratesState,
const val INFINITY = 1_000_000
const val WIN = 100_000
const val NODE_BUDGET = 150_000
const val HARD_NODE_BUDGET = 400_000
const val BULGAR = 100
const val IN_STRONGHOLD = 55
@@ -1,63 +1,85 @@
package org.prolibertate.games.game.president
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic President opponent.
* A President opponent whose strength and temperament come from the seat's profile.
*
* It sheds low cards early, spends the cheapest set that takes the pile, and
* holds twos back for when they are worth something. It does not count cards.
* MEDIUM (the default) sheds low cards early, spends the cheapest set that takes
* the pile, and holds twos back for when they are worth something. It does not
* count cards.
*
* EASY spends high cards freely when leading and sometimes plays a worse set or
* passes when it should have played. HARD reads the round's log to count which
* ranks are still out, leads so that its remaining hand is unbeatable, blocks an
* opponent who is about to go out, and keeps its high cards back when a cheap
* pile is not worth them.
*/
class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class PresidentAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<PresidentState, PresidentMove> {
enum class Difficulty(
/** Rank above which a card is hoarded rather than spent to lead. */
val hoardAbove: Int,
) {
/** Plays whatever is cheapest, whenever it can. */
EASY(hoardAbove = 99),
NORMAL(hoardAbove = 13),
/** Sits on high cards until late, then dumps them. */
CAUTIOUS(hoardAbove = 11),
}
override fun chooseMove(
state: PresidentState,
seat: Int,
legal: List<PresidentMove>,
): PresidentMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val plays = legal.filterIsInstance<PlayCards>()
if (plays.isEmpty()) return legal.first()
return if (state.setSize == 0) chooseLead(state, seat, plays) else chooseFollow(state, plays, legal)
val move: PresidentMove = when (profile.level) {
AiLevel.MEDIUM -> standard(state, seat, plays, legal, hoardAbove = HOARD_ABOVE)
AiLevel.EASY -> easy(state, seat, plays, legal, profile)
AiLevel.HARD -> hard(state, seat, plays, legal, profile)
}
return if (move in legal) move else legal.first()
}
private fun standard(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
legal: List<PresidentMove>,
hoardAbove: Int,
): PresidentMove =
if (state.setSize == 0) {
leadRanking(state, seat, plays, hoardAbove).first()
} else {
chooseFollow(state, plays, legal)
}
// -----------------------------------------------------------------------
// Standard plan
// -----------------------------------------------------------------------
/**
* Leading is a chance to shed. Prefer the biggest set of the lowest rank,
* which clears junk fastest without spending anything valuable.
*/
private fun chooseLead(
private fun leadRanking(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
): PresidentMove {
hoardAbove: Int,
): List<PlayCards> {
val endgame = state.hands[seat].size <= 4
val candidates = plays.filter { play ->
endgame || presidentRank(play.cards.first()) <= difficulty.hoardAbove
endgame || presidentRank(play.cards.first()) <= hoardAbove
}.ifEmpty { plays }
return candidates
.sortedWith(
compareBy(
{ presidentRank(it.cards.first()) },
{ -it.cards.size },
)
)
.first()
return candidates.sortedWith(
compareBy<PlayCards>(
{ presidentRank(it.cards.first()) },
{ -it.cards.size },
),
)
}
/**
@@ -82,4 +104,188 @@ class PresidentAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return cheapest
}
// -----------------------------------------------------------------------
// Easy
// -----------------------------------------------------------------------
private fun easy(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
legal: List<PresidentMove>,
profile: AiProfile,
): PresidentMove {
if (state.setSize == 0) {
// Never hoards: leads aces and twos as readily as anything else.
return profile.pick(leadRanking(state, seat, plays, hoardAbove = 99))
}
val best = chooseFollow(state, plays, legal)
val alternatives = plays.sortedBy { presidentRank(it.cards.first()) }
val ranked = buildList<PresidentMove> {
add(best)
addAll(alternatives.filter { it != best })
if (PassTurn in legal && best != PassTurn) add(PassTurn)
}
return profile.pick(ranked)
}
// -----------------------------------------------------------------------
// Hard
// -----------------------------------------------------------------------
/** Ranks still unaccounted for: not in this seat's hand and not played yet this round. */
private class Counts(state: PresidentState, seat: Int) {
private val unseen = HashMap<Int, Int>()
init {
for (rank in 3..15) unseen[rank] = 4
state.hands[seat].forEach { dec(presidentRank(it)) }
val log = state.log
val start = log.indexOfLast { ROUND_MARK.matches(it) } + 1
for (i in start until log.size) {
val line = log[i]
val at = line.indexOf(" plays ")
if (!line.startsWith("Seat ") || at < 0) continue
line.substring(at + " plays ".length).removeSuffix(".").split(' ').forEach { token ->
LABELS[token]?.let { dec(presidentRank(it)) }
}
}
}
private fun dec(rank: Int) {
unseen[rank] = maxOf(0, (unseen[rank] ?: 0) - 1)
}
fun of(rank: Int): Int = unseen[rank] ?: 0
/** Could an opponent still hold a set of [size] that beats a set of [size] at [rank]? */
fun beatable(rank: Int, size: Int, options: PresidentOptions): Boolean {
if (options.twosClear && rank != 15 && of(15) >= size) return true
if (options.fourOfAKindBomb && size == 4 && (3..15).any { it != rank && of(it) == 4 }) return true
return (rank + 1..15).any { of(it) >= size }
}
}
private fun hard(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
legal: List<PresidentMove>,
profile: AiProfile,
): PresidentMove {
val hand = state.hands[seat]
// Going out beats any plan.
plays.firstOrNull { it.cards.size == hand.size }?.let { return it }
val counts = Counts(state, seat)
val others = (0 until state.options.playerCount).filter { it != seat && !state.isOut(it) }
return if (state.setSize == 0) {
hardLead(state, seat, plays, counts, others, profile)
} else {
hardFollow(state, seat, plays, legal, counts, others, profile)
}
}
/** An opponent who could empty their hand with a set the size of the one on top, or is the score leader close to out. */
private fun outThreat(
state: PresidentState,
seat: Int,
others: List<Int>,
profile: AiProfile,
): Boolean {
if (others.any { state.handCounts[it] <= maxOf(1, state.setSize) }) return true
if (profile.style.spite >= 0.5) {
val leader = others.maxByOrNull { state.scores[it] }
if (leader != null && state.handCounts[leader] <= 2) return true
}
return false
}
private fun hardLead(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
counts: Counts,
others: List<Int>,
profile: AiProfile,
): PresidentMove {
val hand = state.hands[seat]
val options = state.options
val oneCardLeft = others.any { state.handCounts[it] == 1 }
fun unbeatableAfter(play: PlayCards): Boolean {
val remaining = hand.toMutableList().also { left -> play.cards.forEach { left.remove(it) } }
return remaining.groupBy { presidentRank(it) }
.all { (rank, cards) -> !counts.beatable(rank, cards.size, options) }
}
fun key(play: PlayCards): Double {
val rank = presidentRank(play.cards.first())
val size = play.cards.size
var k = rank - 0.9 * size
if (unbeatableAfter(play)) return k - 50.0
// Leading aces and twos spends control that is worth more later.
if (rank >= 14 && hand.size > 4) k += 20.0
// Somebody is down to one card: a single would let them out if they can beat it.
if (oneCardLeft && size == 1) k += 10.0 + (15 - rank) * 2.0
// A bold seat is happier to lead its strong sets early.
if (rank >= 11) k -= profile.style.boldness * 2.0
return k
}
return plays.minBy { key(it) }
}
private fun hardFollow(
state: PresidentState,
seat: Int,
plays: List<PlayCards>,
legal: List<PresidentMove>,
counts: Counts,
others: List<Int>,
profile: AiProfile,
): PresidentMove {
val hand = state.hands[seat]
val options = state.options
val canPass = PassTurn in legal
val threat = outThreat(state, seat, others, profile)
val withoutTwos = plays.filter { play -> play.cards.none { isTwo(it) } }
val pool = withoutTwos.ifEmpty { plays }
val cheapest = pool.minBy { presidentRank(it.cards.first()) }
if (threat) {
// Block them: take the pile with the cheapest set nobody can beat,
// or failing that the cheapest set there is, twos included.
val sure = plays.filter { !counts.beatable(presidentRank(it.cards.first()), it.cards.size, options) }
.minByOrNull { presidentRank(it.cards.first()) }
return sure ?: plays.minBy { presidentRank(it.cards.first()) }
}
val spendingTwo = cheapest.cards.any { isTwo(it) }
if (spendingTwo) {
val remaining = hand.toMutableList().also { left -> cheapest.cards.forEach { left.remove(it) } }
val runOut = remaining.groupBy { presidentRank(it) }
.all { (rank, cards) -> !counts.beatable(rank, cards.size, options) }
val worthIt = state.setRank >= 13 || state.pile.size >= 4 || hand.size <= 4 || runOut
if (!worthIt && canPass) return PassTurn
return cheapest
}
// A cheap pile is not worth a high card while the hand is still big.
val gap = profile.bolder(6.0, 2.0)
val cost = presidentRank(cheapest.cards.first()) - state.setRank
if (canPass && hand.size > 5 && cost > gap && presidentRank(cheapest.cards.first()) >= 12) {
return PassTurn
}
return cheapest
}
private companion object {
const val HOARD_ABOVE = 13
val ROUND_MARK = Regex("^Round \\d+\\.$")
val LABELS: Map<String, Card> = Suit.entries.flatMap { suit ->
Rank.standard.map { Card(it, suit) }
}.associateBy { it.label }
}
}
@@ -1,5 +1,8 @@
package org.prolibertate.games.game.sequence
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -8,18 +11,39 @@ import org.prolibertate.games.game.engine.GameAi
* It scores every legal placement by how much it advances one of its own runs
* and how badly it spoils an opponent's, holds its jacks back until they are
* worth spending, and never looks at anyone's hand.
*
* Per seat, from [profiles]: Easy mostly ignores your runs, burns its one-eyed
* jacks on anything and sometimes settles for a lesser square; Hard blocks hard,
* saves jacks for real threats, always takes a completed run and always stops
* one that is one square from done. Boldness leans towards building over
* blocking and spite the other way round.
*/
class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class SequenceAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<SequenceState, SequenceMove> {
enum class Difficulty(val blockWeight: Int, val jackPatience: Int) {
/** Builds its own runs and mostly ignores yours. */
EASY(blockWeight = 1, jackPatience = 0),
/** The numbers one seat plays with, worked out from its level and style. */
private class Tuning(
val ownWeight: Double,
val blockWeight: Double,
val jackPatience: Double,
val finishing: Boolean,
)
NORMAL(blockWeight = 3, jackPatience = 3),
/** Blocks aggressively and saves jacks for real threats. */
CAUTIOUS(blockWeight = 5, jackPatience = 4),
private fun tuningOf(profile: AiProfile): Tuning {
val (block, patience) = when (profile.level) {
AiLevel.EASY -> 1.0 to 0.0
AiLevel.MEDIUM -> 3.0 to 3.0
AiLevel.HARD -> 5.0 to 4.0
}
val style = profile.style
val easy = profile.level == AiLevel.EASY
return Tuning(
ownWeight = 4.0 + style.boldness,
blockWeight = block - style.boldness * 0.8 + style.spite * 2.0,
// Only a seat that is paying attention changes its mind about jacks.
jackPatience = if (easy) patience else patience + style.boldness - style.spite,
finishing = profile.level == AiLevel.HARD,
)
}
override fun chooseMove(
@@ -28,15 +52,30 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
legal: List<SequenceMove>,
): SequenceMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val tuning = tuningOf(profile)
val team = state.teamOf(seat)
// Clearing a dead card is free value when nothing better is on offer.
val exchange = legal.filterIsInstance<ExchangeDeadCard>().firstOrNull()
if (exchange != null && legal.none { it is PlaceChip }) return exchange
val best = legal
val placements = legal
.filterIsInstance<PlaceChip>()
.maxByOrNull { scorePlacement(state, it, team) }
.map { it to scorePlacement(state, it, team, tuning) }
.sortedByDescending { it.second }
.map { it.first }
val best = if (placements.isEmpty()) {
null
} else if (tuning.finishing) {
// A run that completes, or a run of the opponent's that would, is
// never something to be talked out of.
placements.first()
} else {
// Easy only: the others never slip. Only the better few are in play,
// so a slip is a weaker square rather than a wasted card.
profile.pick(placements.take(4))
}
val removal = legal
.filterIsInstance<RemoveChip>()
@@ -45,7 +84,7 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
// Spend a one-eyed jack only on a genuinely dangerous chip.
if (removal != null) {
val threat = threatAt(state, removal.cell, state.chips[removal.cell])
if (threat >= difficulty.jackPatience) return removal
if (threat >= tuning.jackPatience && !profile.slips()) return removal
}
return best ?: removal ?: exchange ?: legal.first()
@@ -56,14 +95,25 @@ class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
* opponent's run it interrupts. Wild jacks are discounted so they are not
* burned on an ordinary square.
*/
private fun scorePlacement(state: SequenceState, move: PlaceChip, team: Int): Int {
private fun scorePlacement(
state: SequenceState,
move: PlaceChip,
team: Int,
tuning: Tuning,
): Double {
val ours = runPotential(state, move.cell, team)
val theirs = (0 until state.options.teamCount)
.filter { it != team }
.maxOfOrNull { runPotential(state, move.cell, it) }
?: 0
var score = ours * 4 + theirs * difficulty.blockWeight
var score = ours * tuning.ownWeight + theirs * tuning.blockWeight
if (tuning.finishing) {
// A potential of five through this square means placing here
// completes the run (for whoever places it).
if (ours >= RUN_LENGTH) score += 100.0
if (theirs >= RUN_LENGTH) score += 60.0 else if (theirs == RUN_LENGTH - 1) score += 8.0
}
// Central squares sit on more possible lines than edge squares.
val row = SequenceBoard.rowOf(move.cell)
val col = SequenceBoard.colOf(move.cell)
@@ -1,5 +1,8 @@
package org.prolibertate.games.game.tayu
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
/**
@@ -18,17 +21,53 @@ import org.prolibertate.games.game.engine.GameAi
*
* [level] overrides the table's own setting, which is what the tests use;
* ordinarily it is left null and read from [TayuOptions.level].
*
* Per seat, from [profiles]: the table's setting says which terms count at all
* (Gentle ignores the opponent entirely and that is respected at every level).
* Within Full strength, Easy blocks and steers less and sometimes takes one of
* its better few placements rather than the best; Hard blocks and steers harder.
* Boldness weights its own axis against the opponent's, spite weights blocking.
*/
class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove> {
class TayuAi(
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: TayuLevel? = null,
) : GameAi<TayuState, TayuMove> {
constructor(level: TayuLevel) : this(AiProfiles.DEFAULT, level)
private class Weights(val own: Double, val block: Double, val reach: Double)
private fun weightsOf(setting: TayuLevel, profile: AiProfile): Weights {
var own = setting.ownWeight.toDouble()
var block = setting.blockWeight.toDouble()
var reach = setting.reachWeight.toDouble()
// Gentle is a table rule, not a skill level: it stays as it is.
if (setting.blockWeight > 0) {
when (profile.level) {
AiLevel.EASY -> { block *= 0.4; reach *= 0.5 }
AiLevel.MEDIUM -> Unit
AiLevel.HARD -> { block *= 1.35; reach *= 1.5 }
}
own *= 1.0 + 0.15 * profile.style.boldness
block *= 1.0 - 0.15 * profile.style.boldness + 0.4 * profile.style.spite
}
return Weights(own, block, reach)
}
override fun chooseMove(state: TayuState, seat: Int, legal: List<TayuMove>): TayuMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val setting = level ?: state.options.level
if (legal.size == 1) return legal.first()
return legal.maxByOrNull { rate(state, seat, it, setting) } ?: legal.first()
val weights = weightsOf(setting, profile)
val rated = legal.map { it to rate(state, seat, it, weights) }
if (profile.level != AiLevel.EASY) return rated.maxByOrNull { it.second }?.first ?: legal.first()
// Easy: one of its better few placements, not always the best.
val ranked = rated.sortedByDescending { it.second }.take(EASY_POOL).map { it.first }
return profile.pick(ranked)
}
private fun rate(state: TayuState, seat: Int, move: TayuMove, setting: TayuLevel): Int {
private fun rate(state: TayuState, seat: Int, move: TayuMove, weights: Weights): Double {
val after = TayuRules.previewPlacement(state, seat, move)
val mine = state.axisOf(seat)
val theirs = if (mine == Axis.NORTH_SOUTH) Axis.EAST_WEST else Axis.NORTH_SOUTH
@@ -43,15 +82,15 @@ class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove>
// Skipped rather than multiplied by zero: the gentle setting is the whole
// reason to avoid walking the open mouths at all, and this runs for every
// one of a thousand-odd candidate placements.
val reach = if (setting.reachWeight == 0) {
val reach = if (weights.reach == 0.0) {
0
} else {
(pull(after, mine) - pull(after, theirs)) / REACH_SCALE
}
return setting.ownWeight * ours -
setting.blockWeight * opposing +
setting.reachWeight * reach
return weights.own * ours -
weights.block * opposing +
weights.reach * reach
}
/**
@@ -82,5 +121,8 @@ class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove>
private companion object {
/** Brings the open-mouth term onto the same scale as the score term. */
const val REACH_SCALE = 8
/** How many top-rated placements Easy chooses among. */
const val EASY_POOL = 6
}
}
@@ -3,8 +3,13 @@ package org.prolibertate.games.game.wizard
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
import kotlin.math.abs
import kotlin.math.pow
import kotlin.math.roundToInt
/**
@@ -15,52 +20,99 @@ import kotlin.math.roundToInt
* high it is and whether it is trump. In play it chases tricks while it is
* short of its bid and ducks once it has them, which is the whole game.
*/
class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
class WizardAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<WizardState, WizardMove> {
enum class Difficulty(
/**
* Added to the expected-trick count before rounding. Optimists overbid
* and go down; pessimists underbid and get caught taking tricks they
* did not want.
*/
val bidBias: Double,
) {
EASY(bidBias = 0.35),
NORMAL(bidBias = 0.0),
CAUTIOUS(bidBias = -0.25),
private companion object {
/** Optimists overbid and go down; Easy leans this way. */
const val EASY_BID_BIAS = 0.35
val DECK: List<Card> = Decks.wizard()
}
/** Cards this AI has watched played in the current round, per seat it plays. */
private val memory = HashMap<Int, SeenCards>()
private class SeenCards {
var key: Long = Long.MIN_VALUE
val cards = HashSet<Card>()
}
/** Public information only: cards that have appeared on the table this round. */
private fun observe(state: WizardState, seat: Int): Set<Card> = synchronized(memory) {
val seen = memory.getOrPut(seat) { SeenCards() }
val key = state.seed * 1_000_003L + state.round
if (key != seen.key || state.phase == WizardPhase.BIDDING) {
seen.key = key
seen.cards.clear()
}
state.trick.forEach { seen.cards.add(it.card) }
state.completedTrick.forEach { seen.cards.add(it.card) }
seen.cards.removeAll(state.hands[seat].toSet())
seen.cards.toSet()
}
override fun chooseMove(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
WizardPhase.BIDDING -> chooseBid(state, seat, legal)
WizardPhase.PLAYING -> choosePlay(state, seat, legal)
else -> legal.first()
val profile = profiles.of(seat)
val seen = if (profile.level == AiLevel.HARD) observe(state, seat) else emptySet()
val move = try {
when (state.phase) {
WizardPhase.BIDDING -> chooseBid(state, seat, legal, profile)
WizardPhase.PLAYING -> choosePlay(state, seat, legal, profile, seen)
else -> legal.first()
}
} catch (e: RuntimeException) {
legal.first()
}
return if (move in legal) move else legal.first()
}
// -----------------------------------------------------------------------
// Bidding
// -----------------------------------------------------------------------
private fun chooseBid(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
private fun chooseBid(
state: WizardState,
seat: Int,
legal: List<WizardMove>,
profile: AiProfile,
): WizardMove {
val hand = state.hands[seat]
val hard = profile.level == AiLevel.HARD
val easy = profile.level == AiLevel.EASY
val naming = legal.filterIsInstance<ChooseTrump>()
if (naming.isNotEmpty()) {
val hand = state.hands[seat]
val best = naming.maxByOrNull { suitStrength(hand, it.suit) }
return best ?: naming.first()
return when {
hard -> naming.maxByOrNull { hardExpected(state, hand, it.suit) }
easy -> {
val noisy = naming.associateWith { suitStrength(hand, it.suit) + profile.random.nextDouble(-0.8, 0.8) }
profile.pick(naming.sortedByDescending { noisy.getValue(it) })
}
else -> naming.maxByOrNull { suitStrength(hand, it.suit) }
} ?: naming.first()
}
val bids = legal.filterIsInstance<MakeBid>()
if (bids.isEmpty()) return legal.first()
val wanted = (expectedTricks(state.hands[seat], state.trump) + difficulty.bidBias)
.roundToInt()
.coerceIn(0, state.cardsThisRound)
var expected = if (hard) hardExpected(state, hand, state.trump) else expectedTricks(hand, state.trump)
if (hard) {
// The last to bid knows how many tricks the others have left on the table.
val others = state.bids.filterNotNull()
if (others.size == state.options.playerCount - 1) {
expected = expected * 0.75 + (state.cardsThisRound - others.sum()) * 0.25
}
}
if (easy) expected += EASY_BID_BIAS + profile.random.nextDouble(-0.8, 0.8)
// A gambler bids up, a cautious seat bids down.
expected += profile.bolder(0.0, 0.3)
val wanted = expected.roundToInt().coerceIn(0, state.cardsThisRound)
// Screw-the-dealer may have taken the number we wanted away, so settle
// for the nearest one still on offer.
return bids.minBy { abs(it.tricks - wanted) }
val ranked = bids.sortedBy { abs(it.tricks - wanted) }
return if (easy) profile.pick(ranked) else ranked.first()
}
/** How much of a hand a given trump suit would be worth. */
@@ -92,22 +144,88 @@ class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return expected
}
/**
* The chance [card] takes a trick, from the cards that could still beat it.
*
* [pool] is every card that might be in somebody else's hand and [q] the
* chance any one of them is, and each higher card only threatens the trick
* if it lands in the same one, which is less likely the longer the round.
*/
private fun winProb(card: Card, pool: List<Card>, trump: Suit?, q: Double, tricks: Int): Double {
if (isWizardCard(card)) return 1.0
if (isJester(card)) return 0.0
val wizards = pool.count { isWizardCard(it) && it != card }
val ordinary = { c: Card -> !isWizardCard(c) && !isJester(c) }
val trumpCard = trump != null && card.suit == trump
val higher = pool.count { ordinary(it) && it.suit == card.suit && it.rank.order > card.rank.order }
var danger = (higher + wizards).toDouble()
if (!trumpCard && trump != null) {
val voidFactor = 1.0 / (1.0 + 0.5 * (tricks - 1))
danger += voidFactor * pool.count { ordinary(it) && it.suit == trump }
}
val sequencing = 1.0 / (1.0 + 0.35 * (tricks - 1))
return (1.0 - (q * sequencing).coerceIn(0.0, 1.0)).pow(danger)
}
/** Expected tricks from the cards that could still be out, calibrated so the average hand scores its fair share. */
private fun hardExpected(state: WizardState, hand: List<Card>, trump: Suit?): Double {
val n = state.cardsThisRound
val players = state.options.playerCount
val inPlay = DECK.filter { it != state.trumpCard }
val mine = inPlay.filter { it !in hand }
val q = ((players - 1) * n).toDouble() / mine.size.coerceAtLeast(1)
val own = hand.sumOf { winProb(it, mine, trump, q, n) }
val average = inPlay.sumOf { winProb(it, inPlay, trump, q, n) } / inPlay.size
val scale = if (average > 0.0) (1.0 / (players * average)).coerceIn(0.5, 2.0) else 1.0
return own * scale
}
// -----------------------------------------------------------------------
// Play
// -----------------------------------------------------------------------
private fun choosePlay(state: WizardState, seat: Int, legal: List<WizardMove>): WizardMove {
private fun choosePlay(
state: WizardState,
seat: Int,
legal: List<WizardMove>,
profile: AiProfile,
seen: Set<Card>,
): WizardMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.size == 1) return PlayCard(cards.first())
val hard = profile.level == AiLevel.HARD
val bid = state.bids[seat] ?: 0
val stillNeeded = bid - state.tricksWon[seat]
val tricksLeft = state.hands[seat].size
// Chase while we are short, and only while there are enough tricks left
// to be worth chasing.
val wantsTrick = stillNeeded > 0 && stillNeeded <= tricksLeft
// to be worth chasing. (The careful player keeps chasing even when the
// bid is out of reach: every trick still cuts the penalty.)
val wantsTrick = stillNeeded > 0 && (hard || stillNeeded <= tricksLeft)
// A spoiler who has already blown their own bid takes tricks off anyone
// who needs exactly one more.
val spoiling = profile.style.spite >= 0.5 && stillNeeded < 0
if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, state.trump, wantsTrick))
val best = if (hard) {
hardPlay(state, seat, cards, seen, wantsTrick, spoiling, stillNeeded, tricksLeft)
} else {
standardPlay(state, cards, wantsTrick, spoiling)
}
if (profile.level != AiLevel.EASY) return PlayCard(best)
// Easy sometimes burns a strong card at the wrong moment.
val ranked = listOf(best) + (cards - best).sortedByDescending { it.rank.order }
return PlayCard(profile.pick(ranked))
}
private fun standardPlay(
state: WizardState,
cards: List<Card>,
wantsTrick: Boolean,
spoiling: Boolean,
): Card {
if (state.trick.isEmpty()) return chooseLead(cards, state.trump, wantsTrick)
val ledSuit = ledSuitOf(state.trick)
val order = state.trick.size
@@ -122,15 +240,99 @@ class WizardAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
return when {
// Take it as cheaply as we can.
wantsTrick && winners.isNotEmpty() -> PlayCard(winners.minBy { strengthOf(it) })
wantsTrick && winners.isNotEmpty() -> winners.minBy { strengthOf(it) }
// Spite: stop somebody from landing the bid they need.
spoiling && winners.isNotEmpty() && victimNeedsOne(state) -> winners.minBy { strengthOf(it) }
// Cannot take it, or do not want it: throw the least useful card.
losers.isNotEmpty() -> PlayCard(losers.minBy { strengthOf(it) })
losers.isNotEmpty() -> losers.minBy { strengthOf(it) }
// Everything we hold wins, and we did not want the trick. Take it
// with the weakest winner so the good cards stay for later.
else -> PlayCard(cards.minBy { strengthOf(it) })
else -> cards.minBy { strengthOf(it) }
}
}
/** Whether whoever is winning the trick right now needs exactly this trick to make their bid. */
private fun victimNeedsOne(state: WizardState): Boolean {
val ledSuit = ledSuitOf(state.trick)
val leading = state.trick
.withIndex()
.maxByOrNull { (index, played) -> trickStrength(played.card, index, state.trump, ledSuit) }
?: return false
val victim = leading.value.seat
return (state.bids[victim] ?: return false) - state.tricksWon[victim] == 1
}
/**
* The strong player's choice. It keeps track of which cards are gone to
* judge how likely each of its own is to win, spends sure winners when it
* needs tricks, and sheds its most dangerous cards onto tricks it will
* lose anyway when it does not.
*/
private fun hardPlay(
state: WizardState,
seat: Int,
cards: List<Card>,
seen: Set<Card>,
wantsTrick: Boolean,
spoiling: Boolean,
stillNeeded: Int,
tricksLeft: Int,
): Card {
val hand = state.hands[seat]
val unseen = DECK.filter { it != state.trumpCard && it !in seen && it !in hand }
val oppCards = state.handCounts.sum() - hand.size
val q = if (unseen.isEmpty()) 0.0 else (oppCards.toDouble() / unseen.size).coerceIn(0.0, 1.0)
val trump = state.trump
fun pWin(card: Card): Double = winProb(card, unseen, trump, q, tricksLeft)
if (state.trick.isEmpty()) {
if (wantsTrick) {
val scored = cards.map { it to pWin(it) }
val bestOverall = scored.maxBy { it.second }
// With tricks to spare, save the wizard for when it is needed.
if (tricksLeft > stillNeeded) {
val bestPlain = scored.filter { !isWizardCard(it.first) }.maxByOrNull { it.second }
if (bestPlain != null && bestPlain.second >= 0.6) return bestPlain.first
}
return bestOverall.first
}
// Ducking: lead whatever is least likely to win, a jester best of all.
return cards.minWith(compareBy<Card>({ pWin(it) }, { it.rank.order }))
}
val ledSuit = ledSuitOf(state.trick)
val order = state.trick.size
val remaining = state.options.playerCount - order - 1
val bestOnTable = state.trick
.withIndex()
.maxOf { (index, played) -> trickStrength(played.card, index, trump, ledSuit) }
val strengthOf = { card: Card -> trickStrength(card, order, trump, ledSuit) }
val winners = cards.filter { strengthOf(it) > bestOnTable }
val losers = cards.filter { strengthOf(it) <= bestOnTable }
fun sure(card: Card): Boolean =
remaining == 0 || unseen.none { trickStrength(it, order + 1, trump, ledSuit) > strengthOf(card) }
if (wantsTrick) {
if (winners.isNotEmpty()) {
val certain = winners.filter { sure(it) }
return (certain.ifEmpty { winners }).minBy { strengthOf(it) }
}
// Cannot take this one: keep the strong cards for a trick that can be won.
return losers.minBy { strengthOf(it) }
}
if (spoiling && winners.isNotEmpty() && victimNeedsOne(state)) {
return winners.minBy { strengthOf(it) }
}
if (losers.isNotEmpty()) {
// Unload the card that would be hardest to lose later, but keep the
// jester: it is the one card that can never win.
val nonJester = losers.filter { !isJester(it) }
return (nonJester.ifEmpty { losers }).maxBy { strengthOf(it) }
}
return cards.minBy { strengthOf(it) }
}
private fun chooseLead(cards: List<Card>, trump: Suit?, wantsTrick: Boolean): Card {
if (wantsTrick) {
// A led wizard is an unloseable trick.
@@ -1,6 +1,11 @@
package org.prolibertate.games.game.yahtzee
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfile
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
import kotlin.math.exp
import kotlin.math.sqrt
/**
* Plays the dice by eye rather than by arithmetic.
@@ -10,33 +15,53 @@ import org.prolibertate.games.game.engine.GameAi
* enumerated the thirty-two subsets and applied them would be reading the roll
* before making it — beating the player by seeing the future rather than by
* playing well. Everything below decides from the dice already on the table.
*
* Per seat, from [profiles]: Easy sometimes holds a poor set of dice and
* sometimes writes a lesser box than the best on offer. Hard works out, for
* every way of holding dice, the *expected* value of what could be thrown
* (averaging over all outcomes, never over the roll that will actually come),
* valuing each box by what it scores less what that box is usually worth, with
* the upper-section bonus tracked. Boldness chases the bonus and big combos
* and rolls on with slimmer odds; caution takes a sure score sooner.
*/
object YahtzeeAi : GameAi<YahtzeeState, YahtzeeMove> {
class YahtzeeAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) :
GameAi<YahtzeeState, YahtzeeMove> {
override fun chooseMove(state: YahtzeeState, seat: Int, legal: List<YahtzeeMove>): YahtzeeMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val profile = profiles.of(seat)
val rolls = legal.filterIsInstance<RollDice>()
val writes = legal.filterIsInstance<ScoreIn>()
// Nothing on the table yet, so there is only one thing to do.
if (!state.hasRolled) return rolls.firstOrNull() ?: writes.first()
if (profile.level == AiLevel.HARD && writes.isNotEmpty()) {
return chooseExpected(state, seat, rolls, writes, profile)
}
val best = writes.maxByOrNull { scoreOf(it.category, state.dice) }
val ranked = writes.sortedByDescending { scoreOf(it.category, state.dice) }
// Easy sometimes writes the second or third best box rather than the best.
val best = if (ranked.isEmpty()) null else profile.pick(ranked.take(EASY_BOXES))
val bestScore = best?.let { scoreOf(it.category, state.dice) } ?: 0
// A hand already worth taking, or no rolls left, ends the turn.
if (rolls.isEmpty()) return best ?: writes.first()
if (bestScore >= WORTH_KEEPING) return best!!
if (bestScore >= profile.bolder(WORTH_KEEPING.toDouble(), 4.0)) return best!!
val keep = whatToKeep(state.dice).toSet()
val keep = if (profile.slips()) poorKeep(state.dice) else whatToKeep(state.dice).toSet()
return rolls.firstOrNull { it.keep == keep } ?: rolls.first()
}
/**
* Good enough to stop throwing. Set at a full house, which is the point
* where a hand is worth more than an average reroll of it.
*/
private const val WORTH_KEEPING = 25
/** What a distracted player holds: the high dice, or the pair they notice second. */
private fun poorKeep(dice: List<Int>): Set<Int> {
val counts = countsOf(dice)
val second = counts.entries
.sortedWith(compareByDescending<Map.Entry<Int, Int>> { it.value }.thenBy { it.key })
.drop(1)
.firstOrNull { it.value >= 2 }
if (second != null) return dice.indices.filter { dice[it] == second.key }.toSet()
return dice.indices.filter { dice[it] >= 4 }.toSet()
}
/**
* What to hold on to: a run if one is nearly there, otherwise the face there
@@ -82,4 +107,213 @@ object YahtzeeAi : GameAi<YahtzeeState, YahtzeeMove> {
face in wanted && taken.add(face)
}
}
// -- Hard: expected values ------------------------------------------------
/**
* Values every box as what it scores now less what it is normally worth, so
* that writing a zero in Ones costs little and writing one in Yahtzee costs a
* lot, then looks two throws ahead by averaging over every possible outcome
* of every way of holding dice.
*/
private fun chooseExpected(
state: YahtzeeState,
seat: Int,
rolls: List<RollDice>,
writes: List<ScoreIn>,
profile: AiProfile,
): YahtzeeMove {
val valuation = Valuation(state, seat, profile.style.boldness)
val dice = state.dice
val stop = writes.maxByOrNull { valuation.gain(it.category, dice) } ?: writes.first()
if (rolls.isEmpty()) return stop
val stopValue = valuation.gain(stop.category, dice)
// terminal[i]: best write for the i-th multiset of dice with no throws left.
val terminal = DoubleArray(DiceTables.multisets.size) { i ->
val faces = DiceTables.facesOf(i)
writes.maxOf { valuation.gain(it.category, faces) }
}
// Each throw still to come after the next one, worked backwards.
var next = terminal
repeat(state.rollsLeft - 1) {
val previous = next
next = DoubleArray(terminal.size) { i ->
maxOf(terminal[i], bestKeep(i, previous).second)
}
}
val here = DiceTables.indexOf(countVector(dice))
val (keepCounts, expected) = bestKeep(here, next)
val margin = -profile.style.boldness
if (expected <= stopValue + margin) return stop
val remaining = keepCounts.copyOf()
val keep = dice.indices.filter { position ->
val face = dice[position] - 1
if (remaining[face] > 0) {
remaining[face]--
true
} else {
false
}
}.toSet()
return rolls.firstOrNull { it.keep == keep } ?: stop
}
/** The best set of dice to hold from multiset [index], and what throwing the rest is worth. */
private fun bestKeep(index: Int, after: DoubleArray): Pair<IntArray, Double> {
var bestKeep = DiceTables.keepsOf(index).first()
var best = Double.NEGATIVE_INFINITY
for (keep in DiceTables.keepsOf(index)) {
val thrown = DICE_COUNT - keep.sum()
var key = 0
for (face in 0 until 6) key += keep[face] * DiceTables.POWERS[face]
var total = 0.0
for (outcome in DiceTables.rerolls[thrown]) {
total += outcome.probability * after[DiceTables.indexOfKey[key + outcome.key]]
}
if (total > best + 1e-9) {
best = total
bestKeep = keep
}
}
return bestKeep to best
}
private fun countVector(dice: List<Int>): IntArray {
val counts = IntArray(6)
dice.forEach { counts[it - 1]++ }
return counts
}
/** What a box is worth to this card right now, in points relative to its usual yield. */
private class Valuation(state: YahtzeeState, seat: Int, private val boldness: Double) {
private val card = state.cards[seat]
private val yahtzeeBonus = state.options.yahtzeeBonus && card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE
private val openUpper = YahtzeeCategory.entries.filter {
it.section == YahtzeeSection.UPPER && !card.isFilled(it)
}
private val secured = card.upperSubtotal >= UPPER_BONUS_AT
private val diff = YahtzeeCategory.entries
.filter { it.section == YahtzeeSection.UPPER && card.isFilled(it) }
.sumOf { (card[it] ?: 0) - 3 * it.face!! }
private val bonusValue = UPPER_BONUS * (1.0 + 0.4 * boldness)
fun gain(category: YahtzeeCategory, dice: List<Int>): Double {
val score = scoreOf(category, dice)
var value = score - TYPICAL[category.ordinal]
if (category.section == YahtzeeSection.UPPER && !secured) {
val face = category.face!!
val after = if (card.upperSubtotal + score >= UPPER_BONUS_AT) {
1.0
} else {
chance(diff + score - 3 * face, openUpper - category)
}
value += bonusValue * (after - chance(diff, openUpper))
}
// The big combinations: a bold seat values them a little more.
if (score > 0 && (category == YahtzeeCategory.LARGE_STRAIGHT ||
category == YahtzeeCategory.YAHTZEE || category == YahtzeeCategory.FOUR_OF_A_KIND)
) {
value += boldness * 2.0
}
if (yahtzeeBonus && isYahtzee(dice)) value += YAHTZEE_BONUS
return value
}
/** Likelihood the upper section still reaches the bonus from [difference] with [open] to fill. */
private fun chance(difference: Int, open: List<YahtzeeCategory>): Double {
if (open.isEmpty()) return if (difference >= 0) 1.0 else 0.0
val expected = open.sumOf { TYPICAL[it.ordinal] - 3 * it.face!! }
val spread = 2.2 * sqrt(open.size.toDouble()) + 0.3
return 1.0 / (1.0 + exp(-(difference + expected + 1.5) / spread))
}
}
/** Every multiset of five dice and every throw of n dice, worked out once. */
private object DiceTables {
class Outcome(val key: Int, val probability: Double)
val POWERS = intArrayOf(1, 6, 36, 216, 1296, 7776)
val indexOfKey = IntArray(46656) { -1 }
val multisets: List<IntArray> = vectors(DICE_COUNT)
val rerolls: Array<List<Outcome>> = Array(DICE_COUNT + 1) { n ->
val factorial = IntArray(6) { f -> (1..f).fold(1) { a, b -> a * b } }
val total = Math.pow(6.0, n.toDouble())
vectors(n).map { counts ->
val ways = (1..n).fold(1) { a, b -> a * b } / counts.fold(1) { a, c -> a * factorial[c] }
Outcome(keyOf(counts), ways / total)
}
}
private val keeps: Array<List<IntArray>?> = arrayOfNulls(multisets.size)
init {
multisets.forEachIndexed { i, counts -> indexOfKey[keyOf(counts)] = i }
}
fun indexOf(counts: IntArray): Int = indexOfKey[keyOf(counts)]
fun facesOf(index: Int): List<Int> {
val faces = ArrayList<Int>(DICE_COUNT)
multisets[index].forEachIndexed { f, c -> repeat(c) { faces += f + 1 } }
return faces
}
/** Every distinct set of dice that can be held back from multiset [index]. */
fun keepsOf(index: Int): List<IntArray> {
keeps[index]?.let { return it }
val counts = multisets[index]
var all = listOf(IntArray(6))
for (face in 0 until 6) {
all = all.flatMap { partial ->
(0..counts[face]).map { n -> partial.copyOf().also { it[face] = n } }
}
}
keeps[index] = all
return all
}
private fun keyOf(counts: IntArray): Int {
var key = 0
for (face in 0 until 6) key += counts[face] * POWERS[face]
return key
}
/** Every way to split [size] dice over six faces. */
private fun vectors(size: Int): List<IntArray> {
val out = ArrayList<IntArray>()
fun build(face: Int, left: Int, current: IntArray) {
if (face == 5) {
current[5] = left
out += current.copyOf()
return
}
for (n in 0..left) {
current[face] = n
build(face + 1, left - n, current)
}
}
build(0, size, IntArray(6))
return out
}
}
private companion object {
/**
* Good enough to stop throwing. Set at a full house, which is the point
* where a hand is worth more than an average reroll of it.
*/
const val WORTH_KEEPING = 25
/** How many of the best boxes Easy chooses among when it writes a score. */
const val EASY_BOXES = 3
/**
* What each box yields on average under strong play, in the order of
* [YahtzeeCategory]: the cost of spending it.
*/
val TYPICAL = doubleArrayOf(
2.11, 5.28, 8.57, 12.16, 15.69, 19.19, 21.66, 13.10, 22.59, 29.46, 32.71, 16.87, 22.01,
)
}
}
@@ -0,0 +1,58 @@
package org.prolibertate.games.game.yahtzee
import kotlinx.serialization.Serializable
/** What one finished game says about the local player's card. */
@Serializable
data class YahtzeeRecord(
val total: Int,
val gotBonus: Boolean,
/** Yahtzees scored, counting the extra ones that earned the hundred. */
val yahtzees: Int,
/** Boxes written in as nothing. */
val zeros: Int,
val players: Int,
val won: Boolean,
)
/** Builds the record of [seat]'s card in a finished game. Ties for first count as a win. */
fun recordOf(state: YahtzeeState, seat: Int): YahtzeeRecord {
val card = state.cards[seat]
val mine = state.totalFor(seat)
val best = state.cards.indices.maxOf { state.totalFor(it) }
return YahtzeeRecord(
total = mine,
gotBonus = card.upperBonus > 0,
yahtzees = (if (card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE) 1 else 0) +
state.yahtzeeBonuses[seat] / YAHTZEE_BONUS,
zeros = card.boxes.count { it == 0 },
players = state.cards.size,
won = mine >= best,
)
}
/** A game set against the ones before it. [rank] is 1 for a new best. */
data class YahtzeeComparison(
val current: YahtzeeRecord,
val previousGames: Int,
val bestBefore: Int?,
val averageBefore: Double?,
val rank: Int,
val gamesRanked: Int,
val bonusRateBefore: Double?,
val yahtzeesBefore: Int,
val winRateBefore: Double?,
)
fun compareToHistory(previous: List<YahtzeeRecord>, current: YahtzeeRecord): YahtzeeComparison =
YahtzeeComparison(
current = current,
previousGames = previous.size,
bestBefore = previous.maxOfOrNull { it.total },
averageBefore = previous.takeIf { it.isNotEmpty() }?.map { it.total }?.average(),
rank = 1 + previous.count { it.total > current.total },
gamesRanked = previous.size + 1,
bonusRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.gotBonus }.toDouble() / p.size },
yahtzeesBefore = previous.sumOf { it.yahtzees },
winRateBefore = previous.takeIf { it.isNotEmpty() }?.let { p -> p.count { it.won }.toDouble() / p.size },
)
@@ -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)
@@ -24,6 +24,8 @@ data class Settings(
val soundEnabled: Boolean = true,
val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true,
/** Multiplies the system font size across the whole app. */
val textScale: Float = 1.0f,
/**
* Exactly what the user has typed, which may be blank while they are
* mid-edit. Nothing substitutes a default into this value: doing so meant
@@ -46,6 +48,8 @@ data class Settings(
companion object {
const val MIN_SPEED = 0.5f
const val MAX_SPEED = 2.0f
const val MIN_TEXT_SCALE = 0.8f
const val MAX_TEXT_SCALE = 1.6f
const val DEFAULT_PLAYER_NAME = "Player"
}
}
@@ -56,6 +60,7 @@ class SettingsRepository(private val context: Context) {
val SOUND = booleanPreferencesKey("sound_enabled")
val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val TEXT_SCALE = floatPreferencesKey("text_scale")
val NAME = stringPreferencesKey("player_name")
// There is deliberately no update-channel key. The channel is a
// property of the installed APK, not a preference — an install that
@@ -69,6 +74,8 @@ class SettingsRepository(private val context: Context) {
soundEnabled = prefs[Keys.SOUND] ?: true,
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
textScale = (prefs[Keys.TEXT_SCALE] ?: 1.0f)
.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE),
playerName = prefs[Keys.NAME] ?: "",
)
}
@@ -83,6 +90,12 @@ class SettingsRepository(private val context: Context) {
}
}
suspend fun setTextScale(scale: Float) {
context.dataStore.edit {
it[Keys.TEXT_SCALE] = scale.coerceIn(Settings.MIN_TEXT_SCALE, Settings.MAX_TEXT_SCALE)
}
}
suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) {
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
}
@@ -0,0 +1,42 @@
package org.prolibertate.games.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
import org.prolibertate.games.game.yahtzee.YahtzeeRecord
import org.prolibertate.games.game.yahtzee.compareToHistory
private val Context.yahtzeeStatsStore: DataStore<Preferences> by preferencesDataStore(
name = "yahtzee_stats",
)
/** The local player's finished Yahtzee games, newest last, kept to the last [KEEP]. */
class YahtzeeStatsRepository(private val context: Context) {
private val key = stringPreferencesKey("games")
private val json = Json { ignoreUnknownKeys = true }
private fun decode(stored: String?): List<YahtzeeRecord> =
stored?.let { runCatching { json.decodeFromString<List<YahtzeeRecord>>(it) }.getOrNull() }
.orEmpty()
/** Compares [record] with every earlier game, then adds it to the history. */
suspend fun finish(record: YahtzeeRecord): YahtzeeComparison {
val previous = decode(context.yahtzeeStatsStore.data.first()[key])
context.yahtzeeStatsStore.edit {
it[key] = json.encodeToString((previous + record).takeLast(KEEP))
}
return compareToHistory(previous, record)
}
private companion object {
const val KEEP = 500
}
}
@@ -20,6 +20,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -49,11 +51,13 @@ import org.prolibertate.games.net.ScorekeeperSession
import org.prolibertate.games.score.ScorekeeperRepository
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.settings.YahtzeeStatsRepository
import org.prolibertate.games.ui.game.LocalCardSpeed
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
@@ -68,6 +72,7 @@ class AppEnv(
val peerId: String,
/** Games in progress, kept between sessions. */
val saves: SavedGameStore,
val yahtzeeStats: YahtzeeStatsRepository,
)
/**
@@ -84,8 +89,16 @@ sealed interface Route {
data class Section(val menu: GameMenu) : Route
data object Settings : Route
data object Scorekeeper : 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,
@@ -139,7 +152,14 @@ fun AppRoot(env: AppEnv) {
// Card motion reads its speed from here rather than from a parameter.
// Every card screen animates, most of them several layers deep, and the
// alternative was passing one unchanging number down through all of it.
CompositionLocalProvider(LocalCardSpeed provides settings.animationSpeed) {
val density = LocalDensity.current
val scaledDensity = remember(density, settings.textScale) {
Density(density.density, density.fontScale * settings.textScale)
}
CompositionLocalProvider(
LocalCardSpeed provides settings.animationSpeed,
LocalDensity provides scaledDensity,
) {
AppContent(
env = env,
settings = settings,
@@ -177,10 +197,8 @@ private fun AppContent(
// Dev builds show the games that are not finished yet; production
// releases list only what can actually be played.
showComingSoon = showComingSoon,
saved = savedGames(env, stack),
onResume = { saved ->
push(Route.Play(saved.gameId, saved.config, true, saved.localSeat, saved.stateJson))
},
hasSaved = savedGames(env, stack).isNotEmpty(),
onResume = { push(Route.Resume) },
onPickSection = { push(Route.Section(it)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onScorekeeper = { push(Route.Scorekeeper) },
@@ -195,6 +213,25 @@ private fun AppContent(
onBack = pop,
)
is Route.Resume -> {
// Bumped when a game is deleted, so the list is read again.
var deletions by remember(route) { mutableIntStateOf(0) }
val saved by produceState(emptyList<SavedMatch>(), route, deletions) {
value = withContext(Dispatchers.IO) { env.saves.all() }
}
ResumeScreen(
saved = saved,
onResume = { match ->
push(Route.Play(match.gameId, match.config, true, match.localSeat, match.stateJson))
},
onDelete = { match ->
env.saves.clear(match.gameId)
deletions++
},
onBack = pop,
)
}
is Route.Settings -> SettingsScreen(
env = env,
settings = settings,
@@ -219,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 ->
@@ -260,8 +297,8 @@ private fun AppContent(
@Composable
fun MainMenuScreen(
showComingSoon: Boolean,
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
hasSaved: Boolean,
onResume: () -> Unit,
onPickSection: (GameMenu) -> Unit,
onJoinGame: () -> Unit,
onScorekeeper: () -> Unit,
@@ -311,20 +348,11 @@ fun MainMenuScreen(
)
}
// Games left half-played, most recent first, above everything
// else: coming back to the app is mostly coming back to one.
if (saved.isNotEmpty()) {
Text(
"Continue",
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.fillMaxWidth(),
)
val now = System.currentTimeMillis()
saved.forEach { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Button(onClick = { onResume(match) }, modifier = Modifier.fillMaxWidth()) {
Text(savedLine(title, match, now))
}
// Games left half-played, above everything else: coming back to
// the app is mostly coming back to one.
if (hasSaved) {
Button(onClick = onResume, modifier = Modifier.fillMaxWidth()) {
Text("Resume game")
}
}
@@ -30,6 +30,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.AiDifficulty
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.SavedMatch
@@ -88,7 +89,7 @@ fun GameSetupScreen(
playerName: String,
onBack: () -> Unit,
onPlayOffline: (TableConfig, Int) -> Unit,
onHostOnline: (String) -> Unit,
onHostOnline: (String, AiDifficulty) -> Unit,
/** A game of this kind left half-played, if there is one. */
saved: SavedMatch? = null,
onResume: (SavedMatch) -> Unit = {},
@@ -124,6 +125,7 @@ fun GameSetupScreen(
// Pirates and Bulgars is not symmetrical, so which side you take is the
// first thing about the game rather than a detail of it.
var playPirates by remember { mutableStateOf(false) }
var aiDifficulty by remember { mutableStateOf(AiDifficulty.MEDIUM) }
val optionsJson = when (descriptor.id) {
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
@@ -243,6 +245,22 @@ fun GameSetupScreen(
style = MaterialTheme.typography.bodySmall,
)
if (!solo) {
ChipRow(
label = "Computer difficulty",
values = AiDifficulty.entries.toList(),
selected = aiDifficulty,
display = { it.label },
onSelect = { aiDifficulty = it },
)
if (aiDifficulty == AiDifficulty.MIXED) {
Text(
"Each computer player gets its own level.",
style = MaterialTheme.typography.bodySmall,
)
}
}
// Two games let the local player pick a seat rather than always
// taking the first one: chess by colour, and Pirates and Bulgars by
// which side of a very lopsided fight they fancy.
@@ -259,13 +277,13 @@ fun GameSetupScreen(
}
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
onPlayOffline(
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat, aiDifficulty),
localSeat,
)
}
if (!solo) {
PrimaryAction(text = "Host a game for others to join") {
onHostOnline(optionsJson)
onHostOnline(optionsJson, aiDifficulty)
}
}
}
@@ -279,6 +297,7 @@ private fun offlineConfig(
seatCount: Int,
playerName: String,
localSeat: Int = 0,
aiDifficulty: AiDifficulty = AiDifficulty.MEDIUM,
): TableConfig = TableConfig(
gameId = gameId,
seats = (0 until seatCount).map { seat ->
@@ -292,6 +311,7 @@ private fun offlineConfig(
optionsJson = optionsJson,
// Seeded from the clock so consecutive games are not identical.
seed = System.currentTimeMillis(),
aiDifficulty = aiDifficulty,
)
/**
@@ -365,12 +385,15 @@ internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSe
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)
// Seafarers adds two points for each new island, so the target is higher.
CatanRuleSet.SEAFARERS -> listOf(8, 10, 12, 14)
}
/** 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
}
/**
@@ -387,6 +410,7 @@ internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): Cat
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) {
@@ -395,6 +419,9 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.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 ->
"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."
}
@Composable
@@ -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,
@@ -594,7 +595,12 @@ fun PlayScreen(
)
}
StartMatch(env, controller, route)
YahtzeeScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
YahtzeeScreen(
controller = controller,
localSeat = route.localSeat,
stats = env.yahtzeeStats,
onExit = onExit,
)
}
GameCatalog.KLONDIKE -> {
@@ -679,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,
@@ -700,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,
@@ -719,7 +725,7 @@ fun PlayScreen(
val controller = remember(route) {
MatchController<CatanState, CatanMove>(
rules = CatanRules,
ai = CatanAi(),
ai = CatanAi(AiProfiles(route.config)),
config = route.config,
scope = scope,
role = role,
@@ -0,0 +1,87 @@
package org.prolibertate.games.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.SavedMatch
/**
* Every game that can be carried on from, most recently played first, each with
* a way to pick it up and a way to throw it away.
*/
@Composable
fun ResumeScreen(
saved: List<SavedMatch>,
onResume: (SavedMatch) -> Unit,
onDelete: (SavedMatch) -> Unit,
onBack: () -> Unit,
) {
var deleting by remember { mutableStateOf<SavedMatch?>(null) }
ScreenScaffold(title = "Resume game", onBack = onBack) { modifier ->
if (saved.isEmpty()) {
Text("No games to resume.", modifier = modifier)
} else {
val now = System.currentTimeMillis()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(saved, key = { it.gameId }) { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(title, fontWeight = FontWeight.Bold)
Text(
buildString {
append("Saved ${agoText(now, match.savedAtMillis)}")
if (match.summary.isNotBlank()) append(" · ${match.summary}")
},
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onResume(match) }) { Text("Resume") }
TextButton(onClick = { deleting = match }) { Text("Delete") }
}
}
}
}
}
}
}
deleting?.let { match ->
val title = GameCatalog.byId(match.gameId)?.title ?: match.gameId
AlertDialog(
onDismissRequest = { deleting = null },
title = { Text("Delete this game?") },
text = { Text("Your saved game of $title will be removed and cannot be resumed.") },
confirmButton = {
TextButton(onClick = { deleting = null; onDelete(match) }) { Text("Delete") }
},
dismissButton = { TextButton(onClick = { deleting = null }) { Text("Keep it") } },
)
}
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.Release
import org.prolibertate.games.update.UpdateChannel
@@ -83,6 +84,26 @@ fun SettingsScreen(
Divider()
Column {
Text("Text size", fontWeight = FontWeight.Bold)
Text(
text = "${(settings.textScale * 100).roundToInt()}% — " +
"scales text throughout the app, on top of your phone's setting",
style = MaterialTheme.typography.bodySmall,
)
Slider(
value = settings.textScale,
onValueChange = { scale ->
scope.launch { env.settingsRepository.setTextScale(scale) }
},
valueRange = Settings.MIN_TEXT_SCALE..Settings.MAX_TEXT_SCALE,
// 10% increments across 80%-160%.
steps = 7,
)
}
Divider()
Column {
Text("Your name", fontWeight = FontWeight.Bold)
Text(
@@ -60,6 +60,22 @@ fun CrazyEightsScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
// Playing an eight is two decisions: the card, then the suit it turns the
@@ -68,13 +84,15 @@ fun CrazyEightsScreen(
val current = state
if (current == null) {
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Crazy 8s", onBack = onExit) { modifier ->
ScreenScaffold(title = "Crazy 8s", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
@@ -65,11 +65,28 @@ fun CribbageScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
@@ -79,7 +96,8 @@ fun CribbageScreen(
// together. Forgotten whenever a new hand is dealt.
var chosen by remember(current.handNumber) { mutableStateOf(emptySet<Card>()) }
ScreenScaffold(title = "Cribbage", onBack = onExit) { modifier ->
ScreenScaffold(title = "Cribbage", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.16f).coerceIn(44.dp, 92.dp)
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
@@ -72,18 +73,36 @@ fun EuchreScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Euchre", onBack = onExit) { modifier ->
ScreenScaffold(title = "Euchre", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.17f else 0.12f)
@@ -69,18 +69,36 @@ fun GolfScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
var mode by remember { mutableStateOf(PlaceMode.SWAP) }
val current = state
if (current == null) {
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Golf", onBack = onExit) { modifier ->
ScreenScaffold(title = "Golf", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val shortest = minOf(maxWidth, maxHeight)
val ownCard = (shortest * 0.16f).coerceIn(40.dp, 84.dp)
@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -74,18 +75,36 @@ fun KaiserScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Kaiser", onBack = onExit) { modifier ->
ScreenScaffold(title = "Kaiser", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val compact = maxWidth < 600.dp
val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.15f else 0.11f)
@@ -20,11 +20,15 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -66,16 +70,34 @@ fun PresidentScreen(
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val current = state
if (current == null) {
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
dialogs()
}
return
}
ScreenScaffold(title = "President", onBack = onExit) { modifier ->
ScreenScaffold(title = "President", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.15f).coerceIn(44.dp, 88.dp)
@@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -81,12 +82,29 @@ fun SequenceScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
var selected by remember { mutableStateOf<Card?>(null) }
val current = state
if (current == null) {
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
@@ -97,7 +115,8 @@ fun SequenceScreen(
val removals = legal.filterIsInstance<RemoveChip>().filter { it.card == selected }
val highlighted = (placements.map { it.cell } + removals.map { it.cell }).toSet()
ScreenScaffold(title = "Sequence", onBack = onExit) { modifier ->
ScreenScaffold(title = "Sequence", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight
val boardSide = if (wide) {
@@ -75,18 +75,36 @@ fun WizardScreen(
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
val abandoned by controller.abandoned.collectAsState()
var confirmingEnd by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
}
val dialogs: @Composable () -> Unit = {
abandoned?.let { PatienceLeftDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) {
PatienceEndDialog(
what = "The game ends here. Anyone else at the table is told you have left.",
onEnd = onExit,
onKeepPlaying = { confirmingEnd = false },
)
}
}
val legal by controller.legalMoves.collectAsState()
val notice by controller.notice.collectAsState()
val current = state
if (current == null) {
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
}
return
}
ScreenScaffold(title = "Wizard", onBack = onExit) { modifier ->
ScreenScaffold(title = "Wizard", onBack = onExit, actions = endGameAction) { modifier ->
dialogs()
BoxWithConstraints(modifier = modifier) {
val cardWidth = (minOf(maxWidth, maxHeight) * 0.14f).coerceIn(40.dp, 88.dp)
@@ -6,7 +6,11 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,12 +34,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.yahtzee.DICE_COUNT
import org.prolibertate.games.game.yahtzee.RollDice
import org.prolibertate.games.game.yahtzee.ScoreIn
import org.prolibertate.games.game.yahtzee.YahtzeeCategory
import org.prolibertate.games.game.yahtzee.YahtzeeComparison
import org.prolibertate.games.game.yahtzee.recordOf
import org.prolibertate.games.settings.YahtzeeStatsRepository
import kotlin.math.roundToInt
import org.prolibertate.games.game.yahtzee.YahtzeeMove
import org.prolibertate.games.game.yahtzee.YahtzeeSection
import org.prolibertate.games.game.yahtzee.YahtzeeState
@@ -61,6 +70,7 @@ private const val TITLE = "Yahtzee"
fun YahtzeeScreen(
controller: MatchController<YahtzeeState, YahtzeeMove>,
localSeat: Int,
stats: YahtzeeStatsRepository,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
@@ -69,6 +79,11 @@ fun YahtzeeScreen(
val kept = remember { mutableStateListOf<Int>() }
var confirmingEnd by remember { mutableStateOf(false) }
var confirmingZero by remember { mutableStateOf<YahtzeeCategory?>(null) }
var comparison by remember { mutableStateOf<YahtzeeComparison?>(null) }
// Only a game seen to finish is recorded: reopening a saved game that was
// already over must not count it a second time.
var sawInProgress by remember { mutableStateOf(false) }
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
@@ -89,6 +104,15 @@ fun YahtzeeScreen(
// refused, and the Throw button would look broken again.
LaunchedEffect(current.turn, current.round) { kept.clear() }
LaunchedEffect(current.isOver) {
if (!current.isOver) {
sawInProgress = true
} else if (sawInProgress) {
sawInProgress = false
comparison = stats.finish(recordOf(current, localSeat))
}
}
val yours = current.turn == localSeat && !current.isOver
val canRoll = legal.any { it is RollDice }
val writable = legal.filterIsInstance<ScoreIn>().map { it.category }.toSet()
@@ -138,8 +162,14 @@ fun YahtzeeScreen(
localSeat = localSeat,
writable = if (yours) writable else emptySet(),
onWrite = {
controller.submit(ScoreIn(it))
kept.clear()
// A zero is a box spent for nothing, and one mis-tap on a
// dense card is all it takes, so ask first.
if (scoreOf(it, current.dice) == 0) {
confirmingZero = it
} else {
controller.submit(ScoreIn(it))
kept.clear()
}
},
)
@@ -150,9 +180,79 @@ fun YahtzeeScreen(
abandoned?.let { LeftCardDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) EndCardDialog(onExit) { confirmingEnd = false }
comparison?.let { StatsDialog(it, onExit) { comparison = null } }
confirmingZero?.let { category ->
ZeroScoreDialog(
category = category,
onConfirm = {
confirmingZero = null
controller.submit(ScoreIn(category))
kept.clear()
},
onCancel = { confirmingZero = null },
)
}
}
}
@Composable
private fun StatsDialog(c: YahtzeeComparison, onLeave: () -> Unit, onClose: () -> Unit) {
fun pct(v: Double?) = v?.let { "${(it * 100).roundToInt()}%" } ?: "—"
val headline = when {
c.previousGames == 0 -> "Your first recorded game."
c.rank == 1 -> "A new personal best!"
else -> "${ordinal(c.rank)} best of ${c.gamesRanked} games."
}
AlertDialog(
onDismissRequest = onClose,
title = { Text("Game over: ${c.current.total} points") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(headline, fontWeight = FontWeight.Bold)
StatLine("", "This game", "Before")
StatLine("Score", "${c.current.total}", c.averageBefore?.let { "avg ${it.roundToInt()}, best ${c.bestBefore}" } ?: "—")
StatLine("Upper bonus", if (c.current.gotBonus) "Yes" else "No", pct(c.bonusRateBefore))
StatLine("Yahtzees", "${c.current.yahtzees}", "${c.yahtzeesBefore} total")
StatLine("Zeroed boxes", "${c.current.zeros}", "")
if (c.current.players > 1) {
StatLine("Result", if (c.current.won) "Won" else "Lost", "won ${pct(c.winRateBefore)}")
}
Text(
"Games before this one: ${c.previousGames}",
style = MaterialTheme.typography.bodySmall,
)
}
},
confirmButton = { TextButton(onClick = onLeave) { Text("Leave the game") } },
dismissButton = { TextButton(onClick = onClose) { Text("Look at the card") } },
)
}
@Composable
private fun StatLine(label: String, now: String, before: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(label, modifier = Modifier.weight(1.2f), fontWeight = FontWeight.Bold)
Text(now, modifier = Modifier.weight(1f))
Text(before, modifier = Modifier.weight(1.6f))
}
}
private fun ordinal(n: Int): String {
val suffix = if (n % 100 in 11..13) "th" else when (n % 10) { 1 -> "st"; 2 -> "nd"; 3 -> "rd"; else -> "th" }
return "$n$suffix"
}
@Composable
private fun ZeroScoreDialog(category: YahtzeeCategory, onConfirm: () -> Unit, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Score zero?") },
text = { Text("These dice are worth 0 in ${category.label}. Write the zero there?") },
confirmButton = { TextButton(onClick = onConfirm) { Text("Score 0") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Choose another") } },
)
}
@Composable
private fun DiceRow(
dice: List<Int>,
@@ -190,12 +290,29 @@ private fun Die(face: Int?, held: Boolean, enabled: Boolean, onClick: () -> Unit
.clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
text = face?.toString() ?: "·",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = WallaceRed,
)
if (face != null) {
Canvas(modifier = Modifier.size(48.dp)) {
val radius = size.minDimension * 0.08f
pipPositions(face).forEach { (x, y) ->
drawCircle(WallaceRed, radius, Offset(size.width * x, size.height * y))
}
}
}
}
}
/** Pip centres as fractions of the die face, in the usual casino layout. */
private fun pipPositions(face: Int): List<Pair<Float, Float>> {
val lo = 0.28f
val mid = 0.5f
val hi = 0.72f
return when (face) {
1 -> listOf(mid to mid)
2 -> listOf(lo to lo, hi to hi)
3 -> listOf(lo to lo, mid to mid, hi to hi)
4 -> listOf(lo to lo, hi to lo, lo to hi, hi to hi)
5 -> listOf(lo to lo, hi to lo, mid to mid, lo to hi, hi to hi)
else -> listOf(lo to lo, hi to lo, lo to mid, hi to mid, lo to hi, hi to hi)
}
}
@@ -206,28 +323,61 @@ private fun ScoreCard(
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f), style = MaterialTheme.typography.labelSmall)
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
YahtzeeSection.entries.forEach { section ->
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
if (section == YahtzeeSection.UPPER) {
// Upper boxes and bonus on the left, lower boxes and total on the right, so
// each half is wide enough for text that can actually be read. Each half
// has a floor on its width; if two of them do not fit (many players, large
// text) the pair scrolls sideways instead of squeezing.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val needed = (CARD_LABEL_WIDTH + CARD_SEAT_WIDTH * state.cards.size).dp
val half = maxOf((maxWidth - CARD_GAP.dp) / 2, needed)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(CARD_GAP.dp),
) {
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.UPPER, state, localSeat, writable, onWrite)
TotalRow("Bonus at 63", state.cards.map { it.upperBonus })
}
Column(modifier = Modifier.width(half)) {
CardHeader(state, localSeat)
CardSection(YahtzeeSection.LOWER, state, localSeat, writable, onWrite)
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
Divider()
TotalRow("Total", state.cards.indices.map { state.totalFor(it) }, bold = true)
}
}
private const val CARD_LABEL_WIDTH = 120
private const val CARD_SEAT_WIDTH = 56
private const val CARD_GAP = 12
@Composable
private fun CardHeader(state: YahtzeeState, localSeat: Int) {
Row(modifier = Modifier.fillMaxWidth()) {
Text("", modifier = Modifier.weight(2f))
state.cards.indices.forEach { seat ->
Text(
text = if (seat == localSeat) "You" else "S$seat",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun CardSection(
section: YahtzeeSection,
state: YahtzeeState,
localSeat: Int,
writable: Set<YahtzeeCategory>,
onWrite: (YahtzeeCategory) -> Unit,
) {
YahtzeeCategory.entries.filter { it.section == section }.forEach { category ->
CardRow(state, localSeat, category, writable, onWrite)
}
}
@@ -244,12 +394,12 @@ private fun CardRow(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = open) { onWrite(category) }
.padding(vertical = 2.dp),
.padding(vertical = 5.dp),
) {
Text(
text = category.label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (open) FontWeight.Bold else FontWeight.Normal,
)
state.cards.forEachIndexed { seat, card ->
@@ -263,7 +413,7 @@ private fun CardRow(
else -> "—"
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = if (written == null && open) WallaceRed else MaterialTheme.colorScheme.onSurface,
)
}
@@ -272,18 +422,18 @@ private fun CardRow(
@Composable
private fun TotalRow(label: String, values: List<Int>, bold: Boolean = false) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
Text(
text = label,
modifier = Modifier.weight(2f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
values.forEach {
Text(
text = "$it",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
)
}
@@ -33,6 +33,7 @@ import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.NO_PLAYER
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
/**
* The island, drawn and tappable.
@@ -69,9 +70,10 @@ fun CatanBoardView(
) {
val measurer = rememberTextMeasurer()
var size by remember { mutableStateOf(IntSize.Zero) }
val layout = remember(size) {
val topo = state.topo
val layout = remember(size, topo) {
if (size.width > 0 && size.height > 0) {
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat())
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat(), topo)
} else {
null
}
@@ -85,7 +87,7 @@ fun CatanBoardView(
Canvas(
modifier = modifier
.aspectRatio(CatanGeometry.ASPECT)
.aspectRatio(CatanGeometry.aspect(topo))
.onSizeChanged { size = it }
.semantics { contentDescription = "Katan island board" }
.pointerInput(layout, targets, style) {
@@ -97,8 +99,8 @@ fun CatanBoardView(
targets.tappableVertices.isNotEmpty() ->
board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
targets.edges.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
targets.edges.isNotEmpty() || targets.ships.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges + targets.ships)?.let { edgeTap(it) }
targets.hexes.isNotEmpty() ->
// A hex is big: accept a tap anywhere near its middle.
@@ -113,9 +115,12 @@ fun CatanBoardView(
drawHexes(state, board, style, measurer)
drawRolled(rolledHexes, board, style)
drawRobber(state, board, style)
drawPirate(state, board, style)
drawMerchant(state, board, style, measurer)
drawRoads(state, board, style)
drawShips(state, board, style)
drawEdgeTargets(targets, board, style)
drawShipTargets(targets, board, style)
drawBuildings(state, board, style)
drawWalls(state, board, style)
drawMetropolises(state, board, style)
@@ -137,10 +142,15 @@ private fun DrawScope.drawHexes(
style: CatanStyle,
measurer: TextMeasurer,
) {
for (hex in 0 until CatanBoard.HEX_COUNT) {
for (hex in 0 until state.topo.HEX_COUNT) {
val centre = board.hexCenter(hex)
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
val path = polygon(corners)
if (state.terrains[hex] == Terrain.SEA) {
// Open water: only a faint outline, so ships have a grid to sail on.
drawPath(path, color = style.hexEdge.copy(alpha = 0.12f), style = Stroke(width = board.radius * 0.02f))
continue
}
drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
@@ -365,7 +375,7 @@ private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fil
// ---------------------------------------------------------------------------
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (edge in 0 until CatanBoard.EDGE_COUNT) {
for (edge in 0 until state.topo.EDGE_COUNT) {
val owner = state.roads[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
@@ -391,7 +401,7 @@ private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: Ca
}
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
for (vertex in 0 until state.topo.VERTEX_COUNT) {
val building = state.buildings[vertex] ?: continue
val at = board.vertex(vertex)
val fill = style.seats[building.owner % style.seats.size]
@@ -666,3 +676,68 @@ private fun DrawScope.drawStaged(vertex: Int?, edge: Int?, seat: Int?, board: Bo
drawCircle(style.targetSelected, radius * 1.9f, at, style = Stroke(width = board.radius * 0.1f))
}
}
// ---------------------------------------------------------------------------
// Seafarers: ships, the pirate
// ---------------------------------------------------------------------------
/** A ship on each sea edge that has one: a hull along the edge with a sail amidships, in the owner's colour. */
private fun DrawScope.drawShips(state: CatanState, board: BoardLayout, style: CatanStyle) {
val sea = state.sea ?: return
for (edge in sea.ships.indices) {
val owner = sea.ships[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.2f)
val to = lerp(a, b, 0.8f)
val width = board.radius * style.roadWidth * 1.15f
val colour = style.seats[owner % style.seats.size]
drawLine(style.pieceOutline, from.offset(), to.offset(), width * 1.5f, StrokeCap.Round)
drawLine(colour, from.offset(), to.offset(), width, StrokeCap.Round)
// The sail: a small triangle standing off the hull, on the screen's upper side.
val mid = lerp(a, b, 0.5f)
val s = board.radius * 0.17f
val sail = Path().apply {
moveTo(mid.x - s * 0.1f, mid.y - s * 1.9f)
lineTo(mid.x + s * 1.1f, mid.y - s * 0.2f)
lineTo(mid.x - s * 0.1f, mid.y - s * 0.2f)
close()
}
drawPath(sail, style.shipSail)
drawPath(sail, style.pieceOutline, style = Stroke(width = board.radius * 0.03f))
}
}
/** A ring round each of the player's ships that can sail, so they read as tappable. */
private fun DrawScope.drawShipTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (edge in targets.ships) {
val mid = board.edgeMidpoint(edge).offset()
drawCircle(style.target, board.radius * 0.38f, mid, style = Stroke(width = board.radius * 0.07f))
}
}
/** The pirate: a black pawn with a flag, drawn beside the middle of its sea hex like the robber. */
private fun DrawScope.drawPirate(state: CatanState, board: BoardLayout, style: CatanStyle) {
val sea = state.sea ?: return
val hex = board.hexCenter(sea.pirate)
val x = hex.x
val y = hex.y + board.radius * 0.1f
val s = board.radius * style.robberSize
val hull = Path().apply {
moveTo(x - s * 1.0f, y)
lineTo(x + s * 1.0f, y)
lineTo(x + s * 0.6f, y + s * 0.7f)
lineTo(x - s * 0.6f, y + s * 0.7f)
close()
}
drawPath(hull, style.pirate)
drawLine(style.pirate, Offset(x, y), Offset(x, y - s * 1.5f), strokeWidth = s * 0.12f)
val flag = Path().apply {
moveTo(x, y - s * 1.5f)
lineTo(x + s * 0.9f, y - s * 1.15f)
lineTo(x, y - s * 0.8f)
close()
}
drawPath(flag, style.pirate)
drawPath(hull, Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f))
}
@@ -138,6 +138,55 @@ fun CatanDiscardDialog(
)
}
// ---------------------------------------------------------------------------
// Gold (Seafarers)
// ---------------------------------------------------------------------------
/**
* A gold field paid out: choose the resource cards it gives. Not dismissable: the game
* is waiting. The engine's list says which resources the bank can still pay.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoldDialog(
legal: List<CatanMove>,
onSubmit: (org.prolibertate.games.game.catan.PickGold) -> Unit,
) {
val count = CatanInteraction.goldCount(legal)
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
val selection = picked.asCards()
val move = CatanInteraction.goldMove(legal, selection)
// A resource is on offer if some legal pick names it.
val offered = legal.filterIsInstance<org.prolibertate.games.game.catan.PickGold>()
.flatMap { it.cards }.toSet()
AlertDialog(
onDismissRequest = {},
title = { Text(CatanText.GOLD_TITLE) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gold! Choose $count resource cards (${selection.size} of $count chosen).")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.filter { it in offered }.forEach { resource ->
ResourceCounter(
resource = resource,
count = picked[resource] ?: 0,
max = if (selection.size < count) count else (picked[resource] ?: 0),
onChange = { n -> picked = picked + (resource to n.coerceIn(0, count)) },
)
}
}
}
},
confirmButton = {
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text(CatanText.GOLD_TAKE) }
},
)
}
// ---------------------------------------------------------------------------
// The robber
// ---------------------------------------------------------------------------
@@ -1,6 +1,7 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.Topology
import kotlin.math.sqrt
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
@@ -24,29 +25,35 @@ class BoardLayout(
val height: Float,
/** Circumradius of one hex, in pixels. */
val radius: Float,
/** The board this layout draws: the standard island unless the game is Seafarers. */
val topo: Topology = CatanBoard.base,
) {
private val centreX = width / 2f
private val centreY = height / 2f
private val unitX = radius * SQRT3 / 2f
private val unitY = radius / 2f
/** The lattice point drawn at the middle of the box: the middle of the frame. */
private val midX = (topo.lattice[0] + topo.lattice[1]) / 2f
private val midY = (topo.lattice[2] + topo.lattice[3]) / 2f
fun point(latticeX: Int, latticeY: Int): Pt =
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
Pt(centreX + (latticeX - midX) * unitX, centreY - (latticeY - midY) * unitY)
fun hexCenter(hex: Int): Pt {
val (lx, ly) = CatanBoard.hexCenter(hex)
val (lx, ly) = topo.hexCenter(hex)
return point(lx, ly)
}
fun vertex(vertex: Int): Pt {
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
val (lx, ly) = topo.VERTEX_POS[vertex]
return point(lx, ly)
}
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) }
fun hexCorners(hex: Int): List<Pt> = topo.HEX_CORNERS[hex].map { vertex(it) }
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
val (a, b) = topo.EDGE_VERTICES[edge]
return vertex(a) to vertex(b)
}
@@ -62,8 +69,15 @@ class BoardLayout(
*/
fun portAnchor(edge: Int): Pt {
val mid = edgeMidpoint(edge)
val dx = mid.x - centreX
val dy = mid.y - centreY
// Straight out from the middle of the island on the standard board; on a map of
// islands, straight out from the land hex the harbour sits on.
val from = if (topo.EDGE_HEXES[edge].size == 1) {
Pt(centreX, centreY)
} else {
topo.EDGE_HEXES[edge].firstOrNull { topo.land[it] }?.let { hexCenter(it) } ?: Pt(centreX, centreY)
}
val dx = mid.x - from.x
val dy = mid.y - from.y
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
}
@@ -97,23 +111,29 @@ class BoardLayout(
object CatanGeometry {
/** Lattice extents of the island: x spans -5..5, y spans -8..8. */
private const val LATTICE_X_UNITS = 10
private const val LATTICE_Y_UNITS = 16
/** Empty space round the island, in hex radii, for the sea and the harbours. */
const val MARGIN = 1.75f
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
/** A Seafarers frame already ends in a ring of sea, so it needs almost none. */
private const val FRAME_MARGIN = 0.3f
private fun margin(topo: Topology) = if (topo.land.all { it }) MARGIN else FRAME_MARGIN
private fun widthInRadii(topo: Topology) =
(topo.lattice[1] - topo.lattice[0]) * SQRT3 / 2f + 2 * margin(topo)
private fun heightInRadii(topo: Topology) =
(topo.lattice[3] - topo.lattice[2]) / 2f + 2 * margin(topo)
/** Width ÷ height of the board including its margin, for sizing its box. */
val ASPECT: Float = boardWidthInRadii / boardHeightInRadii
val ASPECT: Float = widthInRadii(CatanBoard.base) / heightInRadii(CatanBoard.base)
fun aspect(topo: Topology): Float = widthInRadii(topo) / heightInRadii(topo)
/** The largest layout that fits [width] × [height] pixels. */
fun fit(width: Float, height: Float): BoardLayout {
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii)
return BoardLayout(width, height, radius)
fun fit(width: Float, height: Float, topo: Topology = CatanBoard.base): BoardLayout {
val radius = minOf(width / widthInRadii(topo), height / heightInRadii(topo))
return BoardLayout(width, height, radius, topo)
}
}
@@ -172,6 +172,7 @@ object CatanHudGlyphs {
BuildKind.CITY -> "▲"
BuildKind.WALL -> "▦"
BuildKind.KNIGHT -> "♞"
BuildKind.SHIP -> "⛵"
}
fun label(kind: BuildKind): String = when (kind) {
@@ -180,6 +181,7 @@ object CatanHudGlyphs {
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
}
}
@@ -311,6 +313,10 @@ fun CatanHud(
) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
HudPlayerStrip(state, seats, pointsOf, style)
// Cities & Knights: the ship and the improvement levels are as much part
// of the game as the scores, so they sit with them rather than in a panel
// this layout does not have room for.
if (state.isCk) CatanBarbarians(state, Modifier.padding(horizontal = 8.dp), style)
Box(Modifier.weight(1f).fillMaxWidth()) {
// The island fills the whole area; controls float over its sea margin.
ZoomPanBox(Modifier.fillMaxSize().padding(start = 64.dp, end = 64.dp, top = 30.dp, bottom = 44.dp)) {
@@ -451,6 +457,16 @@ private fun HudPlayerStrip(
Modifier.size(14.dp).clip(CircleShape).background(style.seats[seat]).border(1.dp, style.pieceOutline, CircleShape),
)
Text(seats.label(seat), fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, modifier = Modifier.weight(1f))
// Trade, politics, science: the three improvement levels.
if (state.isCk) {
Text(
text = org.prolibertate.games.game.catan.Track.entries.joinToString(" ") {
"${it.name.first()}${org.prolibertate.games.game.catan.CatanCk.improvement(state, seat, it)}"
},
fontSize = 12.sp,
maxLines = 1,
)
}
Text("${pointsOf(seat)} VP · ${state.resourceCount(seat)}🂠", fontSize = 12.sp, maxLines = 1)
}
}
@@ -5,6 +5,10 @@ import org.prolibertate.games.game.catan.AcceptTrade
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.MovePirate
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.Topology
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyDevCard
@@ -35,6 +39,7 @@ import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PickGold
import org.prolibertate.games.game.catan.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis
@@ -59,10 +64,16 @@ import org.prolibertate.games.game.catan.Track
* only in Cities & Knights; the base game's build menu lists the first three.
*/
enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
/** Seafarers: a ship, laid on a sea edge. */
SHIP;
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)
}
}
@@ -76,11 +87,14 @@ data class BoardTargets(
val edges: Set<Int> = emptySet(),
val hexes: Set<Int> = emptySet(),
val knights: Set<Int> = emptySet(),
/** Seafarers: the player's ships that can sail this turn, tapped to pick one up. */
val ships: Set<Int> = emptySet(),
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
val idleKnights: Set<Int> = emptySet(),
) {
val isEmpty: Boolean
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() && idleKnights.isEmpty()
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() &&
idleKnights.isEmpty() && ships.isEmpty()
/** Every crossing a tap can land on. */
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
@@ -109,8 +123,11 @@ sealed interface TapOutcome {
/** Setup: remember this settlement; the road that goes with it is tapped next. */
data class PickSetupVertex(val vertex: Int) : TapOutcome
/** The robber's hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
/** The robber's (or the pirate's) hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>, val pirate: Boolean = false) : TapOutcome
/** Seafarers: one of the player's ships was tapped; its destination is tapped next. */
data class PickShip(val edge: Int) : TapOutcome
/** One of the player's own knights was tapped: open its menu. */
data class PickKnight(val vertex: Int) : TapOutcome
@@ -164,13 +181,14 @@ object CatanInteraction {
fun stageable(outcome: TapOutcome, building: BuildKind?): CatanMove? {
if (building == null || outcome !is TapOutcome.Submit) return null
return outcome.move.takeIf {
it is BuildRoad || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight
it is BuildRoad || it is BuildShip || it is BuildSettlement || it is BuildCity || it is BuildWall || it is HireKnight
}
}
/** Where a staged move sits on the board, for drawing its ghost. */
fun spotOf(move: CatanMove?): Spot = when (move) {
is BuildRoad -> Spot(edge = move.edge)
is BuildShip -> Spot(edge = move.edge)
is BuildSettlement -> Spot(vertex = move.vertex)
is BuildCity -> Spot(vertex = move.vertex)
is BuildWall -> Spot(vertex = move.vertex)
@@ -189,6 +207,8 @@ object CatanInteraction {
armed: ArmedCard? = null,
/** The local seat, so its sleeping knights can be tapped for their menu too. */
localSeat: Int? = null,
/** Seafarers: the ship that has been picked up and is waiting for a destination. */
movingShip: Int? = null,
): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
@@ -200,8 +220,11 @@ object CatanInteraction {
}
val robber = legal.filterIsInstance<MoveRobber>()
if (robber.isNotEmpty()) {
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
val pirate = legal.filterIsInstance<MovePirate>()
if (robber.isNotEmpty() || pirate.isNotEmpty()) {
return BoardTargets(
hexes = (robber.map { it.hex } + pirate.map { it.hex }).mapNotNull { state.topo.hexIndex[it] }.toSet(),
)
}
// Cities & Knights: answers to a queued chore are taps on the board.
@@ -209,7 +232,14 @@ object CatanInteraction {
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
val ships = legal.filterIsInstance<BuildShip>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads + ships)
if (movingShip != null) {
return BoardTargets(
edges = legal.filterIsInstance<MoveShip>().filter { it.from == movingShip }.map { it.to }.toSet(),
)
}
if (armed != null) return armedTargets(armed)
@@ -221,6 +251,7 @@ object CatanInteraction {
return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SHIP -> BoardTargets(edges = ships)
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
@@ -240,7 +271,11 @@ object CatanInteraction {
} else {
emptySet()
}
BoardTargets(knights = ready, idleKnights = mine - ready)
BoardTargets(
knights = ready,
idleKnights = mine - ready,
ships = legal.filterIsInstance<MoveShip>().map { it.from }.toSet(),
)
}
}
}
@@ -350,6 +385,15 @@ object CatanInteraction {
// --- Cities & Knights: goods ----------------------------------------------
/** The gold pick that names exactly [selection], if the engine allows it. */
fun goldMove(legal: List<CatanMove>, selection: List<Resource>): PickGold? {
val want = selection.groupingBy { it }.eachCount()
return legal.filterIsInstance<PickGold>().firstOrNull { it.cards.groupingBy { c -> c }.eachCount() == want }
}
/** How many cards the gold pick on offer must name. */
fun goldCount(legal: List<CatanMove>): Int = legal.filterIsInstance<PickGold>().firstOrNull()?.cards?.size ?: 0
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
legal.filterIsInstance<GoodsBankTrade>()
@@ -432,6 +476,8 @@ object CatanInteraction {
building: BuildKind?,
setupVertex: Int?,
armed: ArmedCard? = null,
/** Seafarers: the ship picked up and waiting for a destination. */
movingShip: Int? = null,
): TapOutcome {
if (legal.any { it is SetupPlace }) {
if (setupVertex == null) return TapOutcome.Ignored
@@ -442,21 +488,51 @@ object CatanInteraction {
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
// Free roads need no build mode; a paid road does.
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
if (movingShip != null) {
val move = MoveShip(movingShip, edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
// Free roads (and free ships) need no build mode; a paid one does.
val free = legal.any { it is EndFreeRoads }
if (building == BuildKind.SHIP || (free && BuildShip(edge) in legal)) {
val move = BuildShip(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
if (building == null && !free) {
// Nothing armed: tapping one of your own ships that can sail picks it up.
return if (legal.any { it is MoveShip && it.from == edge }) TapOutcome.PickShip(edge) else TapOutcome.Ignored
}
val canBuild = building == BuildKind.ROAD || free
if (!canBuild) return TapOutcome.Ignored
val move = BuildRoad(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard? = null): TapOutcome {
fun onHexTap(
hex: Int,
legal: List<CatanMove>,
armed: ArmedCard? = null,
/** The board the hex index belongs to (the standard island unless the game is Seafarers). */
topo: Topology = CatanBoard.base,
): TapOutcome {
if (armed != null) return onArmedHexTap(hex, legal, armed)
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
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 }
return when {
moves.isEmpty() -> TapOutcome.Ignored
moves.size == 1 -> TapOutcome.Submit(moves.first())
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
moves.isNotEmpty() -> if (moves.size == 1) {
TapOutcome.Submit(moves.first())
} else {
TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
}
pirates.isNotEmpty() -> if (pirates.size == 1) {
TapOutcome.Submit(pirates.first())
} else {
TapOutcome.PickRobberVictim(coord, pirates.mapNotNull { it.victim }, pirate = true)
}
else -> TapOutcome.Ignored
}
}
@@ -494,7 +570,7 @@ object CatanInteraction {
}
val total = state.lastDice.sum()
if (total == 7) return emptySet()
return (0 until CatanBoard.HEX_COUNT)
return (0 until state.topo.HEX_COUNT)
.filter { state.numbers[it] == total && it != state.robber }
.toSet()
}
@@ -507,6 +583,7 @@ object CatanInteraction {
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
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 BuildWall }) add(BuildKind.WALL)
@@ -597,6 +597,7 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
BuildKind.SHIP -> CatanText.SHIP
}
private fun buildCost(kind: BuildKind): String = when (kind) {
@@ -605,6 +606,7 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.CITY -> CatanText.CITY_COST
BuildKind.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
}
@Composable
@@ -57,6 +57,7 @@ import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.RollDice
@@ -112,6 +113,8 @@ fun CatanScreen(
var knightMenu by remember { mutableStateOf<Int?>(null) }
var movingKnight by remember { mutableStateOf<Int?>(null) }
var armed by remember { mutableStateOf<ArmedCard?>(null) }
// Seafarers: the ship that has been picked up and is waiting for somewhere to sail.
var movingShip by remember { mutableStateOf<Int?>(null) }
var showImprove by remember { mutableStateOf(false) }
var showProgress by remember { mutableStateOf(false) }
// The landscape HUD: a placement waiting for its ✓, the piece being dragged from the
@@ -141,6 +144,7 @@ fun CatanScreen(
setupVertex = null
}
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
if (movingShip != null && legal.none { it is MoveShip && it.from == movingShip }) movingShip = null
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
if (legal.isEmpty()) {
showCards = false
@@ -166,6 +170,7 @@ fun CatanScreen(
showProgress = false
knightMenu = null
movingKnight = null
movingShip = null
armed = null
staged = null
dragKind = null
@@ -186,13 +191,17 @@ fun CatanScreen(
return
}
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat)
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat, movingShip)
val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
val devHand = CatanInteraction.devHand(current, localSeat, legal)
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions(
buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE,
buildKinds = when {
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP }
current.isSea -> BuildKind.SEAFARERS
else -> BuildKind.BASE
},
ck = current.isCk,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable },
@@ -216,6 +225,7 @@ fun CatanScreen(
building = building,
movingKnight = movingKnight != null,
armed = armed?.kind,
movingShip = movingShip != null,
)
val handle: (TapOutcome) -> Unit = { outcome ->
@@ -228,6 +238,7 @@ fun CatanScreen(
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
is TapOutcome.PickShip -> movingShip = outcome.edge
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
}
}
@@ -262,8 +273,8 @@ fun CatanScreen(
),
)
},
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed, movingShip)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed, current.topo)) },
modifier = boardModifier,
style = style,
rolledHexes = rolledHexes,
@@ -339,7 +350,7 @@ fun CatanScreen(
spot.vertex, legal, building, setupVertex,
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
)
spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed)
spot?.edge != null -> CatanInteraction.onEdgeTap(spot.edge, legal, building, setupVertex, armed, movingShip)
else -> TapOutcome.Ignored
}
handle(outcome)
@@ -560,6 +571,9 @@ private fun CatanDialogHost(
onSubmit = onSubmit,
)
state.phase == CatanPhase.GOLD && CatanRules.currentSeat(state) == localSeat && legal.isNotEmpty() ->
CatanGoldDialog(legal, onSubmit)
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
ChoreDialog(state, localSeat, legal, onSubmit)
@@ -595,7 +609,15 @@ private fun CatanDialogHost(
CatanVictimDialog(
victims = victimChoice.victims,
seats = seats,
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) },
onPick = { victim ->
onSubmit(
if (victimChoice.pirate) {
org.prolibertate.games.game.catan.MovePirate(victimChoice.hex, victim)
} else {
MoveRobber(victimChoice.hex, victim)
},
)
},
onCancel = onCancelVictim,
)
@@ -81,6 +81,9 @@ data class CatanStyle(
val citySize: Float = 0.42f,
val tokenRadius: Float = 0.36f,
val robberSize: Float = 0.34f,
/** Seafarers: the pirate's flag, and the hull and sail of a ship. */
val pirate: Color = Color(0xFF000000),
val shipSail: Color = Color(0xFFFFFFFF),
val portRadius: Float = 0.46f,
val targetRadius: Float = 0.20f,
/** How close a tap must land to a vertex / edge / hex centre to count. */
@@ -118,6 +121,9 @@ private val LightStyle = CatanStyle(
Terrain.HILLS to Color(0xFFB4532F),
Terrain.MOUNTAINS to Color(0xFF7C8794),
Terrain.DESERT to Color(0xFFE6D3A0),
// 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),
),
hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9),
@@ -72,6 +72,8 @@ object CatanText {
Terrain.HILLS -> "Hills"
Terrain.MOUNTAINS -> "Mountains"
Terrain.DESERT -> "Desert"
Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field"
}
fun devCard(type: DevCardType): String = when (type) {
@@ -108,6 +110,11 @@ object CatanText {
// --- Buttons and headings --------------------------------------------------
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 GOLD_TITLE = "Gold field"
const val GOLD_TAKE = "Take"
const val ROLL = "Roll dice"
const val END_TURN = "End turn"
const val ROAD = "Road"
@@ -183,6 +190,8 @@ object CatanText {
movingKnight: Boolean = false,
/** Cities & Knights: a progress card is waiting for a tap on the board. */
armed: ProgressType? = null,
/** 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)
val me = seats.me
@@ -220,10 +229,23 @@ object CatanText {
}
CatanPhase.ROBBER ->
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…"
if (mine) {
if (state.isSea) SEA_ROBBER else "Move the robber. Tap a hex."
} else {
"$turn is moving the robber…"
}
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.PLAYING -> when {
!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.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."
@@ -386,6 +408,8 @@ object CatanText {
val ck = state.ck!!
return when (state.phase) {
CatanPhase.GOLD -> ""
CatanPhase.GAME_OVER -> {
val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
@@ -106,42 +106,33 @@ class CatalogTest {
}
@Test
fun `every reference game in the catalogue can be played`() {
// Every game the app ships is playable. The exceptions are the games
// whose engine and screen exist but have not been verified on a real
// device yet: they are marked as previews so a production build keeps
// them out while a dev one can open them. `available = false` is what
// keeps an unverified game out of a production build while leaving it
// visible on a dev one.
assertEquals(
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU),
GameCatalog.all.filterNot { it.available }.map { it.id },
)
// It is also the only difference the two channels see in a menu.
// Katan sits under Board and Meowdoku under Solitaire, so the two
// channels part ways in different sections.
val hiddenFromBoard = GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true) -
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false)
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromBoard)
val hiddenFromSolitaire = GameCatalog.inMenu(GameMenu.SOLITAIRE, includeComingSoon = true) -
GameCatalog.inMenu(GameMenu.SOLITAIRE, includeComingSoon = false)
assertEquals(listOf(GameCatalog.byId(GameCatalog.MEOWDOKU)!!), hiddenFromSolitaire)
fun `every game in the catalogue can be played`() {
// `available` is the release gate. Nothing is held back at the moment,
// so a production menu lists every game; a game parked behind the gate
// belongs in `devPreview`, and must say so rather than being quietly
// unavailable everywhere.
GameCatalog.all.filterNot { it.available }.forEach { game ->
assertTrue("${game.id} is unavailable but not a dev preview", game.devPreview)
}
GameMenu.entries.forEach { menu ->
val held = GameCatalog.inMenu(menu, includeComingSoon = true) -
GameCatalog.inMenu(menu, includeComingSoon = false).toSet()
held.forEach { assertTrue("${it.id} hidden from production without a preview flag", it.devPreview) }
}
}
@Test
fun `a preview game opens on a dev build and nowhere else`() {
// Katan until its two-device play is verified; Meowdoku until its board
// and hearts have been looked at on a real screen.
val preview = GameCatalog.all.filter { it.devPreview }
assertEquals(
setOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU),
preview.map { it.id }.toSet(),
)
preview.forEach { game ->
GameCatalog.all.filter { it.devPreview }.forEach { game ->
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
}
// The two games that were once previews now open everywhere.
listOf(GameCatalog.CATAN, GameCatalog.MEOWDOKU).forEach { id ->
val game = GameCatalog.byId(id)!!
assertTrue("$id should open in production", game.playableIn(devBuild = false))
}
// An ordinary finished game opens everywhere.
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
@@ -186,6 +177,8 @@ class CatalogTest {
GameCatalog.MASTERMIND,
GameCatalog.BACKGAMMON,
GameCatalog.PIRATES,
GameCatalog.CATAN,
GameCatalog.MEOWDOKU,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -0,0 +1,43 @@
package org.prolibertate.games.game.backgammon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.engine.AiDifficulty
import org.prolibertate.games.game.engine.AiProfiles
class BackgammonAiProfileTest {
private fun playout(ai: BackgammonAi, seed: Long, steps: Int): List<BackgammonMove> {
var state = BackgammonRules.initialState(BackgammonOptions(), seed)
val chosen = mutableListOf<BackgammonMove>()
repeat(steps) {
if (BackgammonRules.isFinished(state)) return chosen
val seat = BackgammonRules.currentSeat(state) ?: return chosen
val legal = BackgammonRules.legalMoves(state, seat)
if (legal.isEmpty()) return chosen
val move = ai.chooseMove(state, seat, legal)
assertTrue("illegal move $move", move in legal)
chosen += move
state = BackgammonRules.applyMove(state, seat, move)
}
return chosen
}
@Test
fun everyLevelAndStyleReturnsLegalMoves() {
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.MEDIUM, AiDifficulty.HARD, AiDifficulty.MIXED)) {
for (seed in 0L..5L) {
val moves = playout(BackgammonAi(AiProfiles(difficulty, seed)), seed, 14)
assertTrue(moves.isNotEmpty())
}
}
}
@Test
fun defaultProfilePlaysAsBefore() {
val plain = playout(BackgammonAi(), 3L, 20)
val medium = playout(BackgammonAi(AiProfiles(AiDifficulty.MEDIUM, 77L)), 3L, 20)
assertEquals(plain, medium)
}
}
@@ -0,0 +1,105 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.AiDifficulty
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.AiStyle
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** Computer seats at every level and style play legal, forward-moving games in each Katan rule set. */
class CatanAiProfilesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: CatanOptions, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
/** Plays up to [cap] moves, checking every one; returns the last state. */
private fun play(options: CatanOptions, seed: Long, profiles: AiProfiles, cap: Int): CatanState {
val ai = CatanAi(profiles)
var state = CatanRules.initialState(config(options, seed))
var moves = 0
while (!CatanRules.isFinished(state) && moves++ < cap) {
val seat = CatanRules.currentSeat(state)
?: error("seed $seed: nobody on the clock in ${state.phase}; log tail: ${state.log.takeLast(8)}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("seed $seed: seat $seat has no legal move in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue("seed $seed: chose $move outside the legal list in ${state.phase}", move in legal || CatanRules.isLegalMove(state, seat, move))
assertTrue(
"seed $seed: seat $seat chose the illegal move $move in ${state.phase}",
CatanRules.isLegalMove(state, seat, move),
)
state = CatanRules.applyMove(state, seat, move)
}
return state
}
/** A table seed on which some seat among the first three has [style] at [difficulty]. */
private fun tableSeedFor(difficulty: AiDifficulty, style: AiStyle): Long =
(1L..2_000L).first { s ->
val p = AiProfiles(difficulty, s)
(0 until 3).any { p.of(it).style == style }
}
private fun forEveryProfile(rules: CatanRuleSet, cap: Int) {
val options = CatanOptions(
playerCount = 3,
pointsToWin = when (rules) {
CatanRuleSet.BASE -> 10
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
else -> 12
},
ruleSet = rules,
)
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) {
for (style in AiStyle.entries) {
val tableSeed = tableSeedFor(difficulty, style)
val state = play(options, 40L + style.ordinal, AiProfiles(difficulty, tableSeed), cap)
assertTrue(
"$rules $difficulty $style: the game never got past setup",
state.turnNumber > 3 || CatanRules.isFinished(state),
)
}
}
}
@Test
fun `easy and hard seats of every style play the base game`() = forEveryProfile(CatanRuleSet.BASE, 1_500)
@Test
fun `easy and hard seats of every style play Cities and Knights`() =
forEveryProfile(CatanRuleSet.CITIES_AND_KNIGHTS, 1_500)
@Test
fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(CatanRuleSet.SEAFARERS, 1_500)
@Test
fun `a mixed table of four finishes a base game`() {
val state = play(CatanOptions(playerCount = 4), 77L, AiProfiles(AiDifficulty.MIXED, 5L), 12_000)
assertEquals(CatanPhase.GAME_OVER, state.phase)
}
@Test
fun `the default profile plays exactly as an explicit medium table does`() {
for (rules in listOf(CatanRuleSet.BASE, CatanRuleSet.CITIES_AND_KNIGHTS)) {
val options = CatanOptions(playerCount = 3, pointsToWin = 10, ruleSet = rules)
val plain = play(options, 9L, AiProfiles.DEFAULT, 600)
val medium = play(options, 9L, AiProfiles(AiDifficulty.MEDIUM, 1234L), 600)
assertEquals("$rules", plain, medium)
}
}
}
@@ -0,0 +1,325 @@
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.assertNotEquals
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
class CatanSeafarersTest {
private val json = Json { encodeDefaults = true }
private fun options(players: Int = 3) =
CatanOptions(playerCount = players, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
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(players: Int = 3, seed: Long = 7L): CatanState =
CatanRules.initialState(config(options(players), seed))
/** A mid-game table on seat 0's turn with plenty of cards. */
private fun playing(seed: Long = 7L, adjust: CatanState.() -> CatanState = { this }): CatanState =
start(seed = seed).copy(
phase = CatanPhase.PLAYING,
turn = 0,
turnNumber = 1,
resources = List(3) { Resource.entries.associateWith { 5 } },
).adjust()
private fun CatanState.settle(vertex: Int, seat: Int, type: BuildingType = BuildingType.SETTLEMENT) =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) })
private fun CatanState.withShip(edge: Int, seat: Int) =
copy(sea = sea!!.let { it.copy(ships = it.ships.toMutableList().also { l -> l[edge] = seat }) })
private fun CatanState.withRoad(edge: Int, seat: Int) =
copy(roads = roads.toMutableList().also { it[edge] = seat })
/** A home-island vertex that touches open water, with a sea edge leaving it. */
private fun CatanState.coastVertex(): Int {
val topo = topo
return (0 until topo.VERTEX_COUNT).first { v ->
topo.vertexIsland[v] == sea!!.home &&
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
}
// --- The map -------------------------------------------------------------
@Test
fun `the new shores map is a big home island and three small ones`() {
val topo = SeaBoards.topology(SeaScenario.NEW_SHORES)
assertEquals(4, topo.islandCount)
val sizes = (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted()
assertEquals(listOf(2, 3, 3, 21), sizes)
// Every coast is closed: nothing on land touches the edge of the frame.
for (e in 0 until topo.EDGE_COUNT) {
if (topo.EDGE_HEXES[e].size == 1) assertFalse("land on the frame edge at $e", topo.land[topo.EDGE_HEXES[e][0]])
}
}
@Test
fun `a new game has gold, a desert, numbers on every other land hex, a pirate at sea and nine harbours`() {
repeat(5) { i ->
val s = start(seed = 100L + i)
val topo = s.topo
assertEquals(2, s.terrains.count { it == Terrain.GOLD })
assertEquals(1, s.terrains.count { it == Terrain.DESERT })
for (h in 0 until topo.HEX_COUNT) {
val land = s.terrains[h] != Terrain.SEA
assertEquals("hex $h", topo.land[h], land)
assertEquals("hex $h", land && s.terrains[h] != Terrain.DESERT, s.numbers[h] != 0)
}
assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate])
assertTrue(s.terrains[s.robber] == Terrain.DESERT)
assertEquals(9, s.ports.size)
// The red numbers are never neighbours.
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(topo.hexesAdjacent(a, b))
}
}
@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>()
assertTrue(moves.isNotEmpty())
assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home })
assertTrue(moves.all { s.topo.edgeLand[it.edge] })
}
@Test
fun `the state survives its wire format`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
}
// --- Ships ---------------------------------------------------------------
@Test
fun `a ship launches from a harbour town onto a sea edge and costs a lumber and a wool`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
val ships = CatanRules.legalMoves(s, 0).filterIsInstance<BuildShip>()
assertTrue(ships.isNotEmpty())
assertTrue(ships.all { s.topo.edgeSea[it.edge] })
val after = CatanRules.applyMove(s, 0, ships.first())
assertEquals(0, after.sea!!.ships.indexOfFirst { it == 0 }.let { if (it == ships.first().edge) 0 else 1 })
assertEquals(4, after.resources[0].getValue(Resource.LUMBER))
assertEquals(4, after.resources[0].getValue(Resource.WOOL))
assertEquals(5, after.resources[0].getValue(Resource.BRICK))
}
@Test
fun `no ship without the cards, and none sails out of nothing`() {
val bare = playing().let { it.settle(it.coastVertex(), 0) }
.let { it.copy(resources = it.resources.toMutableList().also { r -> r[0] = Resource.entries.associateWith { 0 } }) }
assertTrue(CatanRules.legalMoves(bare, 0).none { it is BuildShip })
// Nobody has a town or a fleet: there is nowhere to start from.
val nothing = playing()
assertTrue(CatanRules.legalMoves(nothing, 0).none { it is BuildShip })
}
@Test
fun `the pirate keeps ships off the edges round its hex`() {
val s = playing().let { it.settle(it.coastVertex(), 0) }
val ship = CatanRules.legalMoves(s, 0).filterIsInstance<BuildShip>().first()
val seaHex = s.topo.EDGE_HEXES[ship.edge].first { !s.topo.land[it] }
val blocked = s.copy(sea = s.sea!!.copy(pirate = seaHex))
assertTrue(CatanRules.legalMoves(blocked, 0).filterIsInstance<BuildShip>().none { seaHex in s.topo.EDGE_HEXES[it.edge] })
}
@Test
fun `a ship cannot sail the turn it is built, but can the next, from the open end only`() {
val s0 = playing().let { it.settle(it.coastVertex(), 0) }
val first = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first()
val s1 = CatanRules.applyMove(s0, 0, first)
assertTrue(CatanRules.legalMoves(s1, 0).none { it is MoveShip })
// Next turn (after the others pass), the lone ship is at an open end and can sail.
val s2 = s1.copy(sea = s1.sea!!.copy(builtThisTurn = emptyList(), shipMoved = false))
val sails = CatanRules.legalMoves(s2, 0).filterIsInstance<MoveShip>()
assertTrue(sails.isNotEmpty())
assertTrue(sails.all { it.from == first.edge && it.to != first.edge })
// Sailing uses the turn's one move.
val s3 = CatanRules.applyMove(s2, 0, sails.first())
assertTrue(s3.sea!!.shipMoved)
assertEquals(NO_PLAYER, s3.sea!!.ships[first.edge])
assertEquals(0, s3.sea!!.ships[sails.first().to])
assertTrue(CatanRules.legalMoves(s3, 0).none { it is MoveShip })
}
@Test
fun `a ship in the middle of a route is stuck`() {
// Town - ship A - ship B: A has the town at one end and B at the other, so only B is at an open end.
val s0 = playing().let { it.settle(it.coastVertex(), 0) }
val a = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first().edge
var s = s0.withShip(a, 0)
val far = s.topo.EDGE_VERTICES[a].toList().first { s.buildings[it] == null }
val b = s.topo.VERTEX_EDGES[far].first { it != a && s.topo.edgeSea[it] && s.roads[it] == NO_PLAYER }
s = s.withShip(b, 0)
val movers = CatanRules.legalMoves(s, 0).filterIsInstance<MoveShip>().map { it.from }.toSet()
assertEquals(setOf(b), movers)
}
@Test
fun `a settlement can rise at the end of a ship route, and the first on a new island scores the bonus`() {
val base = playing()
val topo = base.topo
val sea = base.sea!!
// A vertex on a small island, and a ship chain reaching it is too long to build by hand,
// so give the player the ship on the edge beside that vertex.
val vertex = (0 until topo.VERTEX_COUNT).first { v ->
val island = topo.vertexIsland[v]
island != -1 && island != sea.home &&
topo.VERTEX_EDGES[v].any { topo.edgeSea[it] }
}
val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeSea[it] }
var s = base.withShip(edge, 0)
assertTrue(CatanRules.legalMoves(s, 0).any { it == BuildSettlement(vertex) })
val before = CatanRules.publicVictoryPoints(s)[0]
s = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(before + 1 + SeaRules.ISLAND_BONUS, CatanRules.publicVictoryPoints(s)[0])
assertEquals(0, s.sea!!.islandBonus[topo.vertexIsland[vertex]])
// A second settlement on that island scores no second bonus.
val again = SeaRules.claimIsland(s, 1, vertex)
assertEquals(0, again.sea!!.islandBonus[topo.vertexIsland[vertex]])
}
@Test
fun `a road and a ship join a route only at a town of your own`() {
val base = playing()
val topo = base.topo
val v = base.coastVertex()
val road = topo.VERTEX_EDGES[v].first { topo.edgeLand[it] }
val ship = topo.VERTEX_EDGES[v].first { topo.edgeSea[it] && !topo.edgeLand[it] }
// With a town at the junction, road + ship is one route of two.
val withTown = base.settle(v, 0).withRoad(road, 0).withShip(ship, 0)
assertEquals(2, SeaRules.longestRouteFor(withTown, 0))
// Without it, they are two separate pieces.
val noTown = base.withRoad(road, 0).withShip(ship, 0)
assertEquals(1, SeaRules.longestRouteFor(noTown, 0))
}
// --- Pirate --------------------------------------------------------------
@Test
fun `on a seven the roller may move the robber to land or the pirate to sea, and the pirate robs a ship's owner`() {
val s0 = playing().let { it.settle(it.coastVertex(), 1) }
val ship = SeaRules.buildShipMoves(s0, 1).first().edge
val s = s0.withShip(ship, 1).copy(phase = CatanPhase.ROBBER)
val moves = CatanRules.legalMoves(s, 0)
assertTrue(moves.any { it is MoveRobber })
assertTrue(moves.any { it is MovePirate })
// Robber moves are on land only.
assertTrue(moves.filterIsInstance<MoveRobber>().all { s.topo.land[s.topo.hexIndex.getValue(it.hex)] })
assertTrue(moves.filterIsInstance<MovePirate>().all { !s.topo.land[s.topo.hexIndex.getValue(it.hex)] })
val robbery = moves.filterIsInstance<MovePirate>().first { it.victim == 1 }
val after = CatanRules.applyMove(s, 0, robbery)
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(s.topo.hexIndex.getValue(robbery.hex), after.sea!!.pirate)
assertEquals(s.resourceCount(0) + 1, after.resourceCount(0))
assertEquals(s.resourceCount(1) - 1, after.resourceCount(1))
}
// --- Gold ----------------------------------------------------------------
@Test
fun `gold pays a resource of the owner's choice`() {
val base = playing()
val topo = base.topo
val goldHex = base.terrains.indexOf(Terrain.GOLD)
val number = base.numbers[goldHex]
val v = topo.HEX_CORNERS[goldHex].first { topo.vertexLand[it] }
// Seat 0's town beside the gold field; nothing else of theirs produces on that number.
val s = base.settle(v, 0).copy(
robber = (0 until topo.HEX_COUNT).first { topo.land[it] && it != goldHex && v !in topo.HEX_CORNERS[it] },
resources = List(3) { Resource.entries.associateWith { 0 } },
phase = CatanPhase.AWAIT_ROLL,
)
val dice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6)
val rolled = CatanRules.applyMove(s, 0, RollDice(forced = dice))
assertEquals(CatanPhase.GOLD, rolled.phase)
assertEquals(0, CatanRules.currentSeat(rolled))
val picks = CatanRules.legalMoves(rolled, 0)
assertEquals(Resource.entries.size, picks.size)
val after = CatanRules.applyMove(rolled, 0, PickGold(listOf(Resource.ORE)))
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(1, after.resources[0].getValue(Resource.ORE))
}
// --- Whole games ---------------------------------------------------------
private data class Report(val seed: Long, val moves: Int, val ships: Int, val islandsTaken: Int, val points: List<Int>)
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 20_000): Pair<CatanState, Report> {
val ai = CatanAi()
var state = CatanRules.initialState(config(options, seed))
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("seed $seed ran past $cap moves; log tail: ${state.log.takeLast(8)}", 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: seat $seat chose the illegal move $move in ${state.phase}; log tail: ${state.log.takeLast(5)}",
CatanRules.isLegalMove(state, seat, move),
)
val next = CatanRules.applyMove(state, seat, move)
// The wire format must carry everything, every step.
if (moves % 25 == 0) assertEquals(next, CatanRules.decodeState(CatanRules.encodeState(next)))
state = next
}
val sea = state.sea!!
return state to Report(
seed, moves,
ships = sea.ships.count { it != NO_PLAYER },
islandsTaken = sea.islandBonus.count { it != NO_PLAYER },
points = CatanRules.publicVictoryPoints(state),
)
}
@Test
fun `engines finish Seafarers games at three and four players`() {
val reports = mutableListOf<Report>()
for ((players, seeds) in listOf(3 to listOf(1L, 2L, 3L, 4L), 4 to listOf(5L, 6L, 7L))) {
for (seed in seeds) {
val (state, report) = playOut(options(players), seed)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
val winner = state.winner
assertTrue("seed $seed finished without a winner", winner != null)
assertTrue(
"seed $seed: the winner fell short of the target",
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
)
reports += report
}
}
println("Seafarers self-play: " + reports.joinToString("; ") {
"seed ${it.seed}: ${it.moves} moves, ${it.ships} ships, ${it.islandsTaken} islands, points ${it.points}"
})
// The engines must actually go to sea in at least some of the games.
assertNotEquals("no engine ever built a ship", 0, reports.sumOf { it.ships })
}
}
@@ -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 })
@@ -0,0 +1,30 @@
package org.prolibertate.games.game.yahtzee
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class YahtzeeStatsTest {
private fun game(total: Int, bonus: Boolean = false, yahtzees: Int = 0, won: Boolean = true) =
YahtzeeRecord(total, bonus, yahtzees, zeros = 0, players = 2, won = won)
@Test
fun firstGameHasNothingToCompareTo() {
val c = compareToHistory(emptyList(), game(200))
assertEquals(1, c.rank)
assertEquals(0, c.previousGames)
assertNull(c.bestBefore)
assertNull(c.averageBefore)
}
@Test
fun ranksAgainstPreviousGames() {
val history = listOf(game(150, bonus = true, yahtzees = 1), game(250), game(200, won = false))
val c = compareToHistory(history, game(220))
assertEquals(2, c.rank)
assertEquals(4, c.gamesRanked)
assertEquals(250, c.bestBefore)
assertEquals(200.0, c.averageBefore!!, 0.001)
assertEquals(1, c.yahtzeesBefore)
}
}
@@ -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()))
@@ -0,0 +1,149 @@
package org.prolibertate.games.ui.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.catan.BuildShip
import org.prolibertate.games.game.catan.BuildingType
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.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.PickGold
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** What the screen offers on a Seafarers board: ships, the pirate, and gold. */
class CatanSeafarersInteractionTest {
private fun fresh(): CatanState {
val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
return CatanRules.initialState(
TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
optionsJson = Json { encodeDefaults = true }.encodeToString(options),
seed = 7L,
),
)
}
/** Seat 0, mid-game, a harbour town on the coast of the home island and plenty of cards. */
private fun harbour(): CatanState {
val s = fresh().copy(
phase = CatanPhase.PLAYING,
turn = 0,
turnNumber = 1,
resources = List(3) { Resource.entries.associateWith { 5 } },
)
val topo = s.topo
val v = (0 until topo.VERTEX_COUNT).first { vertex ->
topo.vertexIsland[vertex] == s.sea!!.home &&
topo.VERTEX_EDGES[vertex].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
return s.copy(buildings = s.buildings.toMutableList().also { it[v] = CatanBuilding(BuildingType.SETTLEMENT, 0) })
}
@Test
fun `the ship is on the build menu, and arming it lights the sea edges a ship can take`() {
val s = harbour()
val legal = CatanRules.legalMoves(s, 0)
assertTrue(BuildKind.SHIP in CatanInteraction.canBuild(legal))
val targets = CatanInteraction.targets(s, legal, BuildKind.SHIP, null)
assertEquals(legal.filterIsInstance<BuildShip>().map { it.edge }.toSet(), targets.edges)
val edge = targets.edges.first { !s.topo.edgeLand[it] }
assertEquals(
TapOutcome.Submit(BuildShip(edge)),
CatanInteraction.onEdgeTap(edge, legal, BuildKind.SHIP, null),
)
// The same tap with the road armed means nothing: a road cannot lie between two sea hexes.
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, BuildKind.ROAD, null))
// A build tap waits for ✓ like any other piece.
assertEquals(BuildShip(edge), CatanInteraction.stageable(TapOutcome.Submit(BuildShip(edge)), BuildKind.SHIP))
}
@Test
fun `tapping a ship that can sail picks it up, and the next tap on a lit sea edge sails it`() {
val s0 = harbour()
val built = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first()
val s = CatanRules.applyMove(s0, 0, built)
.let { it.copy(sea = it.sea!!.copy(builtThisTurn = emptyList())) }
val legal = CatanRules.legalMoves(s, 0)
val idle = CatanInteraction.targets(s, legal, null, null)
assertEquals(setOf(built.edge), idle.ships)
assertEquals(TapOutcome.PickShip(built.edge), CatanInteraction.onEdgeTap(built.edge, legal, null, null))
val sailing = CatanInteraction.targets(s, legal, null, null, movingShip = built.edge)
val to = sailing.edges.first()
assertEquals(
TapOutcome.Submit(MoveShip(built.edge, to)),
CatanInteraction.onEdgeTap(to, legal, null, null, movingShip = built.edge),
)
}
@Test
fun `after a seven a land hex moves the robber and a sea hex moves the pirate`() {
val s = harbour().copy(phase = CatanPhase.ROBBER)
val legal = CatanRules.legalMoves(s, 0)
val targets = CatanInteraction.targets(s, legal, null, null)
val land = targets.hexes.first { s.terrains[it] != Terrain.SEA }
val sea = targets.hexes.first { s.terrains[it] == Terrain.SEA }
val robber = CatanInteraction.onHexTap(land, legal, topo = s.topo)
assertTrue(robber is TapOutcome.Submit && robber.move is MoveRobber)
val pirate = CatanInteraction.onHexTap(sea, legal, topo = s.topo)
assertTrue(pirate is TapOutcome.Submit && pirate.move is MovePirate)
}
@Test
fun `a gold pick is found by the cards it names`() {
val picks = listOf(PickGold(listOf(Resource.ORE)), PickGold(listOf(Resource.WOOL)))
assertEquals(PickGold(listOf(Resource.WOOL)), CatanInteraction.goldMove(picks, listOf(Resource.WOOL)))
assertEquals(null, CatanInteraction.goldMove(picks, listOf(Resource.WOOL, Resource.WOOL)))
assertEquals(1, CatanInteraction.goldCount(picks))
}
@Test
fun `the engines' own choices are always something the screen can make`() {
// Every legal move in a played-out game is reachable: a tap, a button or a dialog choice.
val ai = CatanAi()
var s = fresh()
var n = 0
while (!CatanRules.isFinished(s) && n++ < 600) {
val seat = CatanRules.currentSeat(s) ?: break
val legal = CatanRules.legalMoves(s, seat)
val ships = legal.filterIsInstance<BuildShip>()
if (s.phase == CatanPhase.PLAYING && ships.isNotEmpty()) {
val armed = CatanInteraction.targets(s, legal, BuildKind.SHIP, null)
ships.forEach { ship ->
assertTrue("ship ${ship.edge} not lit", ship.edge in armed.edges)
assertEquals(TapOutcome.Submit(ship), CatanInteraction.onEdgeTap(ship.edge, legal, BuildKind.SHIP, null))
}
}
legal.filterIsInstance<MoveShip>().forEach { sail ->
val lit = CatanInteraction.targets(s, legal, null, null, movingShip = sail.from)
assertTrue(sail.to in lit.edges)
assertEquals(
TapOutcome.Submit(sail),
CatanInteraction.onEdgeTap(sail.to, legal, null, null, movingShip = sail.from),
)
}
s = CatanRules.applyMove(s, seat, ai.chooseMove(s, seat, legal))
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
| Golf | [RULES-golf.md](RULES-golf.md) |
| Hearts | — (TODO) |
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) |
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule sets, [RULES-catan-ck.md](RULES-catan-ck.md) and [RULES-catan-seafarers.md](RULES-catan-seafarers.md) |
| Klondike | — (TODO) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
| Meowdoku | [RULES-meowdoku.md](RULES-meowdoku.md) |
+71
View File
@@ -0,0 +1,71 @@
# Katan: Seafarers
The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. 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.
Where the published rules leave room, or where this game differs, it says so below.
## The map
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
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).
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
it is land and a ship if any hex touching it is sea (so a coastal edge takes either, never both).
## What changes
**Setup.** Both starting settlements (and their roads) go on the home island. A starting piece is a
road, never a ship. The second settlement collects from the hexes round it as usual (gold pays nothing
at setup).
**Ships.** A ship costs 1 lumber and 1 wool and lies on a sea edge.
* It must join your fleet or a town of yours: one end of the edge touches your town, or another of your ships.
An opponent's town is a wall.
* Roads and ships connect **only at a town of your own**: a road cannot start from a ship's end, nor a
ship from a road's, unless a settlement or city of yours stands on that crossing. A settlement may be
built at the end of a ship route exactly as at the end of a road route.
* No more than 15 ships. Road Building may lay two roads, two ships or one of each.
* **Sailing.** Once per turn you may move one ship, never one built or moved this turn, and only a ship at the
**open end** of a route (one end touches nothing else of yours). It goes to any edge it could be built on
with it lifted off.
**The pirate.** Starts in the open sea. On a seven (and with a Knight card) the player moves either the
robber, to any land hex, or the pirate, to any sea hex. The robber works as before. The pirate **blocks the six
edges round its hex**: no ship may be built or sail onto them (ships already there stay). It robs one card
at random from a player with a ship on an edge of its hex.
**Gold.** A gold field has a number like any hex. When it comes up, each town beside it (one card; a city two)
pays its owner **any resource of their choice**, chosen after the ordinary production is dealt, in seat order from
the roller. The robber blocks a gold field like any hex. If the bank cannot supply the choice it is limited to
what it holds.
**New islands.** The first player to settle on each island other than the home island scores **2 bonus points**,
permanently (`SeaState.islandBonus`). A second settlement there earns nothing extra.
**Longest route.** Longest Road counts roads and ships together, as a path of your pieces that never runs
through an opponent's town and changes between road and ship only at a town of yours. Five or more, as before.
Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game.
## 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.
## 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
not sail ships it has already launched. See `CatanAi.shipValue`.
+16
View File
@@ -192,3 +192,19 @@ Landscape screens use the control scheme of Catan Universe's match view, redrawn
ratio under the *give* card. An offer made to you opens the same panel read-only: ✓ accepts,
✕ declines, *Counter…* makes the rows editable.
Cities & Knights goods trades and the counter-offer chooser still use dialogs.
## Seafarers on the screen
| Layer | What it adds |
| --- | --- |
| `CatanGeometry.kt` | `BoardLayout` and `CatanGeometry.fit` take the game's `Topology`, so a map of islands fits the box like the standard island does |
| `CatanStyle.kt` | gold-field colour, the pirate's and the ship sail's colours |
| `CatanBoardView.kt` | sea hexes (a faint grid, no fill), ships (hull with a sail), the pirate (a flagged hull), a ring on ships that can sail |
| `CatanInteraction.kt` | `BuildKind.SHIP`; `PickShip`, `movingShip`, ship targets; the robber's and the pirate's hexes; `goldMove` |
| `CatanDialogs.kt` | `CatanGoldDialog`, the choice of what a gold field pays |
| `CatanScreen.kt` | the picked-up ship, the ship on the build menu / column |
**Interaction model.** Arm *Ship* (or drag it from the build column) and tap a lit sea edge: ✓ places it. To sail,
tap one of your ships that has a ring, then a lit sea edge. After a seven, tap a land hex for the robber or a sea
hex for the pirate. A gold field opens a dialog that cannot be dismissed. `CatanSeafarersInteractionTest` checks
that every build and sail the engine offers has a tap that produces it.