Compare commits

..
Author SHA1 Message Date
Claude 5e9bb2feaf Seafarers: Four Islands and Through the Desert maps
CI / Build and check (pull_request) Successful in 10m34s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Add two map-only scenarios. A scenario now says where the starting
settlements may go (SeaStart); SeaState.starts holds the starting
islands, and the first-landing bonus skips all of them. Saves without
starts fall back to the home island.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-11 17:17:57 +00:00
devadmin cc33dffd9a Merge pull request 'Katan: expansions as toggles, scenario in options (phase 1)' (#33) from feature/catan-expansions-toggle into dev
CI / Build and check (push) Successful in 10m40s
CI / Deploy to Cloudflare Pages (push) Successful in 33s
2026-11-11 11:01:42 -06:00
Claude 6c311ad304 Katan: expansions as toggles, scenario chosen in options
CI / Build and check (pull_request) Successful in 10m56s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Replace the single CatanRuleSet with a set of expansions and add a
scenario field; saves with the old ruleSet still decode. Setup shows
a toggle per expansion and a map picker for Seafarers. Combining
expansions is rejected for now (lifted in a later phase).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-11 16:50:24 +00:00
devadmin f8c04cec88 Merge pull request 'Unify computer difficulty; random Katan first seat' (#32) from feature/unify-difficulty-catan-first-seat into dev
CI / Build and check (push) Successful in 10m48s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-11-08 08:33:48 -06:00
Claude 97728daf0a Fold per-game opponent levels into computer difficulty; randomise Katan first seat
CI / Build and check (pull_request) Successful in 11m2s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
The per-game Casual/Club/Strong rows duplicated the new Computer difficulty
setting, so they are gone from setup (Tayu's Gentle stays: it is a table rule).
The saved level is still the Medium baseline, so old saves load unchanged.
Mastermind now takes its level from the difficulty. Katan's first seat is
drawn from the table seed instead of always being seat 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
2026-11-08 14:22:24 +00:00
devadmin a30b1dfcd0 Merge pull request 'Computer difficulty levels and hidden strategies' (#31) from feature/ai-difficulty into dev
CI / Build and check (push) Successful in 11m5s
CI / Deploy to Cloudflare Pages (push) Successful in 29s
2026-11-07 23:26:08 -06:00
20 changed files with 416 additions and 165 deletions
+2 -2
View File
@@ -51,8 +51,8 @@ integration:
## Katan
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
sets chosen at setup: the base game (`docs/RULES-catan.md`), Cities & Knights
The island game is catalogue id `catan`, shown as **Katan**. It has two expansions
(toggles, `CatanOptions.expansions`; none on is the base game): the base game (`docs/RULES-catan.md`), Cities & Knights
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`CatanState.ck`, which is null in the base game) and Seafarers
(`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind
@@ -646,7 +646,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
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 fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
best = maxOf(best, value)
}
@@ -1,7 +1,16 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
const val NO_PLAYER = -1
@@ -151,12 +160,9 @@ enum class CatanPhase {
CHORE,
}
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
/** An expansion a table can switch on. The base game is what is left with none selected. */
@Serializable
enum class CatanRuleSet {
/** The base game: development cards, Largest Army, a robber from the start. */
BASE,
enum class CatanExpansion {
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS,
@@ -164,15 +170,58 @@ enum class CatanRuleSet {
SEAFARERS,
}
@Serializable
/**
* What a table plays: the expansions switched on, and the Seafarers map when that one is.
*
* Saves written before expansions became toggles carry `"ruleSet"` instead of `"expansions"`;
* [CatanOptionsSerializer] reads either.
*/
@Serializable(with = CatanOptionsSerializer::class)
data class CatanOptions(
val playerCount: Int = 3,
val pointsToWin: Int = 10,
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
val expansions: Set<CatanExpansion> = emptySet(),
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
) {
init {
require(playerCount in 3..4) { "The island seats three or four players" }
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
// Lifted once Cities & Knights runs on a Seafarers map.
require(expansions.size <= 1) { "Combining expansions is not supported yet" }
}
val seafarers: Boolean get() = CatanExpansion.SEAFARERS in expansions
val citiesAndKnights: Boolean get() = CatanExpansion.CITIES_AND_KNIGHTS in expansions
}
/** The wire shape of [CatanOptions]. */
@Serializable
private class CatanOptionsWire(
val playerCount: Int = 3,
val pointsToWin: Int = 10,
val expansions: Set<CatanExpansion> = emptySet(),
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
)
/** Reads the legacy single `ruleSet` field as an expansion set. */
object CatanOptionsSerializer : KSerializer<CatanOptions> {
override val descriptor: SerialDescriptor = CatanOptionsWire.serializer().descriptor
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
CatanOptionsWire.serializer(),
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario),
)
override fun deserialize(decoder: Decoder): CatanOptions {
val input = decoder as JsonDecoder
var obj = input.decodeJsonElement().jsonObject
val legacy = (obj["ruleSet"] as? JsonPrimitive)?.content
if (legacy != null) {
val expansions = if (legacy == "BASE") emptyList() else listOf(JsonPrimitive(legacy))
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
}
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario)
}
}
@@ -198,6 +247,8 @@ data class CatanState(
val turn: Int,
/** How many setup placements are done, 0 until 2 * playerCount. */
val setupIndex: Int,
/** The seat that places first and takes the first turn; drawn from the table seed. */
val firstSeat: Int = 0,
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
val turnNumber: Int,
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
@@ -414,12 +465,17 @@ data class SeaState(
val shipMoved: Boolean = false,
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
val islandBonus: List<Int>,
/** The island the players start on. */
/** The island that counts as home: the largest, and the pirate-free start of the original map. */
val home: Int,
/** The islands the starting settlements may go on. Empty in a save from before there were several: just [home]. */
val starts: List<Int> = emptyList(),
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
val gold: List<GoldOwed> = emptyList(),
)
/** Whether [island] is one the game starts on, and so never earns the new-island bonus. */
fun SeaState.isStart(island: Int): Boolean = island == home || island in starts
@Serializable
data class GoldOwed(val seat: Int, val count: Int)
@@ -54,15 +54,15 @@ object CatanRules : GameRules<CatanState, CatanMove> {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
val sea = options.ruleSet == CatanRuleSet.SEAFARERS
val scenario = SeaScenario.NEW_SHORES
val sea = options.seafarers
val scenario = options.scenario
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
?: CatanBoard.generate(config.seed)
// A different random stream than the island, so the same seed still
// produces both a fixed island and a fixed deck.
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
val ck = options.citiesAndKnights
// Cities & Knights has no development cards: knights and progress
// cards take their place.
val devDeck = if (ck) {
@@ -73,12 +73,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
.shuffled(Random(config.seed + 1))
}
// Seat 0 used to lead every game; a separate stream from the board and
// the deck keeps the same seed giving the same table.
val firstSeat = Random(config.seed + 2).nextInt(options.playerCount)
return CatanState(
options = options,
seed = config.seed,
phase = CatanPhase.SETUP_FIRST,
turn = 0,
turn = firstSeat,
setupIndex = 0,
firstSeat = firstSeat,
turnNumber = 0,
terrains = board.terrains,
numbers = board.numbers,
@@ -114,6 +119,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
pirate = it.pirate,
islandBonus = List(topo.islandCount) { NO_PLAYER },
home = it.home,
starts = it.starts,
)
},
)
@@ -121,8 +127,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
override fun currentSeat(state: CatanState): Int? = when (state.phase) {
CatanPhase.GAME_OVER -> null
CatanPhase.SETUP_FIRST -> state.setupIndex
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
CatanPhase.SETUP_FIRST -> (state.firstSeat + state.setupIndex) % state.playerCount
CatanPhase.SETUP_SECOND ->
(state.firstSeat + 2 * state.playerCount - 1 - state.setupIndex) % state.playerCount
CatanPhase.DISCARDING -> state.discardSeat
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
@@ -201,8 +208,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val moves = mutableListOf<SetupPlace>()
for (v in 0 until state.topo.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue
// Seafarers: everyone starts on the home island.
if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue
// Seafarers: the starting settlements go on a starting island.
if (state.sea != null && !state.sea.isStart(state.topo.vertexIsland[v])) continue
for (e in state.topo.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
moves += SetupPlace(v, e)
@@ -512,9 +519,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return when {
newIndex >= 2 * n -> s.copy(
phase = CatanPhase.AWAIT_ROLL,
turn = 0,
turn = state.firstSeat,
turnNumber = 1,
log = s.log + "Setup done — seat 0 leads.",
log = s.log + "Setup done — seat ${state.firstSeat} leads.",
)
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
@@ -5,11 +5,29 @@ import kotlin.random.Random
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
@Serializable
enum class SeaScenario(val title: String, val blurb: String) {
enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart = SeaStart.HOME_ONLY) {
NEW_SHORES(
"Heading for New Shores",
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
),
FOUR_ISLANDS(
"Four Islands",
"Four equal islands, one start each, and two gold islets in the middle. Settle your own island, then sail.",
SeaStart.MAIN_ISLANDS,
),
THROUGH_THE_DESERT(
"Through the Desert",
"One island cut by a broad desert that yields nothing. Reach the far side by road, or sail round to the islets.",
),
}
/** Where the starting settlements may go. */
enum class SeaStart {
/** The largest island only. */
HOME_ONLY,
/** Any island big enough to live on (four hexes or more); the small ones are prizes to sail to. */
MAIN_ISLANDS,
}
/**
@@ -33,8 +51,34 @@ object SeaBoards {
"..........",
)
private val FOUR_ISLANDS_MAP = listOf(
"............",
"..###..###..",
"..###..###..",
"............",
"..$.....$...",
"............",
"..###..###..",
"..###..###..",
"............",
)
private val THROUGH_THE_DESERT_MAP = listOf(
"............",
".###DD###...",
".###DDD###..",
"..###DD###..",
"............",
"..$.....$...",
"............",
"...##...#...",
"............",
)
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP
SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP
}
private val topologies = HashMap<SeaScenario, Topology>()
@@ -72,8 +116,13 @@ object SeaBoards {
val pirate: Int,
val ports: List<Port>,
val home: Int,
/** The islands the starting settlements may go on; always includes [home]. */
val starts: List<Int> = listOf(home),
)
/** An island of this many land hexes or more can be started on. */
private const val MAIN_ISLAND_HEXES = 4
private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
)
@@ -139,6 +188,13 @@ object SeaBoards {
} ?: 0
} ?: sea.first()
return Generated(terrains, numbers.toList(), desert, pirate, ports, home)
val starts = when (scenario.start) {
SeaStart.HOME_ONLY -> listOf(home)
SeaStart.MAIN_ISLANDS -> (0 until topo.islandCount).filter { island ->
topo.islandOf.count { it == island } >= MAIN_ISLAND_HEXES
}.ifEmpty { listOf(home) }
}
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts)
}
}
@@ -153,7 +153,7 @@ internal object SeaRules {
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
val sea = state.sea ?: return state
val island = state.topo.vertexIsland[vertex]
if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state
if (island < 0 || sea.isStart(island) || sea.islandBonus[island] != NO_PLAYER) return state
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
return state.copy(
sea = sea.copy(islandBonus = bonus),
@@ -1,6 +1,7 @@
package org.prolibertate.games.game.mastermind
import kotlin.random.Random
import org.prolibertate.games.game.engine.AiLevel
import org.prolibertate.games.game.engine.AiProfiles
import org.prolibertate.games.game.engine.GameAi
@@ -18,15 +19,13 @@ import org.prolibertate.games.game.engine.GameAi
* plays the one whose *worst* possible answer leaves the smallest number of
* candidates behind, so it cannot be given a reply that tells it little.
*
* The table already carries its own three-step level ([MastermindOptions.level]),
* chosen at setup and shown to the player, and it means exactly what an
* [org.prolibertate.games.game.engine.AiLevel] would here. Layering a second
* knob on top would make the two disagree, so the shared [profiles] are
* accepted for a uniform constructor but this game keeps playing at the level
* the table names. [level] overrides that, which is what the tests use.
* The computer difficulty chosen at setup picks the level: Easy plays Casual,
* Medium plays whatever the table's saved [MastermindOptions.level] says (Club
* for a new game) and Hard plays Strong. [level] overrides all of that, which is
* what the tests use.
*/
class MastermindAi(
@Suppress("unused") private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val profiles: AiProfiles = AiProfiles.DEFAULT,
private val level: MastermindLevel? = null,
) : GameAi<MastermindState, MastermindMove> {
@@ -38,7 +37,12 @@ class MastermindAi(
legal: List<MastermindMove>,
): MastermindMove {
require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
val setting = level ?: state.options.level
val profile = profiles.of(seat)
val setting = level ?: when (profile.level) {
AiLevel.EASY -> MastermindLevel.CASUAL
AiLevel.MEDIUM -> state.options.level
AiLevel.HARD -> MastermindLevel.STRONG
}
// Deterministic for a given position, so a host and a client watching
// the same game agree on what the computer did.
@@ -35,11 +35,8 @@ import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.SavedMatch
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.backgammon.BackgammonLevel
import org.prolibertate.games.game.backgammon.BackgammonOptions
import org.prolibertate.games.game.checkers.CheckersLevel
import org.prolibertate.games.game.checkers.CheckersOptions
import org.prolibertate.games.game.chess.ChessLevel
import org.prolibertate.games.game.chess.ChessOptions
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
import org.prolibertate.games.game.cribbage.CribbageOptions
@@ -57,15 +54,13 @@ import org.prolibertate.games.game.pegsolitaire.PegBoard
import org.prolibertate.games.game.pegsolitaire.PegGoal
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
import org.prolibertate.games.game.kaiser.KaiserOptions
import org.prolibertate.games.game.mastermind.MastermindLevel
import org.prolibertate.games.game.mastermind.MastermindOptions
import org.prolibertate.games.game.morris.MorrisLevel
import org.prolibertate.games.game.morris.MorrisOptions
import org.prolibertate.games.game.pirates.PiratesLevel
import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.SeaScenario
import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions
@@ -379,47 +374,48 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
else -> seat
}
/** The targets the setup screen offers for a Katan rule set. */
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) {
// Five is a short game for trying things out; ten is the published target.
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
/** The targets the setup screen offers for a Katan game with [expansions] on. */
internal fun catanPointsChoices(expansions: Set<CatanExpansion>): List<Int> = when {
// Seafarers adds two points for each new island, so the target is higher.
CatanRuleSet.SEAFARERS -> listOf(8, 10, 12, 14)
CatanExpansion.SEAFARERS in expansions -> listOf(8, 10, 12, 14)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> listOf(8, 10, 13, 15)
// Five is a short game for trying things out; ten is the published target.
else -> listOf(5, 8, 10, 12)
}
/** The published target for a rule set. */
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
CatanRuleSet.BASE -> 10
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
CatanRuleSet.SEAFARERS -> 12
/** The published target for a game with [expansions] on. */
internal fun catanDefaultPoints(expansions: Set<CatanExpansion>): Int = when {
CatanExpansion.SEAFARERS in expansions -> 12
CatanExpansion.CITIES_AND_KNIGHTS in expansions -> 13
else -> 10
}
/**
* [options] switched to [ruleSet]. The target moves to the new rule set's own
* default unless the one already chosen is on its menu too.
* [options] with [expansion] switched on or off. The target moves to the new combination's own
* default unless the one already chosen is on its menu too. Expansions cannot be combined yet,
* so switching one on switches the other off.
*/
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions =
options.copy(
ruleSet = ruleSet,
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) }
?: catanDefaultPoints(ruleSet),
internal fun catanToggleExpansion(options: CatanOptions, expansion: CatanExpansion, on: Boolean): CatanOptions {
val expansions = if (on) setOf(expansion) else options.expansions - expansion
return options.copy(
expansions = expansions,
pointsToWin = options.pointsToWin.takeIf {
it in catanPointsChoices(expansions) && it != catanDefaultPoints(options.expansions)
} ?: catanDefaultPoints(expansions),
)
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE -> "Base game"
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
CatanRuleSet.SEAFARERS -> "Seafarers"
}
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE ->
"Settle the island, trade, and play development cards. Longest Road and Largest Army score."
CatanRuleSet.CITIES_AND_KNIGHTS ->
private fun catanExpansionName(expansion: CatanExpansion): String = when (expansion) {
CatanExpansion.CITIES_AND_KNIGHTS -> "Cities & Knights"
CatanExpansion.SEAFARERS -> "Seafarers"
}
private fun catanExpansionBlurb(expansion: CatanExpansion): String = when (expansion) {
CatanExpansion.CITIES_AND_KNIGHTS ->
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
"cards and metropolises. No development cards; the robber sleeps until the first attack."
CatanRuleSet.SEAFARERS ->
CatanExpansion.SEAFARERS ->
"Build ships to reach new islands. Gold fields pay any resource, a pirate guards the sea, " +
"and the first settlement on each new island scores two extra points."
}
@@ -427,14 +423,24 @@ private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
@Composable
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Rules",
values = CatanRuleSet.entries,
selected = options.ruleSet,
display = ::catanRuleSetName,
onSelect = { onChange(catanSwitchRules(options, it)) },
)
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall)
CatanExpansion.entries.forEach { expansion ->
ToggleRow(
title = catanExpansionName(expansion),
subtitle = catanExpansionBlurb(expansion),
checked = expansion in options.expansions,
onChange = { onChange(catanToggleExpansion(options, expansion, it)) },
)
}
if (options.seafarers) {
ChipRow(
label = "Map",
values = SeaScenario.entries,
selected = options.scenario,
display = { it.title },
onSelect = { onChange(options.copy(scenario = it)) },
)
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
}
ChipRow(
label = "Players",
values = listOf(3, 4),
@@ -444,7 +450,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
)
ChipRow(
label = "Game to",
values = catanPointsChoices(options.ruleSet),
values = catanPointsChoices(options.expansions),
selected = options.pointsToWin,
display = { "$it points" },
onSelect = { onChange(options.copy(pointsToWin = it)) },
@@ -785,13 +791,6 @@ private fun ChessOptionsEditor(
display = { if (it) "White" else "Black" },
onSelect = onColour,
)
ChipRow(
label = "Opponent",
values = ChessLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "Fifty-move rule",
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
@@ -815,13 +814,6 @@ private fun ChessOptionsEditor(
@Composable
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = CheckersLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "Flying kings",
subtitle = "A king slides the length of a diagonal and takes from a distance, " +
@@ -856,13 +848,6 @@ private fun BackgammonOptionsEditor(
onChange: (BackgammonOptions) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = BackgammonLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "Count gammons",
subtitle = "A loser who bore nothing off has lost double, and one still on the " +
@@ -884,13 +869,6 @@ private fun MastermindOptionsEditor(
onChange: (MastermindOptions) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = MastermindLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ChipRow(
label = "Colours",
values = listOf(6, 8),
@@ -955,13 +933,6 @@ private fun PiratesOptionsEditor(
display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
onSelect = onSide,
)
ChipRow(
label = "Opponent",
values = PiratesLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ToggleRow(
title = "A pirate who can take must take",
subtitle = "The rule that huffing exists to enforce, applied directly",
@@ -987,13 +958,6 @@ private fun PiratesOptionsEditor(
@Composable
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Opponent",
values = MorrisLevel.entries.toList(),
selected = options.level,
display = { it.label },
onSelect = { onChange(options.copy(level = it)) },
)
ChipRow(
label = "Pieces each",
values = listOf(3, 6, 9),
@@ -55,15 +55,15 @@ class CatanAiProfilesTest {
(0 until 3).any { p.of(it).style == style }
}
private fun forEveryProfile(rules: CatanRuleSet, cap: Int) {
private fun forEveryProfile(rules: Set<CatanExpansion>, cap: Int) {
val options = CatanOptions(
playerCount = 3,
pointsToWin = when (rules) {
CatanRuleSet.BASE -> 10
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
else -> 12
pointsToWin = when {
CatanExpansion.CITIES_AND_KNIGHTS in rules -> 13
CatanExpansion.SEAFARERS in rules -> 12
else -> 10
},
ruleSet = rules,
expansions = rules,
)
for (difficulty in listOf(AiDifficulty.EASY, AiDifficulty.HARD)) {
for (style in AiStyle.entries) {
@@ -78,14 +78,14 @@ class CatanAiProfilesTest {
}
@Test
fun `easy and hard seats of every style play the base game`() = forEveryProfile(CatanRuleSet.BASE, 1_500)
fun `easy and hard seats of every style play the base game`() = forEveryProfile(emptySet(), 1_500)
@Test
fun `easy and hard seats of every style play Cities and Knights`() =
forEveryProfile(CatanRuleSet.CITIES_AND_KNIGHTS, 1_500)
forEveryProfile(setOf(CatanExpansion.CITIES_AND_KNIGHTS), 1_500)
@Test
fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(CatanRuleSet.SEAFARERS, 1_500)
fun `easy and hard seats of every style play Seafarers`() = forEveryProfile(setOf(CatanExpansion.SEAFARERS), 1_500)
@Test
fun `a mixed table of four finishes a base game`() {
@@ -95,8 +95,8 @@ class CatanAiProfilesTest {
@Test
fun `the default profile plays exactly as an explicit medium table does`() {
for (rules in listOf(CatanRuleSet.BASE, CatanRuleSet.CITIES_AND_KNIGHTS)) {
val options = CatanOptions(playerCount = 3, pointsToWin = 10, ruleSet = rules)
for (rules in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
val options = CatanOptions(playerCount = 3, pointsToWin = 10, expansions = rules)
val plain = play(options, 9L, AiProfiles.DEFAULT, 600)
val medium = play(options, 9L, AiProfiles(AiDifficulty.MEDIUM, 1234L), 600)
assertEquals("$rules", plain, medium)
@@ -25,7 +25,7 @@ class CatanCkRulesTest {
CatanOptions(
playerCount = players,
pointsToWin = 13,
ruleSet = if (ck) CatanRuleSet.CITIES_AND_KNIGHTS else CatanRuleSet.BASE,
expansions = if (ck) setOf(CatanExpansion.CITIES_AND_KNIGHTS) else emptySet(),
),
),
seed = seed,
@@ -138,7 +138,7 @@ class CatanCkRulesTest {
val base = CatanRules.initialState(config(ck = false))
assertNull(base.ck)
assertEquals(25, base.devDeck.size)
assertEquals(CatanRuleSet.BASE, base.options.ruleSet)
assertEquals(emptySet<CatanExpansion>(), base.options.expansions)
}
@Test
@@ -157,7 +157,21 @@ class CatanCkRulesTest {
fun `the options without a rule set still read as the base game`() {
val old = """{"playerCount":4,"pointsToWin":10}"""
val options = Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(old)
assertEquals(CatanRuleSet.BASE, options.ruleSet)
assertEquals(emptySet<CatanExpansion>(), options.expansions)
}
@Test
fun `options saved with a rule set still read as the expansion they named`() {
val read = { text: String -> Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(text) }
assertEquals(
setOf(CatanExpansion.CITIES_AND_KNIGHTS),
read("""{"playerCount":3,"pointsToWin":13,"ruleSet":"CITIES_AND_KNIGHTS"}""").expansions,
)
assertEquals(
setOf(CatanExpansion.SEAFARERS),
read("""{"playerCount":3,"pointsToWin":12,"ruleSet":"SEAFARERS"}""").expansions,
)
assertEquals(emptySet<CatanExpansion>(), read("""{"ruleSet":"BASE"}""").expansions)
}
@Test
@@ -152,7 +152,7 @@ class CatanCkSelfPlayTest {
fun `three engines play Cities and Knights to a finish`() {
val reports = (1L..12L).map { seed ->
val (state, report) = playOut(
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
CatanOptions(playerCount = 3, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
seed,
)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
@@ -171,7 +171,7 @@ class CatanCkSelfPlayTest {
fun `four engines play Cities and Knights to a finish`() {
val reports = (101L..108L).map { seed ->
val (state, report) = playOut(
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
CatanOptions(playerCount = 4, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
seed,
)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
@@ -115,8 +115,9 @@ class CatanRulesTest {
// -- Setup ---------------------------------------------------------------
@Test
fun `setup runs in snake order and then seat zero leads`() {
fun `setup runs in snake order from the first seat and then that seat leads`() {
var state = CatanRules.initialState(config())
val first = state.firstSeat
val order = mutableListOf<Int>()
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
val seat = CatanRules.currentSeat(state)!!
@@ -126,12 +127,21 @@ class CatanRulesTest {
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
)
}
assertEquals(listOf(0, 1, 2, 2, 1, 0), order)
val forward = (0 until 3).map { (first + it) % 3 }
assertEquals(forward + forward.reversed(), order)
assertEquals(CatanPhase.AWAIT_ROLL, state.phase)
assertEquals(0, state.turn)
assertEquals(first, state.turn)
assertEquals(1, state.turnNumber)
}
@Test
fun `the first seat comes from the seed and varies between tables`() {
val firsts = (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat }
assertEquals(firsts, (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat })
assertEquals(setOf(0, 1, 2), firsts.toSet())
assertEquals(firsts[0], CatanRules.initialState(config(seed = 1L)).turn)
}
@Test
fun `the second settlement earns one card from each adjacent land`() {
var state = CatanRules.initialState(config())
@@ -0,0 +1,125 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** What every Seafarers map owes the engine, and the shape of each one. */
class CatanSeaScenariosTest {
private val json = Json { encodeDefaults = true }
private fun options(scenario: SeaScenario, players: Int = 3) = CatanOptions(
playerCount = players,
pointsToWin = 12,
expansions = setOf(CatanExpansion.SEAFARERS),
scenario = scenario,
)
private fun config(options: CatanOptions, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun sizes(scenario: SeaScenario): List<Int> {
val topo = SeaBoards.topology(scenario)
return (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted()
}
@Test
fun `each map has the islands it is named for`() {
assertEquals(listOf(2, 3, 3, 21), sizes(SeaScenario.NEW_SHORES))
assertEquals(listOf(1, 1, 6, 6, 6, 6), sizes(SeaScenario.FOUR_ISLANDS))
assertEquals(listOf(1, 1, 1, 2, 25), sizes(SeaScenario.THROUGH_THE_DESERT))
}
@Test
fun `every map deals numbers, harbours and a pirate at sea, with the reds apart`() {
for (scenario in SeaScenario.entries) {
repeat(5) { i ->
val s = CatanRules.initialState(config(options(scenario), 300L + i))
val topo = s.topo
val deserts = s.terrains.count { it == Terrain.DESERT }
for (h in 0 until topo.HEX_COUNT) {
val land = s.terrains[h] != Terrain.SEA
assertEquals("$scenario hex $h", topo.land[h], land)
assertEquals("$scenario hex $h", land && s.terrains[h] != Terrain.DESERT, s.numbers[h] != 0)
}
assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS)
assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate])
assertEquals("$scenario harbours", 9, s.ports.size)
val reds = (0 until topo.HEX_COUNT).filter { s.numbers[it] == 6 || s.numbers[it] == 8 }
for (a in reds) for (b in reds) if (a < b) assertFalse("$scenario reds touch", topo.hexesAdjacent(a, b))
assertEquals(scenario, s.sea!!.scenario)
}
}
}
@Test
fun `four islands start on any main island and the islets earn the bonus`() {
val s = CatanRules.initialState(config(options(SeaScenario.FOUR_ISLANDS), 5L))
val sea = s.sea!!
assertEquals(4, sea.starts.size)
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
assertEquals(sea.starts.toSet(), moves.map { s.topo.vertexIsland[it.vertex] }.toSet())
for (island in 0 until s.topo.islandCount) assertEquals(island in sea.starts, sea.isStart(island))
// A settlement on a starting island scores no first-landing bonus.
val onMain = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] == sea.starts.first() }
assertEquals(s, SeaRules.claimIsland(s, 0, onMain))
val islet = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] >= 0 && s.topo.vertexIsland[it] !in sea.starts }
assertEquals(0, SeaRules.bonusPoints(s, 0))
assertEquals(0, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, onMain), 0))
assertEquals(SeaRules.ISLAND_BONUS, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, islet), 0))
}
@Test
fun `through the desert starts everyone on the one big island`() {
val s = CatanRules.initialState(config(options(SeaScenario.THROUGH_THE_DESERT), 5L))
assertEquals(listOf(s.sea!!.home), s.sea!!.starts)
assertEquals(25, s.topo.islandOf.count { it == s.sea!!.home })
assertTrue(s.terrains.count { it == Terrain.DESERT } >= 5)
}
@Test
fun `a save from before there were several starts still starts on the home island`() {
val s = CatanRules.initialState(config(options(SeaScenario.NEW_SHORES), 5L))
val old = s.copy(sea = s.sea!!.copy(starts = emptyList()))
assertTrue(old.sea!!.isStart(old.sea!!.home))
val moves = CatanRules.legalMoves(old, old.firstSeat).filterIsInstance<SetupPlace>()
assertTrue(moves.all { old.topo.vertexIsland[it.vertex] == old.sea!!.home })
}
@Test
fun `engines finish a game on every map at three and four players`() {
for (scenario in SeaScenario.entries) {
for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) {
val opts = options(scenario, players)
val ai = CatanAi()
var state = CatanRules.initialState(config(opts, seed))
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 20_000)
val seat = CatanRules.currentSeat(state) ?: error("$scenario seed $seed: nobody to move in ${state.phase}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("$scenario seed $seed: no legal move for $seat in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue("$scenario seed $seed: illegal $move", CatanRules.isLegalMove(state, seat, move))
state = CatanRules.applyMove(state, seat, move)
}
assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase)
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
}
}
}
@@ -17,7 +17,7 @@ class CatanSeafarersTest {
private val json = Json { encodeDefaults = true }
private fun options(players: Int = 3) =
CatanOptions(playerCount = players, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
CatanOptions(playerCount = players, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS))
private fun config(options: CatanOptions, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
@@ -96,7 +96,7 @@ class CatanSeafarersTest {
@Test
fun `setup places both settlements on the home island, with roads rather than ships`() {
val s = start()
val moves = CatanRules.legalMoves(s, 0).filterIsInstance<SetupPlace>()
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
assertTrue(moves.isNotEmpty())
assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home })
assertTrue(moves.all { s.topo.edgeLand[it.edge] })
@@ -171,7 +171,7 @@ class SaveRoundTripAllGamesTest {
GameCatalog.CATAN,
CatanRules,
CatanAi(),
opts(CatanOptions(3, 13, org.prolibertate.games.game.catan.CatanRuleSet.CITIES_AND_KNIGHTS)),
opts(CatanOptions(3, 13, setOf(org.prolibertate.games.game.catan.CatanExpansion.CITIES_AND_KNIGHTS))),
steps = 600,
)
}
@@ -21,7 +21,7 @@ import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
@@ -69,7 +69,7 @@ class CatanCkInteractionTest {
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
CatanOptions(players, pointsToWin = 13, expansions = setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
),
seed = seed,
)
@@ -8,7 +8,7 @@ import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.engine.PlayerKind
@@ -16,7 +16,7 @@ import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.ui.catanDefaultPoints
import org.prolibertate.games.ui.catanPointsChoices
import org.prolibertate.games.ui.catanSwitchRules
import org.prolibertate.games.ui.catanToggleExpansion
class CatanCkTextTest {
@@ -28,7 +28,7 @@ class CatanCkTextTest {
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
CatanOptions(3, 13, setOf(CatanExpansion.CITIES_AND_KNIGHTS)),
),
seed = 31L,
)
@@ -56,20 +56,24 @@ class CatanCkTextTest {
@Test
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(emptySet()))
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(setOf(CatanExpansion.CITIES_AND_KNIGHTS)))
assertEquals(10, catanDefaultPoints(emptySet()))
assertEquals(13, catanDefaultPoints(setOf(CatanExpansion.CITIES_AND_KNIGHTS)))
val base = CatanOptions()
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
val ck = catanToggleExpansion(base, CatanExpansion.CITIES_AND_KNIGHTS, true)
assertEquals(setOf(CatanExpansion.CITIES_AND_KNIGHTS), ck.expansions)
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
assertEquals(10, catanToggleExpansion(ck, CatanExpansion.CITIES_AND_KNIGHTS, false).pointsToWin)
// A short game picked on purpose is kept when both menus have it.
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
val short = catanToggleExpansion(base.copy(pointsToWin = 8), CatanExpansion.CITIES_AND_KNIGHTS, true)
assertEquals(8, short.pointsToWin)
assertEquals(3, ck.playerCount)
// Expansions cannot be combined yet: switching one on switches the other off.
val sea = catanToggleExpansion(ck, CatanExpansion.SEAFARERS, true)
assertEquals(setOf(CatanExpansion.SEAFARERS), sea.expansions)
}
}
@@ -28,7 +28,8 @@ internal object CatanFixtures {
/** A fresh table, on the first setup placement. */
fun start(players: Int = 3, seed: Long = 7L): CatanState =
CatanRules.initialState(config(players, seed))
// These tests are about the screen, not who starts: pin seat 0 first.
CatanRules.initialState(config(players, seed)).let { it.copy(firstSeat = 0, turn = 0) }
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
fun playing(
@@ -12,7 +12,7 @@ import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanBuilding
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanExpansion
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.MovePirate
@@ -29,7 +29,7 @@ import org.prolibertate.games.game.engine.TableConfig
class CatanSeafarersInteractionTest {
private fun fresh(): CatanState {
val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
val options = CatanOptions(playerCount = 3, pointsToWin = 12, expansions = setOf(CatanExpansion.SEAFARERS))
return CatanRules.initialState(
TableConfig(
gameId = GameCatalog.CATAN,
+18 -8
View File
@@ -1,20 +1,30 @@
# Katan: Seafarers
The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. It is the base
Seafarers is an expansion switched on at setup (`CatanOptions.expansions`; the old single `ruleSet` in saves is still read) and its map is chosen beside it (`CatanOptions.scenario`). It is the base
game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to
settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map),
`CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea`
(null in the other rule sets). It cannot be combined with Cities & Knights.
(null in the other rule sets). It cannot yet be combined with Cities & Knights; the setup screen switches one off when the other is turned on.
Where the published rules leave room, or where this game differs, it says so below.
## The map
## The maps
One map, *Heading for New Shores*: a large **home island** (21 hexes, one desert) and three small
islands (3, 3 and 2 hexes) across the water, inside a ring of sea. Which land hexes are gold, which
are the desert and where the sea lies is fixed; the **resources on the other hexes, the number tokens
Each map is a fixed arrangement of land and sea; the **resources on the plain hexes, the number tokens
and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring
hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14).
Pick the map at setup; each scenario names where the starting settlements may go (`SeaStart`).
* **Heading for New Shores** (default). A large home island (21 hexes, one desert) and three small islands (3, 3
and 2 hexes) across the water, with gold fields on two of the small ones. Start on the home island.
* **Four Islands.** Four equal islands (six hexes each) and two one-hex gold islets between them. Each player
may start on **any** of the four main islands, so a table can spread out or crowd one; the islets are the
prize for the first ship to arrive.
* **Through the Desert.** One 25-hex island cut across by a broad desert (seven hexes that pay nothing and
hold the robber), with three islets and gold to sail to. Everyone starts on the island. The desert is
land, so a road can cross it, but nothing beside it pays, which is what makes the far side a race.
A *new island* for the bonus is any island that is not a starting island for that map.
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching
@@ -60,8 +70,8 @@ Everything else (development cards, trading, harbours, Largest Army, the discard
The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**:
fog islands, treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations,
the scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map
is a new text map and a new enum entry.
the scenarios that need new rules (fog, cloth, wonders, the forgotten tribe, pirate islands) and trade ships.
A map-only scenario is a new text map and a new `SeaScenario` entry.
## How the AI plays it