Compare commits

..
Author SHA1 Message Date
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
16 changed files with 206 additions and 152 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)
}
}
@@ -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]. */
@@ -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,
@@ -121,8 +126,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 }
@@ -512,9 +518,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)
@@ -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())
@@ -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,
+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.