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
This commit is contained in:
Claude
2026-11-11 16:50:24 +00:00
parent f8c04cec88
commit 6c311ad304
13 changed files with 165 additions and 86 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
@@ -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)
}
}
@@ -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) {
@@ -59,7 +59,8 @@ import org.prolibertate.games.game.morris.MorrisOptions
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
@@ -373,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."
}
@@ -421,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),
@@ -438,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)) },
@@ -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)
@@ -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,
@@ -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)
}
}
@@ -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,
+2 -2
View File
@@ -1,10 +1,10 @@
# 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.