Katan: Cities & Knights engine, AI and rules tests

A second rule set chosen at setup (CatanRuleSet): commodities, knights, walls,
barbarians and the event die, city improvements and metropolises, and the
54 progress cards. Everything lives behind CatanState.ck, so the base game's
state and rules are untouched. Multi-seat decisions run through one chore queue.

Also wraps three multi-part log lines so each event is one entry.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
This commit is contained in:
Claude
2026-10-17 14:14:25 +00:00
parent 12fec14034
commit d88083679d
9 changed files with 3563 additions and 44 deletions
@@ -20,10 +20,14 @@ import org.prolibertate.games.game.engine.GameAi
*/
class CatanAi : GameAi<CatanState, CatanMove> {
/** Plays the Cities & Knights tables; this class keeps the base game. */
private val ck = CatanCkAi(this)
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
"No legal move for seat $seat"
}
if (state.isCk) return ck.choose(state, seat, legal)
return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
@@ -40,8 +44,8 @@ class CatanAi : GameAi<CatanState, CatanMove> {
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("The game is over; nobody chooses a move")
CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
}
@@ -49,7 +53,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// Setup
// -----------------------------------------------------------------------
private fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
internal fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
require(places.isNotEmpty()) { "No legal settlement placement" }
return places.maxByOrNull { place ->
spotValue(state, place.vertex) +
@@ -276,7 +280,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// -----------------------------------------------------------------------
/** What a seat would think of building on [vertex]: production and ports. */
private fun spotValue(state: CatanState, vertex: Int): Double {
internal fun spotValue(state: CatanState, vertex: Int): Double {
if (state.buildings[vertex] != null) return -1.0
var value = 0.0
val kinds = mutableSetOf<Resource>()
@@ -297,7 +301,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* What a road on this edge leads to: the best settlement spot it brings
* within reach, discounted for being one step further out.
*/
private fun roadValue(state: CatanState, move: BuildRoad): Double {
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
var best = 0.0
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
for (endpoint in listOf(from, to)) {
@@ -309,7 +313,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
}
/** How much the AI wants to hold one card of [resource]. */
private fun want(resource: Resource): Double = when (resource) {
internal fun want(resource: Resource): Double = when (resource) {
Resource.ORE -> 1.20 // the city
Resource.GRAIN -> 1.15 // the city and the dev cards
Resource.LUMBER -> 1.0 // every road and settlement
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
package org.prolibertate.games.game.catan
/**
* The computer opponent for Cities & Knights, reached through [CatanAi].
*
* It plays the same cautious game as the base AI — settle well, raise cities —
* and adds the three things this rule set asks of a player: keep enough active
* knights standing when the barbarians land, spend commodities on improvements
* as soon as it can, and answer every chore the table raises with the card it
* values least. It reads only what a player at the table could see, plus its
* own hand.
*
* It is not a strong player. It never moves knights about (they stay where
* hired, and are activated when the barbarians draw near), never plays the
* progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat,
* Deserter, Intrigue), and makes no domestic offers, though it answers them.
*/
internal class CatanCkAi(private val ai: CatanAi) {
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove = when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
CatanPhase.CHORE -> chooseChore(state, seat, legal)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
// -----------------------------------------------------------------------
// Chores
// -----------------------------------------------------------------------
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val ck = state.ck!!
return when (val chore = ck.chores.first()) {
is DiscardChore -> {
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
DiscardGoods(CatanCk.goodsList(state, seat).sortedBy { worth(state, seat, it) }.take(n))
}
is GiveChore -> {
val pool = CatanCk.goodsList(state, seat)
.filter { !chore.commoditiesOnly || it.isCommodity }
.sortedBy { worth(state, seat, it) }
GiveGoods(pool.take(minOf(chore.count, pool.size)))
}
is PillageChore ->
legal.filterIsInstance<PillageCity>().minByOrNull { production(state, it.vertex) } ?: legal.first()
is PickDeckChore -> {
val picks = legal.filterIsInstance<PickDeck>()
picks.maxByOrNull { CatanCk.improvement(state, seat, it.track) } ?: legal.first()
}
is DropProgressChore ->
legal.filterIsInstance<DropProgress>().minByOrNull { drop ->
ck.progressHands[seat].first { it.id == drop.card }.kind.let(::cardWorth)
} ?: legal.first()
is MetropolisChore ->
legal.filterIsInstance<PlaceMetropolis>().maxByOrNull { production(state, it.vertex) }
?: legal.first()
is AqueductChore ->
legal.filterIsInstance<TakeAqueduct>().maxByOrNull { ai.want(it.resource) } ?: legal.first()
is RelocateChore -> legal.first()
is RemoveKnightChore ->
legal.filterIsInstance<RemoveKnight>().minByOrNull {
CatanCk.knightAt(state, it.vertex)?.level ?: 0
} ?: legal.first()
is PlaceKnightChore -> legal.first()
is SpyChore -> legal.first()
is SmithChore ->
legal.filterIsInstance<PromoteKnight>().firstOrNull() ?: legal.first { it is EndSmith }
}
}
// -----------------------------------------------------------------------
// The robber
// -----------------------------------------------------------------------
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val hex = CatanBoard.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
score += if (building.owner == seat) -worth * odds else worth * odds
}
if (move.victim != null) {
score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim]
}
score
} ?: moves.first()
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val ck = state.ck!!
val soon = ck.barbarians <= 3
// Chase the robber off a hex it is squatting on.
if (ck.attacked) {
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
if (onMine != null && robberHurtsMe) return onMine
}
// The cheap upgrade first, then the full-price one.
legal.filterIsInstance<PlayProgress>()
.filter { kindOf(state, seat, it) == ProgressType.MEDICINE }
.maxByOrNull { production(state, it.vertex!!) }
?.let { return it }
legal.filterIsInstance<BuildCity>().maxByOrNull { production(state, it.vertex) }
?.let { return it }
legal.filterIsInstance<BuildSettlement>().maxByOrNull { ai.spotValue(state, it.vertex) }
?.let { return it }
// Improvements spend cards nothing else needs. Favour the track furthest along.
legal.filterIsInstance<BuyImprovement>()
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
?.let { return it }
// Knights: stand ready by the time the barbarians land.
defence(state, seat, legal, soon)?.let { return it }
progress(state, seat, legal)?.let { return it }
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
if (CatanCk.goodsCount(state, seat) >= 7) return it
}
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.1 }
.maxByOrNull { ai.roadValue(state, it) }
?.let { return it }
legal.filterIsInstance<GoodsBankTrade>()
.filter { completes(state, seat, it) }
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
?.let { return it }
return legal.lastOrNull { it is EndTurn } ?: legal.first()
}
/** Hire, promote and activate so the knights are standing when the barbarians land. */
private fun defence(state: CatanState, seat: Int, legal: List<CatanMove>, soon: Boolean): CatanMove? {
val mine = CatanCk.knightsOf(state, seat)
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
val want = if (soon) 4 else 2
if (mine.size < want) {
legal.filterIsInstance<HireKnight>().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire ->
// Keep a grain back for waking it, once the ship is close.
if (!soon || grainLeft >= 1) return hire
}
}
if (soon || mine.size >= 2) {
legal.filterIsInstance<PromoteKnight>().firstOrNull()?.let { return it }
}
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
return null
}
/** Progress cards worth playing without a plan. */
private fun progress(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove? {
val ck = state.ck!!
val soon = ck.barbarians <= 4
val plays = legal.filterIsInstance<PlayProgress>()
fun kindOf(play: PlayProgress) = ck.progressHands[seat].first { it.id == play.card }.kind
fun first(kind: ProgressType) = plays.firstOrNull { kindOf(it) == kind }
first(ProgressType.IRRIGATION)?.let { return it }
first(ProgressType.MINING)?.let { return it }
first(ProgressType.ENGINEER)?.let { return it }
first(ProgressType.SMITH)?.let { return it }
first(ProgressType.WEDDING)?.let { return it }
if (soon) first(ProgressType.WARLORD)?.let { return it }
plays.filter { kindOf(it) == ProgressType.MASTER_MERCHANT }
.maxByOrNull { play -> play.goods.orEmpty().sumOf { worth(state, seat, it) } }
?.let { return it }
first(ProgressType.SPY)?.let { return it }
first(ProgressType.BISHOP)?.let { return it }
if (legal.filterIsInstance<BuildRoad>().size >= 2) first(ProgressType.ROAD_BUILDING)?.let { return it }
// Crane: only when it makes the difference to an improvement.
first(ProgressType.CRANE)?.let { crane ->
val helps = Track.entries.any { track ->
val level = CatanCk.improvement(state, seat, track)
val have = ck.commodities[seat][track.commodity] ?: 0
level < IMPROVEMENT_MAX && have == level // one short of the next level's price
}
if (helps) return crane
}
// Saboteur: when someone level or ahead is sitting on a pile.
first(ProgressType.SABOTEUR)?.let { saboteur ->
val vp = CatanRules.publicVictoryPoints(state)
val pile = (0 until state.playerCount).filter { it != seat && vp[it] >= vp[seat] }
.sumOf { CatanCk.goodsCount(state, it) / 2 }
if (pile >= 4) return saboteur
}
// Monopolies: only when the table is holding a pile of it.
plays.filter { kindOf(it) == ProgressType.RESOURCE_MONOPOLY || kindOf(it) == ProgressType.TRADE_MONOPOLY }
.maxByOrNull { held(state, seat, it.good!!) }
?.let { best ->
val each = if (kindOf(best) == ProgressType.RESOURCE_MONOPOLY) 2 else 1
val take = (0 until state.playerCount).filter { it != seat }
.sumOf { minOf(each, CatanCk.goodsOf(state, it)[best.good!!] ?: 0) }
if (take >= 3) return best
}
// The merchant, on the best hex beside a building of ours.
plays.filter { kindOf(it) == ProgressType.MERCHANT }
.maxByOrNull { play ->
val hex = play.hexA!!
CatanBoard.numberOdds(state.numbers[hex]).toDouble()
}?.let { return it }
// A commercial harbour turns spare resources into commodities.
plays.filter { kindOf(it) == ProgressType.COMMERCIAL_HARBOR }
.maxByOrNull { held(state, seat, it.good!!) }
?.let { if (held(state, seat, it.good!!) >= 2) return it }
return null
}
private fun kindOf(state: CatanState, seat: Int, play: PlayProgress): ProgressType =
state.ck!!.progressHands[seat].first { it.id == play.card }.kind
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
private fun production(state: CatanState, vertex: Int): Double =
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
}
private fun held(state: CatanState, seat: Int, good: Good): Int = CatanCk.goodsOf(state, seat)[good] ?: 0
/** Whether the trade leaves a hand that can pay for a city, a settlement or an improvement. */
private fun completes(state: CatanState, seat: Int, trade: GoodsBankTrade): Boolean {
val hand = CatanCk.goodsOf(state, seat).toMutableMap()
hand[trade.give] = (hand[trade.give] ?: 0) - CatanCk.bankRatio(state, seat, trade.give)
hand[trade.get] = (hand[trade.get] ?: 0) + 1
fun covers(cost: Map<Resource, Int>) = cost.all { (r, n) -> (hand[Good.of(r)] ?: 0) >= n }
if (covers(CatanRules.CITY_COST) || covers(CatanRules.SETTLEMENT_COST)) return true
return Track.entries.any { track ->
val level = CatanCk.improvement(state, seat, track)
level < IMPROVEMENT_MAX &&
(hand[Good.of(track.commodity)] ?: 0) >= level + 1 &&
(state.ck!!.commodities[seat][track.commodity] ?: 0) < level + 1
}
}
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove =
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.05 }
.maxByOrNull { ai.roadValue(state, it) }
?: legal.firstOrNull { it is EndFreeRoads }
?: legal.first()
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val decline = legal.first { it is DeclineTrade }
val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline
val offer = state.ck?.goodsOffer
if (offer != null) {
val incoming = offer.give.sumOf { worth(state, seat, it) }
val outgoing = offer.get.sumOf { worth(state, seat, it) }
return if (incoming > outgoing) accept else decline
}
val plain = state.pendingTrade ?: return decline
val incoming = plain.give.sumOf { ai.want(it) }
val outgoing = plain.get.sumOf { ai.want(it) }
return if (incoming > outgoing) accept else decline
}
// -----------------------------------------------------------------------
// Valuations
// -----------------------------------------------------------------------
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
private fun worth(state: CatanState, seat: Int, good: Good): Double =
good.resource?.let { ai.want(it) } ?: when (good) {
Good.PAPER -> 0.95
Good.COIN -> 0.9
else -> 0.85
}
/** Which track to lean toward when two are level: politics for the knights, then science. */
private fun trackLean(track: Track): Int = when (track) {
Track.POLITICS -> 2
Track.SCIENCE -> 1
Track.TRADE -> 0
}
private fun cardWorth(kind: ProgressType): Double = when (kind) {
ProgressType.INVENTOR, ProgressType.ALCHEMIST, ProgressType.DIPLOMAT,
ProgressType.DESERTER, ProgressType.INTRIGUE, ProgressType.MERCHANT_FLEET,
-> 0.0
ProgressType.MERCHANT, ProgressType.COMMERCIAL_HARBOR, ProgressType.CRANE -> 0.5
else -> 1.0
}
}
@@ -0,0 +1,460 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/*
* The data of the Cities & Knights rule set. The rules themselves live in
* [CatanCk]; docs/RULES-catan-ck.md describes them and the choices made.
*
* Everything here is reached through [CatanState.ck], which is null for the
* base game, so a base game never sees any of it.
*/
/** Steps the barbarian ship must make before it lands. */
const val BARBARIAN_STEPS = 7
/** Commodity cards of each type the bank starts with (resources have 19). */
const val BANK_PER_COMMODITY = 12
/** Knights of each strength one player owns. */
const val KNIGHTS_PER_LEVEL = 2
/** City walls one player owns. */
const val MAX_WALLS = 3
/** Levels of city improvement in each track. */
const val IMPROVEMENT_MAX = 5
/** Progress cards a player may keep in hand. */
const val PROGRESS_HAND_MAX = 4
/** Cards a player may hold through a seven, before walls. */
const val BASE_HAND_LIMIT = 7
/** Longest Road is worth two points here; there is no Largest Army. */
const val CK_LONGEST_ROAD_VP = 2
/** Improvement level that unlocks the track's special power. */
const val IMPROVEMENT_POWER_LEVEL = 3
/** Level at which a track can claim its metropolis, and the level that can steal one. */
const val METROPOLIS_LEVEL = 4
/**
* What the three cities-of-industry give in place of a second resource:
* forest gives paper, pasture cloth and mountains coin.
*/
@Serializable
enum class Commodity { CLOTH, COIN, PAPER }
/**
* Any card a player can hold in hand and trade: a resource or a commodity.
* Progress cards are not goods.
*/
@Serializable
enum class Good {
LUMBER, BRICK, WOOL, GRAIN, ORE, CLOTH, COIN, PAPER;
val resource: Resource?
get() = when (this) {
LUMBER -> Resource.LUMBER
BRICK -> Resource.BRICK
WOOL -> Resource.WOOL
GRAIN -> Resource.GRAIN
ORE -> Resource.ORE
else -> null
}
val commodity: Commodity?
get() = when (this) {
CLOTH -> Commodity.CLOTH
COIN -> Commodity.COIN
PAPER -> Commodity.PAPER
else -> null
}
val isCommodity: Boolean get() = commodity != null
companion object {
fun of(resource: Resource): Good = when (resource) {
Resource.LUMBER -> LUMBER
Resource.BRICK -> BRICK
Resource.WOOL -> WOOL
Resource.GRAIN -> GRAIN
Resource.ORE -> ORE
}
fun of(commodity: Commodity): Good = when (commodity) {
Commodity.CLOTH -> CLOTH
Commodity.COIN -> COIN
Commodity.PAPER -> PAPER
}
val RESOURCES: List<Good> = Resource.entries.map { of(it) }
val COMMODITIES: List<Good> = Commodity.entries.map { of(it) }
}
}
/**
* The three tracks of city improvement. Each is bought with one commodity, has
* a colour of progress-card deck and a power that unlocks at level three.
*/
@Serializable
enum class Track(val commodity: Commodity) {
/** Yellow, bought with cloth: trade commodities two for one. */
TRADE(Commodity.CLOTH),
/** Blue, bought with coin: knights may become mighty. */
POLITICS(Commodity.COIN),
/** Green, bought with paper: the aqueduct. */
SCIENCE(Commodity.PAPER),
}
/**
* One face of the event die: a ship, or a city gate of a track's colour.
* Three faces are ships.
*/
@Serializable
enum class EventFace { SHIP, TRADE, POLITICS, SCIENCE }
/** The progress cards: [copies] of each in the deck of its [track]. */
@Serializable
enum class ProgressType(val track: Track, val copies: Int, val scoresAtOnce: Boolean = false) {
// Science (green)
ALCHEMIST(Track.SCIENCE, 2),
CRANE(Track.SCIENCE, 2),
ENGINEER(Track.SCIENCE, 1),
INVENTOR(Track.SCIENCE, 2),
IRRIGATION(Track.SCIENCE, 2),
MEDICINE(Track.SCIENCE, 2),
MINING(Track.SCIENCE, 2),
PRINTER(Track.SCIENCE, 1, scoresAtOnce = true),
ROAD_BUILDING(Track.SCIENCE, 2),
SMITH(Track.SCIENCE, 2),
// Politics (blue)
BISHOP(Track.POLITICS, 2),
CONSTITUTION(Track.POLITICS, 1, scoresAtOnce = true),
DESERTER(Track.POLITICS, 2),
DIPLOMAT(Track.POLITICS, 2),
INTRIGUE(Track.POLITICS, 2),
SABOTEUR(Track.POLITICS, 2),
SPY(Track.POLITICS, 3),
WARLORD(Track.POLITICS, 2),
WEDDING(Track.POLITICS, 2),
// Trade (yellow)
COMMERCIAL_HARBOR(Track.TRADE, 2),
MASTER_MERCHANT(Track.TRADE, 2),
MERCHANT(Track.TRADE, 6),
MERCHANT_FLEET(Track.TRADE, 2),
RESOURCE_MONOPOLY(Track.TRADE, 4),
TRADE_MONOPOLY(Track.TRADE, 2),
}
/** One progress card; [id] is stable for the life of the game. */
@Serializable
data class ProgressCard(val id: Int, val kind: ProgressType)
/**
* A knight on the board. [level] is its strength: 1 basic, 2 strong, 3 mighty.
*
* [activatedTurn] and [promotedTurn] record the turn number each happened on:
* a knight cannot act on the turn it was activated, and cannot be promoted twice
* in one turn.
*/
@Serializable
data class Knight(
val vertex: Int,
val owner: Int,
val level: Int,
val active: Boolean = false,
val activatedTurn: Int = 0,
val promotedTurn: Int = 0,
)
/** A metropolis stands on one city: [vertex] of seat [seat]. */
@Serializable
data class MetropolisAt(val seat: Int, val vertex: Int)
/** The merchant sits on a hex, controlled by [seat] (who holds its point). */
@Serializable
data class MerchantAt(val seat: Int, val hex: Int)
/** A domestic trade offer in goods, which may include commodities. */
@Serializable
data class GoodsOffer(val by: Int, val to: Int, val give: List<Good>, val get: List<Good>)
/** Where play goes when the queue of [Chore]s runs out. */
@Serializable
enum class Back { PLAYING, AWAIT_ROLL, AFTER_EVENT, AFTER_DISCARDS, AFTER_PRODUCTION }
/**
* Something one seat must decide before the game can go on. A chore is queued
* when a roll, an attack or a card needs an answer from somebody (possibly not
* the player whose turn it is); the first in [CkState.chores] is the one being
* asked.
*/
@Serializable
sealed interface Chore {
val seat: Int
}
/** Give [count] cards to the bank, of the seat's choosing (a seven, a saboteur). */
@Serializable
@SerialName("c_discard")
data class DiscardChore(override val seat: Int, val count: Int) : Chore
/**
* Hand [count] cards of the seat's choosing to seat [to] (a wedding, a
* commercial harbour). [commoditiesOnly] restricts them to commodities, and
* [reward] is the resource [to] gives back for each.
*/
@Serializable
@SerialName("c_give")
data class GiveChore(
override val seat: Int,
val count: Int,
val to: Int,
val commoditiesOnly: Boolean = false,
val reward: Good? = null,
) : Chore
/** The barbarians won and this seat lost the contest: pick a city to fall. */
@Serializable
@SerialName("c_pillage")
data class PillageChore(override val seat: Int) : Chore
/** Choose a colour of progress deck to draw from. */
@Serializable
@SerialName("c_pickDeck")
data class PickDeckChore(override val seat: Int) : Chore
/** More progress cards than the hand holds: give one back. */
@Serializable
@SerialName("c_dropProgress")
data class DropProgressChore(override val seat: Int) : Chore
/** Choose the city that will bear the [track] metropolis. */
@Serializable
@SerialName("c_metropolis")
data class MetropolisChore(override val seat: Int, val track: Track) : Chore
/** The aqueduct pays out: take any one resource. */
@Serializable
@SerialName("c_aqueduct")
data class AqueductChore(override val seat: Int) : Chore
/** A knight was driven from its place; move it, or lose it. [knight] stood on [Knight.vertex]. */
@Serializable
@SerialName("c_relocate")
data class RelocateChore(override val seat: Int, val knight: Knight) : Chore
/** A deserter was played against this seat: remove one of your knights. */
@Serializable
@SerialName("c_removeKnight")
data class RemoveKnightChore(override val seat: Int, val by: Int) : Chore
/** Place a knight of [level] on the board for free, active or not as given. */
@Serializable
@SerialName("c_placeKnight")
data class PlaceKnightChore(override val seat: Int, val level: Int, val active: Boolean) : Chore
/** Choose one of [target]'s progress cards to take. */
@Serializable
@SerialName("c_spy")
data class SpyChore(override val seat: Int, val target: Int) : Chore
/** Promote up to [left] more knights for free. */
@Serializable
@SerialName("c_smith")
data class SmithChore(override val seat: Int, val left: Int) : Chore
/**
* Everything Cities & Knights adds to the table.
*
* Counts that are public ([progressCounts], the knights, the improvement
* levels) are stored directly; the decks and the other seats' progress hands are
* here in full because the host owns the truth, and [CatanRules.viewFor] keeps
* them off the wire.
*/
@Serializable
data class CkState(
/** Commodity cards per seat. */
val commodities: List<Map<Commodity, Int>>,
/** Improvement level (0..5) per seat and track. */
val improvements: List<Map<Track, Int>>,
/** The metropolis of each track that has one. */
val metropolises: Map<Track, MetropolisAt> = emptyMap(),
val knights: List<Knight> = emptyList(),
/** Vertices whose city has a wall. */
val walls: List<Int> = emptyList(),
/** Steps left before the barbarians land, [BARBARIAN_STEPS] at the start. */
val barbarians: Int = BARBARIAN_STEPS,
/** Whether the barbarians have landed once: until then the robber sleeps. */
val attacked: Boolean = false,
val eventDie: EventFace? = null,
/** Progress decks, top first. Order is hidden. */
val decks: Map<Track, List<ProgressCard>>,
val progressHands: List<List<ProgressCard>>,
/** Public mirror of the progress-hand sizes. */
val progressCounts: List<Int>,
/** Victory points from Constitution and Printer, played face up at once. */
val progressVp: List<Int>,
/** Defender of Catan cards won, one point each. */
val defenderVp: List<Int>,
val merchant: MerchantAt? = null,
/** A crane was played this turn and its discount is unspent. */
val crane: Boolean = false,
/** A merchant fleet was played this turn for this good. */
val fleet: Good? = null,
/** Dice named by an alchemist, used by the next roll. */
val alchemist: List<Int>? = null,
/** The robber is being moved by a bishop, which robs everyone nearby. */
val bishop: Boolean = false,
val chores: List<Chore> = emptyList(),
val back: Back = Back.PLAYING,
val goodsOffer: GoodsOffer? = null,
/** Seats that have already drawn a defender or winners' card this attack, for the log only. */
val lastAttack: String? = null,
)
// ---------------------------------------------------------------------------
// Moves
// ---------------------------------------------------------------------------
/** Buy the next level of improvement in [track]. */
@Serializable
@SerialName("buyImprovement")
data class BuyImprovement(val track: Track) : CatanMove
/** Hire a basic knight onto [vertex], an empty crossing at the end of one of your roads. */
@Serializable
@SerialName("hireKnight")
data class HireKnight(val vertex: Int) : CatanMove
/** Pay a grain to activate the knight on [vertex]. */
@Serializable
@SerialName("activateKnight")
data class ActivateKnight(val vertex: Int) : CatanMove
/** Promote the knight on [vertex] one level (paid, or free under a Smith). */
@Serializable
@SerialName("promoteKnight")
data class PromoteKnight(val vertex: Int) : CatanMove
/** Move the active knight on [from] along your roads to [to], driving off a weaker knight there. */
@Serializable
@SerialName("moveKnight")
data class MoveKnight(val from: Int, val to: Int) : CatanMove
/** The active knight on [vertex] chases the robber; the robber move follows. */
@Serializable
@SerialName("chaseRobber")
data class ChaseRobber(val vertex: Int) : CatanMove
/** Raise a wall around the city on [vertex]. */
@Serializable
@SerialName("buildWall")
data class BuildWall(val vertex: Int) : CatanMove
/**
* Play a progress card. Which of the optional arguments are used depends on
* the card (see [CatanCk]); the engine's legal list names every combination it
* accepts, so a client only ever chooses among them.
*/
@Serializable
@SerialName("progress")
data class PlayProgress(
val card: Int,
val target: Int? = null,
val vertex: Int? = null,
val edge: Int? = null,
val hexA: Int? = null,
val hexB: Int? = null,
val good: Good? = null,
val dice: List<Int>? = null,
val goods: List<Good>? = null,
) : CatanMove
/**
* Trade [give] to the bank for [get], at the best ratio this seat has for the
* card it gives. Replaces [MaritimeTrade] in Cities & Knights, where
* commodities trade too.
*/
@Serializable
@SerialName("goodsBank")
data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove
/**
* Offer a trade in goods to seat [to]. Free-form like [OfferTrade], so it is
* checked structurally and is not in the enumerated move list.
*/
@Serializable
@SerialName("offerGoods")
data class OfferGoods(val to: Int, val give: List<Good>, val get: List<Good>) : CatanMove
/**
* Give up exactly the cards named, to a [DiscardChore]. Structural, not
* enumerated; the legal list carries one example so a seat is never without a move.
*/
@Serializable
@SerialName("discardGoods")
data class DiscardGoods(val cards: List<Good>) : CatanMove
/** Hand over the cards named to a [GiveChore]. Structural, like [DiscardGoods]. */
@Serializable
@SerialName("giveGoods")
data class GiveGoods(val cards: List<Good>) : CatanMove
/** Let the city on [vertex] fall to the barbarians. */
@Serializable
@SerialName("pillageCity")
data class PillageCity(val vertex: Int) : CatanMove
/** Draw from the deck of this track's colour. */
@Serializable
@SerialName("pickDeck")
data class PickDeck(val track: Track) : CatanMove
/** Give up one progress card from a hand that is over the limit. */
@Serializable
@SerialName("dropProgress")
data class DropProgress(val card: Int) : CatanMove
/** Put the metropolis on the city at [vertex]. */
@Serializable
@SerialName("placeMetropolis")
data class PlaceMetropolis(val vertex: Int) : CatanMove
/** Take one [resource] from the bank for an aqueduct. */
@Serializable
@SerialName("aqueduct")
data class TakeAqueduct(val resource: Resource) : CatanMove
/** Move the knight that was driven off to [to]. */
@Serializable
@SerialName("relocateKnight")
data class RelocateKnight(val to: Int) : CatanMove
/** Take the knight on [vertex] off the board, for a deserter. */
@Serializable
@SerialName("removeKnight")
data class RemoveKnight(val vertex: Int) : CatanMove
/** Place the free knight from a deserter on [vertex]. */
@Serializable
@SerialName("placeKnight")
data class PlaceKnight(val vertex: Int) : CatanMove
/** Take the spied progress card with this id. */
@Serializable
@SerialName("spyTake")
data class SpyTake(val card: Int) : CatanMove
/** Stop promoting knights under a Smith. */
@Serializable
@SerialName("endSmith")
data object EndSmith : CatanMove
@@ -113,12 +113,30 @@ enum class CatanPhase {
TRADE_PENDING,
GAME_OVER,
/**
* Cities & Knights only: the first seat in [CkState.chores] has something
* to decide (a discard, a city to give up, a card to draw…) and the game
* waits on it. Play resumes where [CkState.back] says.
*/
CHORE,
}
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
@Serializable
enum class CatanRuleSet {
/** The base game: development cards, Largest Army, a robber from the start. */
BASE,
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS,
}
@Serializable
data class CatanOptions(
val playerCount: Int = 3,
val pointsToWin: Int = 10,
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
) {
init {
require(playerCount in 3..4) { "The island seats three or four players" }
@@ -190,9 +208,14 @@ data class CatanState(
val rngStep: Int,
val winner: Int?,
val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null,
) {
val playerCount: Int get() = options.playerCount
/** Whether this table plays Cities & Knights. */
val isCk: Boolean get() = ck != null
/** Total resource cards held by [seat]. */
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
@@ -227,7 +250,11 @@ data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
*/
@Serializable
@SerialName("roll")
data class RollDice(val forced: List<Int>? = null) : CatanMove
data class RollDice(
val forced: List<Int>? = null,
/** Test-only, like [forced]: the event die of a Cities & Knights roll. */
val event: EventFace? = null,
) : CatanMove
/** Discard exactly these cards. */
@Serializable
@@ -57,9 +57,16 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val board = 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 devDeck = DEV_DECK
.mapIndexed { id, type -> DevCard(id, type) }
.shuffled(Random(config.seed + 1))
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
// Cities & Knights has no development cards: knights and progress
// cards take their place.
val devDeck = if (ck) {
emptyList()
} else {
DEV_DECK
.mapIndexed { id, type -> DevCard(id, type) }
.shuffled(Random(config.seed + 1))
}
return CatanState(
options = options,
@@ -91,8 +98,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
rngStep = 0,
winner = null,
log = listOf(
"Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.",
"Catan${if (ck) " — Cities & Knights" else ""} — ${options.playerCount} players, " +
"first to ${options.pointsToWin} victory points.",
),
ck = if (ck) CatanCk.initial(options, config.seed) else null,
)
}
@@ -101,7 +110,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.SETUP_FIRST -> state.setupIndex
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
CatanPhase.DISCARDING -> state.discardSeat
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to ?: state.ck?.goodsOffer?.to
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn
@@ -130,12 +140,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is OfferTrade -> offerLegal(state, seat, move)
else -> move in legalMoves(state, seat)
else -> if (state.isCk) {
CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat))
} else {
move in legalMoves(state, seat)
}
}
}
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
if (currentSeat(state) != seat) return emptyList()
if (state.isCk) return CatanCk.legalMoves(state, seat)
return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
@@ -152,11 +167,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
CatanPhase.GAME_OVER -> emptyList()
CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList()
}
}
private fun setupMoves(state: CatanState): List<SetupPlace> {
internal fun setupMoves(state: CatanState): List<SetupPlace> {
val moves = mutableListOf<SetupPlace>()
for (v in 0 until CatanBoard.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue
@@ -276,7 +291,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* Road placements that connect to [seat]'s network, cost aside. A player
* with all fifteen roads laid has none.
*/
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
internal fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList()
} else {
@@ -291,7 +306,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* connection requirement does not apply — that is why setup uses its
* own move.)
*/
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
internal fun settlementVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList()
} else {
@@ -301,7 +316,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
}
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
internal fun cityVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList()
} else {
@@ -310,11 +325,14 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
}
private fun hasKnight(state: CatanState, vertex: Int): Boolean =
state.ck?.knights?.any { it.vertex == vertex } == true
/** How many settlements or cities [seat] currently has standing. */
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
state.buildings.count { it?.owner == seat && it.type == type }
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
internal fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
val offer = state.pendingTrade ?: return emptyList()
val responses = mutableListOf<CatanMove>()
if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) {
@@ -325,8 +343,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
/** An empty vertex with no building on it or one edge away. */
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null &&
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && !hasKnight(state, v) &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/**
@@ -345,6 +363,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
/** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
val building = state.buildings[vertex]
// A knight is a wall to everyone but its owner, like a building.
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
if (knight != null && knight.owner != seat) return false
// Your own building is always a way in; anyone else's is a wall.
if (building != null) return building.owner == seat
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
@@ -387,18 +408,22 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
EndTurn -> applyEndTurn(state, seat)
EndFreeRoads -> applyEndFreeRoads(state, seat)
}
else -> CatanCk.apply(state, seat, move)
}.let { if (it.isCk) CatanCk.settle(it) else it }
}
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
val n = state.playerCount
val second = state.phase == CatanPhase.SETUP_SECOND
// In Cities & Knights the second placement is a city.
val first = if (state.isCk && second) BuildingType.CITY else BuildingType.SETTLEMENT
var s = state.copy(
buildings = state.buildings.toMutableList().also {
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
it[move.vertex] = CatanBuilding(first, seat)
},
roads = state.roads.toMutableList().also { it[move.edge] = seat },
log = state.log + "Seat $seat settles at ${move.vertex} and lays a road on ${move.edge}.",
log = state.log + ("Seat $seat ${if (first == BuildingType.CITY) "raises a city" else "settles"} " +
"at ${move.vertex} and lays a road on ${move.edge}."),
)
if (second) {
@@ -432,6 +457,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
if (state.isCk) return CatanCk.applyRoll(state, seat, move)
val dice = move.forced ?: derivedDice(state)
val total = dice[0] + dice[1]
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
@@ -506,6 +532,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
if (state.isCk) return CatanCk.applyRobber(state, seat, move)
val hex = CatanBoard.hexIndex[move.hex]
?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
@@ -522,8 +549,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
return s.copy(
phase = CatanPhase.PLAYING,
log = s.log + "Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
" (${move.hex.q},${move.hex.r})$stolen.",
log = s.log + ("Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
" (${move.hex.q},${move.hex.r})$stolen."),
)
}
@@ -535,7 +562,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (free) {
val left = s.pendingFreeRoads - 1
s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = CatanPhase.PLAYING)
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
}
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
}
@@ -560,7 +587,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
},
)
s = payCost(s, seat, CITY_COST)
return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}."))
s = s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.")
// A city can be what a waiting metropolis needed.
if (s.isCk) s = CatanCk.afterCityBuilt(s, seat)
return checkWin(s)
}
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
@@ -637,11 +667,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
state.copy(
phase = CatanPhase.TRADE_PENDING,
pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get),
log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " +
"${describe(countOf(move.get))} to seat ${move.to}.",
log = state.log + ("Seat $seat offers ${describe(countOf(move.give))} for " +
"${describe(countOf(move.get))} to seat ${move.to}."),
)
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true)
val offer = state.pendingTrade!!
var resources = withCards(state.resources, offer.to, offer.get, -1)
resources = withCards(resources, offer.by, offer.give, -1)
@@ -656,7 +687,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
state.copy(
if (state.ck?.goodsOffer != null) CatanCk.answerOffer(state, seat, accept = false) else state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
log = state.log + "Seat $seat declines the offer.",
@@ -668,8 +699,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1))
val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour"
return s.copy(
log = s.log + "Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
"${move.get.name.lowercase()} via $via.",
log = s.log + ("Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
"${move.get.name.lowercase()} via $via."),
)
}
@@ -679,7 +710,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (settled.phase == CatanPhase.GAME_OVER) return settled
val next = (state.turn + 1) % state.playerCount
val s = state.copy(
val s = (if (state.isCk) CatanCk.endTurn(state) else state).copy(
turn = next,
turnNumber = state.turnNumber + 1,
phase = CatanPhase.AWAIT_ROLL,
@@ -692,9 +723,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return checkWin(s)
}
/** Where play goes after free roads: a card played before the roll returns to the roll. */
private fun phaseAfterRoads(state: CatanState): CatanPhase =
if (state.ck?.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
state.copy(
phase = CatanPhase.PLAYING,
phase = phaseAfterRoads(state),
pendingFreeRoads = 0,
log = state.log + "Seat $seat forfeits the remaining free road.",
)
@@ -710,6 +745,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* view or the host's copy.
*/
fun publicVictoryPoints(state: CatanState): List<Int> {
if (state.isCk) return CatanCk.publicVictoryPoints(state)
val points = IntArray(state.playerCount)
state.buildings.forEach { building ->
if (building != null) {
@@ -725,7 +761,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
publicVictoryPoints(state)[seat] +
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
private fun checkWin(state: CatanState): CatanState {
internal fun checkWin(state: CatanState): CatanState {
if (state.phase == CatanPhase.GAME_OVER) return state
val seat = state.turn
val points = victoryPoints(state, seat)
@@ -746,7 +782,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* road falls below five segments the award reopens to the longest road
* of five or more, lowest seat first on a tie.
*/
private fun refreshLongestRoad(state: CatanState): CatanState {
internal fun refreshLongestRoad(state: CatanState): CatanState {
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
val holder = state.longestRoadOwner
val best = lengths
@@ -782,6 +818,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val building = state.buildings[v]
if (building != null && building.owner != seat) blocked[v] = true
}
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
for (start in 0 until CatanBoard.VERTEX_COUNT) {
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
@@ -843,7 +880,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
fun bankSupply(state: CatanState, resource: Resource): Int =
BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 }
private fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
internal fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need }
private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState =
@@ -894,6 +931,16 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return best
}
/** 3 if [seat] has a settlement on a generic harbour, else the bank's 4. */
fun genericPortRatio(state: CatanState, seat: Int): Int {
for (port in state.ports) {
if (port.resource != null) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
}
return 4
}
// -----------------------------------------------------------------------
// Results, redaction and wire format
// -----------------------------------------------------------------------
@@ -908,12 +955,15 @@ object CatanRules : GameRules<CatanState, CatanMove> {
else -> "Seat $winner wins"
}
override fun viewFor(state: CatanState, seat: Int): CatanState = state.copy(
// Hidden information stays with the holder and the host.
devDeck = emptyList(),
devHands = state.devHands.mapIndexed { index, hand ->
if (index == seat) hand else emptyList()
},
override fun viewFor(state: CatanState, seat: Int): CatanState = CatanCk.viewFor(
state.copy(
// Hidden information stays with the holder and the host.
devDeck = emptyList(),
devHands = state.devHands.mapIndexed { index, hand ->
if (index == seat) hand else emptyList()
},
),
seat,
)
override fun encodeState(state: CatanState): String = json.encodeToString(state)
@@ -190,6 +190,8 @@ object CatanText {
"$turn is placing free roads…"
}
CatanPhase.CHORE -> "Waiting for ${seats.label(onClock ?: state.turn)}…"
CatanPhase.TRADE_PENDING -> {
val offer = state.pendingTrade
when {