Draft: Katan rules engine, parked for the game task (not ready)

Work-in-progress from the WALA-118 session, kept off main: the Katan game
is a later task, and this bootstrap only lists it under Board Games.

- game/catan/: CatanModel, CatanBoard (hex geometry, seeded island
  generation), CatanRules (phases, building, trading, scoring), CatanAi.
- GameRules.isLegalMove: structural move validation for games whose move
  space is combinatorial (trade offers), so MatchController's gates treat
  every game the same way.
- Tests: 51 — the engine's intended behaviour, written ahead of the
  implementation.

Honest state: it compiles, but 8 of the 51 tests still fail — forced-dice
handling in applyMove, token odds, win-by-standing-points, and two board
geometry invariants. Recorded, not silently green; the Katan game task picks
this up from here. Do not merge.

Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
adrianoftyriel
2026-10-14 02:26:16 +00:00
co-authored by Paperclip
parent 5c797c8807
commit d6a9a4f6bd
9 changed files with 3062 additions and 2 deletions
@@ -0,0 +1,319 @@
package org.prolibertate.games.game.catan
import org.prolibertate.games.game.engine.GameAi
/**
* A heuristic Catan opponent.
*
* It plays a legal, mildly strategic game: it settles on the best-producing
* intersections it can reach, raises cities, chases Largest Army with its
* knights, spends its progress cards when they complete something, trades
* its surplus to the bank or the table to close a one-card gap, and robs
* whoever is winning.
*
* Honesty is a design constraint, not an accident: this runs on the host,
* where the authoritative state is fully visible. It reads only public
* information — terrain, numbers, buildings, roads, resource counts,
* dev-card counts, knights played — plus its own hand, and never looks
* into the deck, another seat's development cards, or the seed beyond the
* roll the rules derive from it.
*/
class CatanAi : GameAi<CatanState, CatanMove> {
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
"No legal move for seat $seat"
}
return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
CatanPhase.DISCARDING -> chooseDiscard(state, seat, legal.filterIsInstance<Discard>())
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.GAME_OVER -> legal.firstOrNull()
?: error("The game is over; nobody chooses a move")
}
}
// -----------------------------------------------------------------------
// Setup
// -----------------------------------------------------------------------
private 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) +
0.25 * placeDirection(state, place)
} ?: places.first()
}
/**
* Where the setup road points: the best settlement spot two edges out
* through the far end of the road. The vertex next to the new
* settlement can never hold one (the distance rule), so what a road is
* worth is what it reaches past it.
*/
private fun placeDirection(state: CatanState, place: SetupPlace): Double {
val far = CatanBoard.otherEnd(place.edge, place.vertex)
return CatanBoard.VERTEX_NEIGHBORS[far]
.filter { it != place.vertex && it !in CatanBoard.VERTEX_NEIGHBORS[place.vertex] }
.maxOfOrNull { spotValue(state, it) }
?: 0.0
}
// -----------------------------------------------------------------------
// Dice, discards and the robber
// -----------------------------------------------------------------------
private fun chooseDiscard(state: CatanState, seat: Int, discards: List<Discard>): Discard {
require(discards.isNotEmpty()) { "No legal discard" }
// Throw away what it wants least; grain and ore are the city, wool
// is mostly a dev-card tax.
return discards.minByOrNull { discard ->
discard.cards.sumOf { want(it) }
} ?: discards.first()
}
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
if (building.owner == seat) continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
score += worth * CatanBoard.numberOdds(state.numbers[hex]) / 36.0
}
if (move.victim != null) {
score += 0.15 * state.resourceCount(move.victim) +
0.3 * points[move.victim]
}
score
} ?: moves.first()
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
// A city doubles the best production the seat already owns.
legal.filterIsInstance<BuildCity>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
legal.filterIsInstance<BuildSettlement>().maxByOrNull {
spotValue(state, it.vertex)
}?.let { return it }
// A knight is worth playing whenever one is playable: it blocks an
// opponent's best hex and marches toward Largest Army.
legal.filterIsInstance<PlayKnight>().firstOrNull()?.let { return it }
yearOfPlenty(state, legal)?.let { return it }
monopoly(state, legal)?.let { return it }
// Road Building is worth two free segments whenever there is
// somewhere to put them.
if (legal.filterIsInstance<PlayRoadBuilding>().isNotEmpty() &&
legal.filterIsInstance<BuildRoad>().size >= 2
) {
return legal.filterIsInstance<PlayRoadBuilding>().first()
}
legal.filterIsInstance<BuyDevCard>().firstOrNull()?.let { return it }
// A paid road only when it reaches toward a spot worth settling.
legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.1 }
.maxByOrNull { roadValue(state, it) }
?.let { return it }
// A bank trade only when it completes a build this turn; trading
// four for one on spec is how hands evaporate.
legal.filterIsInstance<MaritimeTrade>()
.filter { completesBuild(state, seat, it) }
.maxByOrNull { CatanRules.bestPortRatio(state, seat, it.give) * -1.0 }
?.let { return it }
offer(state, seat)?.let { return it }
return legal.lastOrNull { it is EndTurn } ?: legal.first()
}
/** Year of Plenty: two of whatever the nearest build is shortest of. */
private fun yearOfPlenty(state: CatanState, legal: List<CatanMove>): PlayYearOfPlenty? {
val takes = legal.filterIsInstance<PlayYearOfPlenty>()
if (takes.isEmpty()) return null
val shortage = shortage(state) ?: return null
return takes.maxByOrNull { take ->
take.take.count { it == shortage } * 2 + take.take.size
}
}
/** Monopoly: declare the resource everyone else is hoarding, of the ones wanted. */
private fun monopoly(state: CatanState, legal: List<CatanMove>): PlayMonopoly? {
val plays = legal.filterIsInstance<PlayMonopoly>()
if (plays.isEmpty()) return null
val best = plays.maxByOrNull {
19 - CatanRules.bankSupply(state, it.resource) + want(it.resource)
} ?: return null
// Only worth the card when the table is actually holding a pile.
return if (19 - CatanRules.bankSupply(state, best.resource) >= 10) best else null
}
/** What the seat is shortest of for its next city, else its next settlement. */
private fun shortage(state: CatanState): Resource? {
val seat = state.turn
val targets = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
for (cost in targets) {
val missing = cost.entries.filter { (res, need) ->
(state.resources[seat][res] ?: 0) < need
}
if (missing.isNotEmpty()) {
return missing.maxByOrNull { (res, need) -> need - (state.resources[seat][res] ?: 0) }?.key
}
}
return null
}
private fun completesBuild(state: CatanState, seat: Int, move: MaritimeTrade): Boolean {
val hand = state.resources[seat].toMutableMap()
val ratio = CatanRules.bestPortRatio(state, seat, move.give)
hand[move.give] = (hand[move.give] ?: 0) - ratio
hand[move.get] = (hand[move.get] ?: 0) + 1
return covers(hand, CatanRules.CITY_COST) || covers(hand, CatanRules.SETTLEMENT_COST)
}
private fun covers(hand: Map<Resource, Int>, cost: Map<Resource, Int>): Boolean =
cost.all { (res, need) -> (hand[res] ?: 0) >= need }
/**
* A domestic offer for exactly the card that closes a build, paid for
* with two of the surplus — offered to whoever visibly holds the most
* of what is missing. Constructed rather than enumerated (see
* [OfferTrade]), so it is checked against the rules before it is spoken.
*
* At most one offer a turn: a declined offer changes nothing, so
* re-offering it would only bounce between the same two seats forever.
*/
private fun offer(state: CatanState, seat: Int): OfferTrade? {
if (offeredThisTurn(state, seat)) return null
val seatHand = state.resources[seat]
val builds = listOf(CatanRules.CITY_COST, CatanRules.SETTLEMENT_COST)
for (cost in builds) {
val missing = cost.entries.filter { (res, need) -> (seatHand[res] ?: 0) < need }
if (missing.size != 1) continue
val need = missing.first().key
val surplus = Resource.entries
.filter { it != need }
.filter { (seatHand[it] ?: 0) > (cost[it] ?: 0) }
.sortedByDescending { seatHand[it] ?: 0 }
if (surplus.isEmpty()) continue
val target = (0 until state.playerCount)
.filter { it != seat }
.filter { (state.resources[it][need] ?: 0) > 0 }
.maxByOrNull { state.resources[it][need] ?: 0 }
?: continue
val give = listOf(surplus.first(), surplus.first())
val trade = OfferTrade(to = target, give = give, get = listOf(need))
if (CatanRules.isLegalMove(state, seat, trade)) return trade
}
return null
}
/**
* Whether [seat] has already put an offer on the table this turn, read
* from the public log: the lines after this seat's last roll are this
* turn's, and an offer can only be spoken on the offerer's own turn.
*/
private fun offeredThisTurn(state: CatanState, seat: Int): Boolean {
val sinceRoll = state.log
.drop(state.log.indexOfLast { it.startsWith("Seat $seat rolls") } + 1)
return sinceRoll.any { it.startsWith("Seat $seat offers") }
}
// -----------------------------------------------------------------------
// Free roads and trade responses
// -----------------------------------------------------------------------
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val roads = legal.filterIsInstance<BuildRoad>()
.filter { roadValue(state, it) > 0.05 }
.maxByOrNull { roadValue(state, it) }
return roads ?: legal.firstOrNull { it is EndFreeRoads }
?: legal.filterIsInstance<BuildRoad>().firstOrNull()
?: legal.first()
}
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull() ?: run {
return legal.first { it is DeclineTrade }
}
val offer = state.pendingTrade ?: return legal.first()
// Accept when what arrives is worth more than what leaves: a mild
// table, not a shark.
val incoming = offer.get.sumOf { want(it) }
val outgoing = offer.give.sumOf { want(it) }
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
}
// -----------------------------------------------------------------------
// Valuations
// -----------------------------------------------------------------------
/** What a seat would think of building on [vertex]: production and ports. */
private fun spotValue(state: CatanState, vertex: Int): Double {
if (state.buildings[vertex] != null) return -1.0
var value = 0.0
val kinds = mutableSetOf<Resource>()
for (h in CatanBoard.VERTEX_HEXES[vertex]) {
val resource = state.terrains[h].produces ?: continue
value += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * want(resource)
kinds += resource
}
value += 0.12 * kinds.size // variety keeps a build plan alive
for (e in CatanBoard.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) 0.1 else 0.2
}
return value
}
/**
* 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 {
var best = 0.0
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
for (endpoint in listOf(from, to)) {
for (near in CatanBoard.VERTEX_NEIGHBORS[endpoint]) {
best = maxOf(best, spotValue(state, near) * 0.8)
}
}
return best
}
/** How much the AI wants to hold one card of [resource]. */
private 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
Resource.BRICK -> 0.95
Resource.WOOL -> 0.85 // abundant and only half of a dev card
}
}
@@ -0,0 +1,319 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.Serializable
import kotlin.math.atan2
import kotlin.random.Random
/**
* The island as geometry: the 19 hexes, the 54 shared vertices, the 72
* shared edges, the coast and the nine harbour positions.
*
* Everything is derived once, deterministically, from axial coordinates —
* there is no randomness and no state here; the layout of a particular
* game (which terrain and number sits on which hex) is [generate], and the
* live state lives in [CatanState].
*
* Coordinates: hexes are addressed axially ((q, r), pointy-top). Every
* position is kept on an exact integer lattice so vertices shared by
* neighbouring hexes are recognised by position instead of by floating
* point: the centre of hex (q, r) is at lattice point (2q + r, 3r), and its
* six corners follow the offsets in [CORNER_OFFSETS]. Vertex positions are
* in half-units of the hex grid; rendering scales and flips them (lattice
* +Y is toward the top of the board).
*/
object CatanBoard {
/** The 19 hexes: every (q, r) within distance 2 of the centre. */
val HEXES: List<HexCoord> = buildList {
for (r in -2..2) {
for (q in -2..2) {
if (hexDistance(q, r, 0, 0) <= 2) add(HexCoord(q, r))
}
}
}
val HEX_COUNT: Int = HEXES.size
val hexIndex: Map<HexCoord, Int> = HEXES.withIndex().associate { (i, hex) -> hex to i }
/**
* Corner k of a hex, as a lattice offset from its centre. Pointy-top:
* corner 0 is the top corner, then clockwise.
*/
private val CORNER_OFFSETS = listOf(
0 to 2, 1 to 1, 1 to -1, 0 to -2, -1 to -1, -1 to 1,
)
/** Unit steps to a hex's six neighbours, matching the corner order. */
private val NEIGHBOR_STEPS = listOf(
0 to 1, 1 to 0, 1 to -1, 0 to -1, -1 to 0, -1 to 1,
)
/** The six board-corner hexes: pairwise non-adjacent by construction. */
private val CORNER_HEXES = listOf(
HexCoord(2, 0), HexCoord(0, 2), HexCoord(-2, 2),
HexCoord(-2, 0), HexCoord(0, -2), HexCoord(2, -2),
)
// -----------------------------------------------------------------------
// Vertices and edges
// -----------------------------------------------------------------------
/** Lattice position of each vertex, by vertex id. */
val VERTEX_POS: List<Pair<Int, Int>>
val VERTEX_COUNT: Int
/** Vertex ids of hex h's six corners, corner 0 first (see [CORNER_OFFSETS]). */
val HEX_CORNERS: List<List<Int>>
/** Both endpoints of each edge, as vertex ids, low id first. */
val EDGE_VERTICES: List<Pair<Int, Int>>
val EDGE_COUNT: Int
/** Edges touching each vertex. */
val VERTEX_EDGES: List<List<Int>>
/** Vertices one edge away from each vertex: the distance rule's horizon. */
val VERTEX_NEIGHBORS: List<List<Int>>
/** Hexes touching each vertex. */
val VERTEX_HEXES: List<List<Int>>
/** Hexes touching each edge: two for interior edges, one on the coast. */
val EDGE_HEXES: List<List<Int>>
/** Edges adjacent to each edge (sharing a vertex). */
val EDGE_NEIGHBORS: List<List<Int>>
/** Hexes adjacent to each hex, in [NEIGHBOR_STEPS] order. */
val HEX_NEIGHBORS: List<List<Int>>
/** The coastal edges (those with only one hex) in ring order. */
val COASTAL: List<Int>
/** The nine fixed harbour positions, spread around the coast. */
val PORT_POSITIONS: List<Int>
init {
// Vertices: assign ids in first-seen order over hexes then corners.
val vertexIds = mutableMapOf<Pair<Int, Int>, Int>()
val vertexPos = mutableListOf<Pair<Int, Int>>()
val hexCorners = HEXES.map { hex ->
val cx = 2 * hex.q + hex.r
val cy = 3 * hex.r
CORNER_OFFSETS.map { (dx, dy) ->
val pos = (cx + dx) to (cy + dy)
vertexIds.getOrPut(pos) {
vertexPos += pos
vertexPos.size - 1
}
}
}
VERTEX_POS = vertexPos
VERTEX_COUNT = vertexPos.size
HEX_CORNERS = hexCorners
// Edges between a hex's consecutive corners, deduplicated by the
// pair of vertex ids.
val edgeIds = mutableMapOf<Pair<Int, Int>, Int>()
val edgeVertices = mutableListOf<Pair<Int, Int>>()
hexCorners.forEachIndexed { h, corners ->
for (k in 0..5) {
val a = corners[k]
val b = corners[(k + 1) % 6]
val key = minOf(a, b) to maxOf(a, b)
edgeIds.getOrPut(key) {
edgeVertices += key
edgeVertices.size - 1
}
}
}
EDGE_VERTICES = edgeVertices
EDGE_COUNT = edgeVertices.size
val vertexEdges = List(VERTEX_COUNT) { mutableListOf<Int>() }
val vertexHexes = List(VERTEX_COUNT) { mutableSetOf<Int>() }
val edgeHexes = List(EDGE_COUNT) { mutableSetOf<Int>() }
hexCorners.forEachIndexed { h, corners ->
corners.forEach { v -> vertexHexes[v] += h }
for (k in 0..5) {
val a = corners[k]
val b = corners[(k + 1) % 6]
val key = minOf(a, b) to maxOf(a, b)
val e = edgeIds.getValue(key)
edgeHexes[e] += h
vertexEdges[a] += e
vertexEdges[b] += e
}
}
VERTEX_EDGES = vertexEdges.map { it.toList() }
VERTEX_HEXES = vertexHexes.map { it.sorted() }
EDGE_HEXES = edgeHexes.map { it.sorted() }
VERTEX_NEIGHBORS = List(VERTEX_COUNT) { v ->
VERTEX_EDGES[v].map { otherEnd(it, v) }.distinct().sorted()
}
EDGE_NEIGHBORS = List(EDGE_COUNT) { e ->
val (a, b) = EDGE_VERTICES[e]
(VERTEX_EDGES[a] + VERTEX_EDGES[b]).distinct().filter { it != e }.sorted()
}
HEX_NEIGHBORS = HEXES.mapIndexed { h, hex ->
NEIGHBOR_STEPS.mapNotNull { (dq, dr) ->
hexIndex[HexCoord(hex.q + dq, hex.r + dr)]
}
}
// The coast in walking order: sort the coastal edges by the angle of
// their midpoint around the board centre, which is the lattice
// origin. The board is a hexagon, so this yields one closed ring.
COASTAL = EDGE_HEXES.withIndex()
.filter { it.value.size == 1 }
.map { (e, _) -> e }
.sortedBy { e -> angleOf(midpoint(e)) }
// Harbours sit roughly every third or fourth coastal edge, like the
// physical frame: nine positions, steps summing to the whole ring.
PORT_POSITIONS = buildList {
var at = 0
for (step in listOf(3, 4, 3, 3, 4, 3, 3, 4, 3)) {
add(COASTAL[at % COASTAL.size])
at += step
}
}.distinct()
check(HEX_COUNT == 19) { "A Catan island has 19 hexes, found $HEX_COUNT" }
check(VERTEX_COUNT == 54) { "A Catan island has 54 vertices, found $VERTEX_COUNT" }
check(EDGE_COUNT == 72) { "A Catan island has 72 edges, found $EDGE_COUNT" }
check(COASTAL.size == 30) { "The coast has 30 edges, found ${COASTAL.size}" }
check(PORT_POSITIONS.size == 9) { "Nine harbours, found ${PORT_POSITIONS.size}" }
check(PORT_POSITIONS.all { EDGE_HEXES[it].size == 1 }) { "Harbours must be coastal" }
}
/** The other endpoint of [edge], given one [vertex] on it. */
fun otherEnd(edge: Int, vertex: Int): Int {
val (a, b) = EDGE_VERTICES[edge]
return if (vertex == a) b else a
}
/** Lattice position of hex h's centre. */
fun hexCenter(h: Int): Pair<Int, Int> {
val hex = HEXES[h]
return (2 * hex.q + hex.r) to (3 * hex.r)
}
/** Whether two hexes share an edge. */
fun hexesAdjacent(a: Int, b: Int): Boolean = b in HEX_NEIGHBORS[a]
/** Hex-grid distance between two axial positions. */
fun hexDistance(q1: Int, r1: Int, q2: Int, r2: Int): Int {
val dq = q1 - q2
val dr = r1 - r2
return maxOf(kotlin.math.abs(dq), kotlin.math.abs(dr), kotlin.math.abs(dq + dr))
}
/**
* How often a number is expected to come up, in thirty-sixths. A 6 or an
* 8 comes up five times in thirty-six, a 2 or a 12 once.
*/
fun numberOdds(number: Int): Int = 6 - kotlin.math.abs(7 - number)
private fun midpoint(edge: Int): Pair<Double, Double> {
val (a, b) = EDGE_VERTICES[edge]
val pa = VERTEX_POS[a]
val pb = VERTEX_POS[b]
return ((pa.first + pb.first) / 2.0) to ((pa.second + pb.second) / 2.0)
}
private fun angleOf(point: Pair<Double, Double>): Double =
atan2(point.second, point.first)
// -----------------------------------------------------------------------
// Island generation
// -----------------------------------------------------------------------
/**
* The island layout for one game: terrain and number per hex, the robber
* on the desert, and the nine harbours.
*/
@Serializable
data class Generated(
val terrains: List<Terrain>,
/** 0 where there is no token (the desert). */
val numbers: List<Int>,
val robber: Int,
val ports: List<Port>,
)
/** The terrain tile mix: four forests, four pastures, four fields, three hills, three mountains, one desert. */
private val TERRAIN_TILES: List<Terrain> = listOf(
Terrain.FOREST, Terrain.FOREST, Terrain.FOREST, Terrain.FOREST,
Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE, Terrain.PASTURE,
Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS, Terrain.FIELDS,
Terrain.HILLS, Terrain.HILLS, Terrain.HILLS,
Terrain.MOUNTAINS, Terrain.MOUNTAINS, Terrain.MOUNTAINS,
Terrain.DESERT,
)
/** The 18 number tokens, the two hardest numbers withheld: 7 never appears. */
private val RED_TOKENS = listOf(6, 6, 8, 8)
private val OTHER_TOKENS = listOf(2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
/** Shuffle attempts before falling back to the corner placement for 6s and 8s. */
private const val RED_ATTEMPTS = 60
/**
* Generates the island from [seed].
*
* Terrain is shuffled over the hexes, exactly as shuffling the land
* tiles face down does. Number tokens are shuffled over the land hexes
* with one repair rule: no two of the red tokens (6 and 8) may sit on
* adjacent hexes, the balance convention the published game asks for.
* The robber starts on the desert. Harbour *positions* are fixed (the
* physical frame is not shuffled); the nine harbour *types* — five
* 2:1 resource ports and four 3:1 ports — are shuffled onto them.
*
* The 6/8 separation is guaranteed, not merely probable: if no shuffled
* placement separates them (about one shuffle in five does), the red
* tokens go to four of the six board-corner hexes, which are pairwise
* non-adjacent, and at most one corner can be desert.
*/
fun generate(seed: Long): Generated {
val random = Random(seed)
val terrains = TERRAIN_TILES.shuffled(random)
val desert = terrains.indexOf(Terrain.DESERT)
val land = terrains.indices.filter { it != desert }
var redHexes: List<Int>? = null
var attempt = 0
while (redHexes == null && attempt < RED_ATTEMPTS) {
val candidate = land.shuffled(random).take(4)
val separated = candidate.withIndex().all { (i, a) ->
candidate.drop(i + 1).none { hexesAdjacent(a, it) }
}
if (separated) redHexes = candidate
attempt++
}
val reds = redHexes ?: CORNER_HEXES
.mapNotNull { hexIndex[it] }
.filter { it != desert }
.shuffled(random)
.take(4)
val numbers = IntArray(HEX_COUNT)
RED_TOKENS.forEachIndexed { i, token -> numbers[reds[i]] = token }
val rest = land.filter { it !in reds }.shuffled(random)
OTHER_TOKENS.forEachIndexed { i, token -> numbers[rest[i]] = token }
val portTypes = (
listOf(
Resource.LUMBER, Resource.BRICK, Resource.WOOL, Resource.GRAIN, Resource.ORE,
) + List(4) { null }
).shuffled(random)
val ports = PORT_POSITIONS.mapIndexed { i, edge ->
val resource = portTypes[i]
if (resource == null) Port(edge, null, 3) else Port(edge, resource, 2)
}
return Generated(terrains, numbers.toList(), desert, ports)
}
}
@@ -0,0 +1,307 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Sentinel stored in [CatanState.roads] for an unclaimed road position. */
const val NO_PLAYER = -1
/** Road segments required to hold Longest Road. */
const val LONGEST_ROAD_MIN = 5
/** Knights required to take Largest Army. */
const val LARGEST_ARMY_MIN = 3
/** Resource cards of each type the bank starts with. */
const val BANK_PER_RESOURCE = 19
/**
* The five resources the island produces.
*
* Catan names two of them after the terrain rather than the commodity
* (wood becomes lumber, sheep become wool); those names travel with the
* trademark, so the engine speaks in resources only.
*/
@Serializable
enum class Resource { LUMBER, BRICK, WOOL, GRAIN, ORE }
/** One terrain type per hex; the desert produces nothing. */
@Serializable
enum class Terrain(val produces: Resource?) {
FOREST(Resource.LUMBER),
PASTURE(Resource.WOOL),
FIELDS(Resource.GRAIN),
HILLS(Resource.BRICK),
MOUNTAINS(Resource.ORE),
DESERT(null),
}
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
@Serializable
data class HexCoord(val q: Int, val r: Int)
@Serializable
enum class BuildingType { SETTLEMENT, CITY }
/** What stands on one vertex: a settlement or city owned by one seat. */
@Serializable
data class CatanBuilding(val type: BuildingType, val owner: Int)
/**
* One harbour. A port sits on a coastal edge: a player who owns a
* settlement or city on *either* endpoint vertex of [edge] may trade at
* [ratio] (2 or 3) instead of the bank's 4:1. A resource port trades at 2:1
* for its own resource only; [resource] is null for a generic 3:1 harbour.
*/
@Serializable
data class Port(val edge: Int, val resource: Resource?, val ratio: Int)
@Serializable
enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTORY_POINT }
/**
* One development card. [id] is stable for the life of the game (the deck
* is built once from the seed), so moves can name a card by id even after
* it moves from the deck into a hand. [boughtTurn] records when it was
* drawn: a card cannot be played on the turn it was bought.
*/
@Serializable
data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0)
/**
* A domestic trade offer on the table. [by] is the offering seat and [to]
* the only seat that may respond — v1 has no counter-offers and no open
* auctions, so an offer is always addressed to one player.
*/
@Serializable
data class TradeOffer(val by: Int, val to: Int, val give: List<Resource>, val get: List<Resource>)
/**
* Where the game stands. The phases that involve more than the player
* whose turn it is carry their own bookkeeping: [discardSeat] during
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING].
*/
@Serializable
enum class CatanPhase {
/** Snake order forward: settlement + road each, seats 0..n-1. */
SETUP_FIRST,
/** Snake order back: settlement + road each, seats n-1..0, plus income. */
SETUP_SECOND,
/** The player whose turn it is must roll the dice. */
AWAIT_ROLL,
/** Players holding more than 7 resource cards discard half. */
DISCARDING,
/** The roller must move the robber and steal. */
ROBBER,
/** Build, trade, buy and play development cards, or end the turn. */
PLAYING,
/** A played Road Building card: up to two roads remain to be placed. */
FREE_ROADS,
/** The offered seat must accept or decline. */
TRADE_PENDING,
GAME_OVER,
}
@Serializable
data class CatanOptions(
val playerCount: Int = 3,
val pointsToWin: Int = 10,
) {
init {
require(playerCount in 3..4) { "The island seats three or four players" }
require(pointsToWin >= 2) { "pointsToWin must be at least 2" }
}
}
/**
* The whole game as one immutable value.
*
* Public information (terrain, numbers, ports, buildings, roads, resource
* counts, dev-card counts, knights played) is stored directly. Hidden
* information — the deck's order and every hand's development cards — is
* here in full because the host owns the truth; [CatanRules.viewFor] is
* what keeps it off the wire.
*/
@Serializable
data class CatanState(
val options: CatanOptions,
val seed: Long,
val phase: CatanPhase,
/**
* The seat whose turn it is — not always the seat expected to move:
* during [CatanPhase.DISCARDING] and [CatanPhase.TRADE_PENDING] another
* seat acts on the turn owner's behalf.
*/
val turn: Int,
/** How many setup placements are done, 0 until 2 * playerCount. */
val setupIndex: Int,
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
val turnNumber: Int,
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
val terrains: List<Terrain>,
/** Number token per hex, 0 where there is none (the desert). */
val numbers: List<Int>,
/** Hex the robber currently occupies. */
val robber: Int,
val ports: List<Port>,
/** What stands on each vertex, or null. */
val buildings: List<CatanBuilding?>,
/** Owning seat per edge, or [NO_PLAYER]. */
val roads: List<Int>,
/** Resource cards per seat. Card counts are public in Catan. */
val resources: List<Map<Resource, Int>>,
/** The bank's development cards, top first. Order is hidden. */
val devDeck: List<DevCard>,
/** Development cards per seat. Hidden from everyone but the holder. */
val devHands: List<List<DevCard>>,
/** Public mirror of the dev-hand sizes, for views and scoreboards. */
val devHandCounts: List<Int>,
/** Knights played (not merely held) per seat. */
val knightsPlayed: List<Int>,
/** Whether the turn owner has already played a development card. */
val devCardPlayedThisTurn: Boolean,
val pendingTrade: TradeOffer?,
/** Roads still owed on a played Road Building card. */
val pendingFreeRoads: Int,
/** Seat due to discard during [CatanPhase.DISCARDING]. */
val discardSeat: Int?,
val longestRoadOwner: Int?,
val largestArmyOwner: Int?,
/** The dice as last rolled, for the table display. */
val lastDice: List<Int>,
/**
* How many dice rolls and steals have happened so far. Random events are
* derived from the seed and this counter, so the same game replays
* identically without storing a random generator in the state.
*/
val rngStep: Int,
val winner: Int?,
val log: List<String>,
) {
val playerCount: Int get() = options.playerCount
/** Total resource cards held by [seat]. */
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
/** [seat]'s resource cards as a flat list, one entry per card. */
fun handOf(seat: Int): List<Resource> =
Resource.entries.flatMap { res -> List(resources[seat][res] ?: 0) { res } }
/** Whether [seat] holds every card in [cards], counting duplicates. */
fun holdsAll(seat: Int, cards: List<Resource>): Boolean {
val need = cards.groupingBy { it }.eachCount()
return need.all { (res, n) -> (resources[seat][res] ?: 0) >= n }
}
}
@Serializable
sealed interface CatanMove
/** Setup placement: a settlement on [vertex] plus a road on [edge]. */
@Serializable
@SerialName("setup")
data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
/**
* Roll the dice.
*
* With [forced] null the roll is derived from the game seed, which is what
* the AI, the UI and the network always send. [forced] exists so tests and
* offline reviews can stage a specific number; it is not part of the
* enumerated move list, so the network driver's "is this move in the legal
* list" check rejects any forced roll a client tries to sneak through —
* only code holding the state directly (the host's own tests) can use it.
*/
@Serializable
@SerialName("roll")
data class RollDice(val forced: List<Int>? = null) : CatanMove
/** Discard exactly these cards. */
@Serializable
@SerialName("discard")
data class Discard(val cards: List<Resource>) : CatanMove
/** Move the robber to [hex] and steal from [victim], if anyone can be robbed. */
@Serializable
@SerialName("robber")
data class MoveRobber(val hex: HexCoord, val victim: Int? = null) : CatanMove
/** Build a road on [edge] — paid normally, or free during FREE_ROADS. */
@Serializable
@SerialName("road")
data class BuildRoad(val edge: Int) : CatanMove
@Serializable
@SerialName("settlement")
data class BuildSettlement(val vertex: Int) : CatanMove
@Serializable
@SerialName("city")
data class BuildCity(val vertex: Int) : CatanMove
@Serializable
@SerialName("buyCard")
data object BuyDevCard : CatanMove
/**
* Play a knight. The card is named by [card] id; the robber move follows as
* its own move once the phase has advanced.
*/
@Serializable
@SerialName("knight")
data class PlayKnight(val card: Int) : CatanMove
@Serializable
@SerialName("roadBuilding")
data class PlayRoadBuilding(val card: Int) : CatanMove
@Serializable
@SerialName("monopoly")
data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
/** Take [take] (up to two cards) from the bank. */
@Serializable
@SerialName("plenty")
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
/**
* Offer a domestic trade to [to]: [give] of ours for [get] of theirs.
*
* Like a forced dice roll this is a free-form intent rather than an
* enumerated move — the space of two card multisets is combinatorial — so
* it is validated structurally by [CatanRules.isLegalMove] instead of
* appearing in [CatanRules.legalMoves].
*/
@Serializable
@SerialName("offer")
data class OfferTrade(val to: Int, val give: List<Resource>, val get: List<Resource>) : CatanMove
@Serializable
@SerialName("accept")
data object AcceptTrade : CatanMove
@Serializable
@SerialName("decline")
data object DeclineTrade : CatanMove
/** Bank trade: [give] at the best ratio this seat can trade at for [get]. */
@Serializable
@SerialName("bank")
data class MaritimeTrade(val give: Resource, val get: Resource) : CatanMove
@Serializable
@SerialName("endTurn")
data object EndTurn : CatanMove
/** Forfeit any remaining free roads from a played Road Building card. */
@Serializable
@SerialName("endFreeRoads")
data object EndFreeRoads : CatanMove
@@ -0,0 +1,905 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Catan as a pure state machine. See RULES-catan.md for the ruleset and the
* choices made where the published rules leave room.
*
* The rules the engine enforces come from the base game as published for
* three to four players: setup in snake order, one roll per turn,
* production weighted by number tokens, the robber on a seven, domestic
* and bank trade, building, development cards, Longest Road, Largest Army
* and victory at ten points.
*/
object CatanRules : GameRules<CatanState, CatanMove> {
override val gameId: String = GameCatalog.CATAN
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
// -----------------------------------------------------------------------
// Public constants the AI and the UI plan with
// -----------------------------------------------------------------------
val ROAD_COST: Map<Resource, Int> = mapOf(Resource.LUMBER to 1, Resource.BRICK to 1)
val SETTLEMENT_COST: Map<Resource, Int> = mapOf(
Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1,
)
val CITY_COST: Map<Resource, Int> = mapOf(Resource.GRAIN to 2, Resource.ORE to 3)
val DEV_COST: Map<Resource, Int> = mapOf(Resource.WOOL to 1, Resource.GRAIN to 1, Resource.ORE to 1)
/** The 25 development cards in the bank's deck: 14 knights, two of each progress card, five victory points. */
val DEV_DECK: List<DevCardType> = List(14) { DevCardType.KNIGHT } +
List(2) { DevCardType.ROAD_BUILDING } +
List(2) { DevCardType.MONOPOLY } +
List(2) { DevCardType.YEAR_OF_PLENTY } +
List(5) { DevCardType.VICTORY_POINT }
// -----------------------------------------------------------------------
// Life cycle
// -----------------------------------------------------------------------
override fun initialState(config: TableConfig): CatanState {
val options = json.decodeFromString<CatanOptions>(config.optionsJson)
require(config.seats.size == options.playerCount) {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
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))
return CatanState(
options = options,
seed = config.seed,
phase = CatanPhase.SETUP_FIRST,
turn = 0,
setupIndex = 0,
turnNumber = 0,
terrains = board.terrains,
numbers = board.numbers,
robber = board.robber,
ports = board.ports,
buildings = List(CatanBoard.VERTEX_COUNT) { null },
roads = List(CatanBoard.EDGE_COUNT) { NO_PLAYER },
resources = List(options.playerCount) {
Resource.entries.associateWith { 0 }
},
devDeck = devDeck,
devHands = List(options.playerCount) { emptyList() },
devHandCounts = List(options.playerCount) { 0 },
knightsPlayed = List(options.playerCount) { 0 },
devCardPlayedThisTurn = false,
pendingTrade = null,
pendingFreeRoads = 0,
discardSeat = null,
longestRoadOwner = null,
largestArmyOwner = null,
lastDice = emptyList(),
rngStep = 0,
winner = null,
log = listOf(
"Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.",
),
)
}
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.DISCARDING -> state.discardSeat
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn
}
// -----------------------------------------------------------------------
// Legality
// -----------------------------------------------------------------------
/**
* Structural legality, the form the network driver and the UI should
* gate through.
*
* Two moves are free-form intents rather than members of the enumerated
* move list, because enumerating them would explode combinatorially:
* a dice roll may carry forced values (tests and offline reviews only —
* over the network the driver's membership check rejects it), and a
* domestic trade offer names any two multisets of resource cards.
*/
override fun isLegalMove(state: CatanState, seat: Int, move: CatanMove): Boolean {
if (currentSeat(state) != seat) return false
return when (move) {
is RollDice -> state.phase == CatanPhase.AWAIT_ROLL &&
(move.forced == null ||
(move.forced.size == 2 && move.forced.all { it in 1..6 }))
is OfferTrade -> offerLegal(state, seat, move)
else -> move in legalMoves(state, seat)
}
}
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
if (currentSeat(state) != seat) return emptyList()
return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
CatanPhase.AWAIT_ROLL -> listOf(RollDice())
CatanPhase.DISCARDING -> discardMoves(state, seat)
CatanPhase.ROBBER -> robberMoves(state, seat)
CatanPhase.PLAYING -> playingMoves(state, seat)
CatanPhase.FREE_ROADS ->
roadMoves(state, seat) + listOf<CatanMove>(EndFreeRoads)
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
CatanPhase.GAME_OVER -> emptyList()
}
}
private fun setupMoves(state: CatanState): List<SetupPlace> {
val moves = mutableListOf<SetupPlace>()
for (v in 0 until CatanBoard.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue
for (e in CatanBoard.VERTEX_EDGES[v]) {
if (state.roads[e] != NO_PLAYER) continue
moves += SetupPlace(v, e)
}
}
return moves
}
private fun discardMoves(state: CatanState, seat: Int): List<Discard> {
val total = state.resourceCount(seat)
if (total <= 7) return emptyList()
val half = total / 2
val held = state.resources[seat]
val moves = mutableListOf<Discard>()
fun walk(resourceIndex: Int, remaining: Int, acc: MutableList<Resource>) {
if (resourceIndex == Resource.entries.size) {
if (remaining == 0) moves += Discard(acc.toList())
return
}
val resource = Resource.entries[resourceIndex]
val have = held[resource] ?: 0
for (take in 0..minOf(have, remaining)) {
repeat(take) { acc += resource }
walk(resourceIndex + 1, remaining - take, acc)
repeat(take) { acc.removeAt(acc.size - 1) }
}
}
walk(0, half, mutableListOf())
return moves
}
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
val moves = mutableListOf<MoveRobber>()
for (h in 0 until CatanBoard.HEX_COUNT) {
if (h == state.robber) continue
val victims = CatanBoard.HEX_CORNERS[h]
.mapNotNull { state.buildings[it] }
.map { it.owner }
.filter { it != seat }
.distinct()
.filter { state.resourceCount(it) > 0 }
if (victims.isEmpty()) {
moves += MoveRobber(CatanBoard.HEXES[h])
} else {
victims.forEach { moves += MoveRobber(CatanBoard.HEXES[h], it) }
}
}
return moves
}
private fun playingMoves(state: CatanState, seat: Int): List<CatanMove> {
val moves = mutableListOf<CatanMove>()
if (affordable(state, seat, ROAD_COST)) moves += roadMoves(state, seat)
if (affordable(state, seat, SETTLEMENT_COST)) {
moves += settlementVertices(state, seat).map { BuildSettlement(it) }
}
if (affordable(state, seat, CITY_COST)) {
moves += cityVertices(state, seat).map { BuildCity(it) }
}
if (state.devDeck.isNotEmpty() && affordable(state, seat, DEV_COST)) {
moves += BuyDevCard
}
if (!state.devCardPlayedThisTurn) {
for (card in state.devHands[seat]) {
if (card.boughtTurn == state.turnNumber) continue
when (card.type) {
DevCardType.KNIGHT -> moves += PlayKnight(card.id)
DevCardType.ROAD_BUILDING -> moves += PlayRoadBuilding(card.id)
DevCardType.MONOPOLY -> Resource.entries.forEach {
moves += PlayMonopoly(card.id, it)
}
DevCardType.YEAR_OF_PLENTY -> moves += plentyTakes(state).map {
PlayYearOfPlenty(card.id, it)
}
DevCardType.VICTORY_POINT -> Unit // revealed by winning, never played
}
}
}
for (give in Resource.entries) {
if ((state.resources[seat][give] ?: 0) < bestPortRatio(state, seat, give)) continue
for (get in Resource.entries) {
if (get == give || bankSupply(state, get) < 1) continue
moves += MaritimeTrade(give, get)
}
}
moves += EndTurn
return moves
}
/** Every take a Year of Plenty card could name: one card, or two. */
private fun plentyTakes(state: CatanState): List<List<Resource>> {
val takes = mutableListOf<List<Resource>>()
for (a in Resource.entries) {
if (bankSupply(state, a) >= 1) takes += listOf(a)
for (b in Resource.entries) {
// Two of a kind needs a bank that can pay both, not just one.
val needOfB = if (a == b) 2 else 1
if (bankSupply(state, a) >= 1 && bankSupply(state, b) >= needOfB) {
takes += listOf(a, b)
}
}
}
return takes
}
/** Road placements that connect to [seat]'s network, cost aside. */
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
(0 until CatanBoard.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
.map { BuildRoad(it) }
/**
* Settlement placements: an open vertex at least two edges from every
* building, connected to [seat]'s road network. (During setup the
* connection requirement does not apply — that is why setup uses its
* own move.)
*/
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
}
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
}
private 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)) {
responses += AcceptTrade
}
responses += DeclineTrade
return responses
}
/** An empty vertex with no building on it or one edge away. */
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/** A road on [edge] would join [seat]'s network of roads and buildings. */
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } ||
CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } ||
state.buildings[a]?.owner == seat ||
state.buildings[b]?.owner == seat
}
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
if (state.phase != CatanPhase.PLAYING || state.turn != seat) return false
if (move.to !in 0 until state.playerCount || move.to == seat) return false
if (move.give.isEmpty() || move.get.isEmpty()) return false
// Like-for-like is not a trade; the published game rejects it too.
if (countOf(move.give) == countOf(move.get)) return false
return state.holdsAll(seat, move.give)
}
// -----------------------------------------------------------------------
// Move application
// -----------------------------------------------------------------------
override fun applyMove(state: CatanState, seat: Int, move: CatanMove): CatanState {
require(currentSeat(state) == seat) {
"Not seat $seat's move in phase ${state.phase}"
}
require(isLegalMove(state, seat, move)) { "Illegal move $move for seat $seat" }
return when (move) {
is SetupPlace -> applySetup(state, seat, move)
is RollDice -> applyRoll(state, seat, move)
is Discard -> applyDiscard(state, seat, move)
is MoveRobber -> applyRobber(state, seat, move)
is BuildRoad -> applyBuildRoad(state, seat, move)
is BuildSettlement -> applyBuildSettlement(state, seat, move)
is BuildCity -> applyBuildCity(state, seat, move)
BuyDevCard -> applyBuyDevCard(state, seat)
is PlayKnight -> applyPlayKnight(state, seat, move)
is PlayRoadBuilding -> applyPlayRoadBuilding(state, seat, move)
is PlayMonopoly -> applyPlayMonopoly(state, seat, move)
is PlayYearOfPlenty -> applyPlayYearOfPlenty(state, seat, move)
is OfferTrade -> applyOfferTrade(state, seat, move)
AcceptTrade -> applyAcceptTrade(state, seat)
DeclineTrade -> applyDeclineTrade(state, seat)
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
EndTurn -> applyEndTurn(state, seat)
EndFreeRoads -> applyEndFreeRoads(state, seat)
}
}
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
val n = state.playerCount
val second = state.phase == CatanPhase.SETUP_SECOND
var s = state.copy(
buildings = state.buildings.toMutableList().also {
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, 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}.",
)
if (second) {
// The second settlement earns one card from each adjacent
// producing hex, supply permitting.
val adjacent = CatanBoard.VERTEX_HEXES[move.vertex].mapNotNull { state.terrains[it].produces }
val granted = adjacent.groupingBy { it }.eachCount().filter { (res, need) ->
need <= bankSupply(state, res)
}
if (granted.isNotEmpty()) {
s = s.copy(
resources = withCounts(s.resources, seat, granted, 1),
log = s.log + "Seat $seat banks ${describe(granted)} from the new lands.",
)
}
}
val newIndex = state.setupIndex + 1
s = s.copy(setupIndex = newIndex)
return when {
newIndex >= 2 * n -> s.copy(
phase = CatanPhase.AWAIT_ROLL,
turn = 0,
turnNumber = 1,
log = s.log + "Setup done — seat 0 leads.",
)
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
else -> s
}
}
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
val dice = move.forced ?: derivedDice(state)
val total = dice[0] + dice[1]
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
return if (total == 7) {
val firstDiscarder = (0 until s.playerCount)
.firstOrNull { s.resourceCount(it) > 7 }
val robbed = if (firstDiscarder != null) {
s.copy(phase = CatanPhase.DISCARDING, discardSeat = firstDiscarder)
} else {
s.copy(phase = CatanPhase.ROBBER)
}
robbed.copy(log = robbed.log + "Seat $seat rolls 7 — the robber rides.")
} else {
applyProduction(s, total)
}
}
/** Dice derived from the seed and the count of random events so far. */
private fun derivedDice(state: CatanState): List<Int> {
val rng = Random(state.seed * 1_000_003L + state.rngStep)
return listOf(rng.nextInt(6) + 1, rng.nextInt(6) + 1)
}
private fun applyProduction(state: CatanState, number: Int): CatanState {
val gains = List(state.playerCount) { mutableMapOf<Resource, Int>() }
for (resource in Resource.entries) {
var demand = 0
for (h in 0 until CatanBoard.HEX_COUNT) {
if (state.numbers[h] != number) continue
if (h == state.robber) continue
if (state.terrains[h].produces != resource) continue
for (v in CatanBoard.HEX_CORNERS[h]) {
val building = state.buildings[v] ?: continue
val amount = if (building.type == BuildingType.CITY) 2 else 1
gains[building.owner][resource] = (gains[building.owner][resource] ?: 0) + amount
demand += amount
}
}
// Official ruling: when the bank cannot meet the demand, nobody
// receives that resource this roll.
if (demand > bankSupply(state, resource)) {
for (perSeat in gains) perSeat.remove(resource)
}
}
var s = state.copy(phase = CatanPhase.PLAYING)
val whoGot = mutableListOf<String>()
for (seat in 0 until s.playerCount) {
val got = gains[seat].filterValues { it > 0 }
if (got.isEmpty()) continue
s = s.copy(resources = withCounts(s.resources, seat, got, 1))
whoGot += "seat $seat takes ${describe(got)}"
}
val said = if (whoGot.isEmpty()) "nothing for anyone" else whoGot.joinToString(", ")
return s.copy(
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
)
}
private fun applyDiscard(state: CatanState, seat: Int, move: Discard): CatanState {
var s = state.copy(resources = withCards(state.resources, seat, move.cards, -1))
val next = nextDiscarder(s, seat)
s = if (next != null) {
s.copy(discardSeat = next)
} else {
s.copy(phase = CatanPhase.ROBBER, discardSeat = null)
}
return s.copy(log = s.log + "Seat $seat discards ${describe(countOf(move.cards))}.")
}
private fun nextDiscarder(state: CatanState, after: Int): Int? =
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
val hex = CatanBoard.hexIndex[move.hex]
?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
var stolen: String? = null
if (move.victim != null) {
// One card at random from the victim's hand, derived like the
// dice: same seed, next random event.
val rng = Random(state.seed * 1_000_003L + state.rngStep)
val victimHand = state.handOf(move.victim)
val card = victimHand[rng.nextInt(victimHand.size)]
val less = withCards(s.resources, move.victim, listOf(card), -1)
s = s.copy(resources = withCards(less, seat, listOf(card), 1))
stolen = " and steals a ${card.name.lowercase()} from seat ${move.victim}"
}
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.",
)
}
private fun applyBuildRoad(state: CatanState, seat: Int, move: BuildRoad): CatanState {
val free = state.phase == CatanPhase.FREE_ROADS
var s = state.copy(roads = state.roads.toMutableList().also { it[move.edge] = seat })
if (!free) s = payCost(s, seat, ROAD_COST)
s = refreshLongestRoad(s)
if (free) {
val left = s.pendingFreeRoads - 1
s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = CatanPhase.PLAYING)
}
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
}
private fun applyBuildSettlement(state: CatanState, seat: Int, move: BuildSettlement): CatanState {
var s = state.copy(
buildings = state.buildings.toMutableList().also {
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
},
)
s = payCost(s, seat, SETTLEMENT_COST)
// A settlement can break another player's road, so the award is
// recomputed for everyone.
s = refreshLongestRoad(s)
return checkWin(s.copy(log = s.log + "Seat $seat builds a settlement at ${move.vertex}."))
}
private fun applyBuildCity(state: CatanState, seat: Int, move: BuildCity): CatanState {
var s = state.copy(
buildings = state.buildings.toMutableList().also {
it[move.vertex] = CatanBuilding(BuildingType.CITY, seat)
},
)
s = payCost(s, seat, CITY_COST)
return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}."))
}
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
val card = state.devDeck.first().copy(boughtTurn = state.turnNumber)
val hands = state.devHands.toMutableList().also { it[seat] = it[seat] + card }
return payCost(state, seat, DEV_COST).copy(
devDeck = state.devDeck.drop(1),
devHands = hands,
devHandCounts = hands.map { it.size },
// What was bought is not announced — only that something was.
log = state.log + "Seat $seat buys a development card.",
)
}
private fun applyPlayKnight(state: CatanState, seat: Int, move: PlayKnight): CatanState {
val hands = removeCard(state, seat, move.card)
val played = state.knightsPlayed.toMutableList().also { it[seat] = it[seat] + 1 }
var s = state.copy(
devHands = hands,
devHandCounts = hands.map { it.size },
knightsPlayed = played,
devCardPlayedThisTurn = true,
phase = CatanPhase.ROBBER,
)
s = refreshLargestArmy(s, seat)
return s.copy(log = s.log + "Seat $seat plays a knight.")
}
private fun applyPlayRoadBuilding(state: CatanState, seat: Int, move: PlayRoadBuilding): CatanState {
val hands = removeCard(state, seat, move.card)
return state.copy(
devHands = hands,
devHandCounts = hands.map { it.size },
devCardPlayedThisTurn = true,
phase = CatanPhase.FREE_ROADS,
pendingFreeRoads = 2,
log = state.log + "Seat $seat plays Road Building.",
)
}
private fun applyPlayMonopoly(state: CatanState, seat: Int, move: PlayMonopoly): CatanState {
val resource = move.resource
var resources = state.resources
var seized = 0
for (other in 0 until state.playerCount) {
if (other == seat) continue
val held = state.resources[other][resource] ?: 0
if (held == 0) continue
resources = withCounts(withCards(resources, other, List(held) { resource }, -1), seat, mapOf(resource to held), 1)
seized += held
}
val hands = removeCard(state, seat, move.card)
return state.copy(
resources = resources,
devHands = hands,
devHandCounts = hands.map { it.size },
devCardPlayedThisTurn = true,
log = state.log + "Seat $seat plays Monopoly — seizes $seized ${resource.name.lowercase()}.",
)
}
private fun applyPlayYearOfPlenty(state: CatanState, seat: Int, move: PlayYearOfPlenty): CatanState {
val hands = removeCard(state, seat, move.card)
return state.copy(
resources = withCards(state.resources, seat, move.take, 1),
devHands = hands,
devHandCounts = hands.map { it.size },
devCardPlayedThisTurn = true,
log = state.log + "Seat $seat plays Year of Plenty — takes ${describe(countOf(move.take))}.",
)
}
private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState =
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}.",
)
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
val offer = state.pendingTrade!!
var resources = withCards(state.resources, offer.to, offer.get, -1)
resources = withCards(resources, offer.by, offer.give, -1)
resources = withCards(resources, offer.by, offer.get, 1)
resources = withCards(resources, offer.to, offer.give, 1)
return state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
resources = resources,
log = state.log + "Seat $seat accepts — the cards cross the table.",
)
}
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
log = state.log + "Seat $seat declines the offer.",
)
private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState {
val ratio = bestPortRatio(state, seat, move.give)
val paid = withCards(state.resources, seat, List(ratio) { move.give }, -1)
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.",
)
}
private fun applyEndTurn(state: CatanState, seat: Int): CatanState {
// Defensive: a player already at the target wins rather than passes.
val settled = checkWin(state)
if (settled.phase == CatanPhase.GAME_OVER) return settled
val next = (state.turn + 1) % state.playerCount
val s = state.copy(
turn = next,
turnNumber = state.turnNumber + 1,
phase = CatanPhase.AWAIT_ROLL,
devCardPlayedThisTurn = false,
pendingTrade = null,
)
// Someone can cross the target on another player's turn (a broken
// Longest Road re-awarded to a third player); they win when their
// own turn arrives.
return checkWin(s)
}
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
state.copy(
phase = CatanPhase.PLAYING,
pendingFreeRoads = 0,
log = state.log + "Seat $seat forfeits the remaining free road.",
)
// -----------------------------------------------------------------------
// Scoring
// -----------------------------------------------------------------------
/**
* Victory points everyone at the table can count: buildings, the two
* awards, and nothing hidden. Hidden victory-point cards are added on
* top by [victoryPoints], which is only meaningful for the holder's own
* view or the host's copy.
*/
fun publicVictoryPoints(state: CatanState): List<Int> {
val points = IntArray(state.playerCount)
state.buildings.forEach { building ->
if (building != null) {
points[building.owner] += if (building.type == BuildingType.CITY) 2 else 1
}
}
state.longestRoadOwner?.let { points[it] += 5 }
state.largestArmyOwner?.let { points[it] += 5 }
return points.toList()
}
fun victoryPoints(state: CatanState, seat: Int): Int =
publicVictoryPoints(state)[seat] +
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
private fun checkWin(state: CatanState): CatanState {
if (state.phase == CatanPhase.GAME_OVER) return state
val seat = state.turn
val points = victoryPoints(state, seat)
if (points < state.options.pointsToWin) return state
return state.copy(
phase = CatanPhase.GAME_OVER,
winner = seat,
log = state.log + "Seat $seat reveals $points victory points and wins.",
)
}
/**
* Recomputes everyone's road lengths and re-awards Longest Road.
*
* The award needs a strictly longer road than the holder's; equal length
* leaves it where it is, exactly as the published rule keeps the first
* builder ahead of a builder who merely matches them. If the holder's
* 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 {
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
val holder = state.longestRoadOwner
val best = lengths
.withIndex()
.filter { it.value >= LONGEST_ROAD_MIN }
.maxByOrNull { it.value } // first of the maximum: lowest seat on a tie
val newOwner = when {
holder != null && lengths[holder] >= LONGEST_ROAD_MIN &&
(best == null || best.value <= lengths[holder]) -> holder
else -> best?.index
}
if (newOwner == holder) return state
val line = if (newOwner == null) {
"Longest Road falls vacant."
} else {
"Seat $newOwner takes Longest Road (${lengths[newOwner]} segments)."
}
return state.copy(longestRoadOwner = newOwner, log = state.log + line)
}
/**
* The seat's longest single road: a simple path over its own edges that
* never passes through an opponent's building and never revisits a
* vertex (so a figure of two loops sharing an intersection counts as
* one road, not two — the intersection is a branch).
*/
fun longestRoadFor(state: CatanState, seat: Int): Int {
var best = 0
val blocked = BooleanArray(CatanBoard.VERTEX_COUNT)
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val building = state.buildings[v]
if (building != null && building.owner != seat) blocked[v] = 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)
best = maxOf(best, roadDfs(state, seat, start, blocked, visited))
}
return best
}
private fun roadDfs(
state: CatanState,
seat: Int,
v: Int,
blocked: BooleanArray,
visited: BooleanArray,
): Int {
visited[v] = true
var best = 0
for (e in CatanBoard.VERTEX_EDGES[v]) {
if (state.roads[e] != seat) continue
val w = CatanBoard.otherEnd(e, v)
if (visited[w]) continue
if (blocked[w]) {
// The segment may run up to an opponent's building, but not
// through it.
best = maxOf(best, 1)
} else {
best = maxOf(best, 1 + roadDfs(state, seat, w, blocked, visited))
}
}
visited[v] = false
return best
}
/**
* Recomputes Largest Army: first to three knights takes it, and it is
* stolen only by a strictly larger army.
*/
private fun refreshLargestArmy(state: CatanState, seat: Int): CatanState {
val holder = state.largestArmyOwner
val newOwner = when {
holder == null && state.knightsPlayed[seat] >= LARGEST_ARMY_MIN -> seat
holder != null && holder != seat &&
state.knightsPlayed[seat] > state.knightsPlayed[holder] -> seat
else -> holder
}
if (newOwner == holder) return state
return state.copy(
largestArmyOwner = newOwner,
log = state.log + "Seat $newOwner takes Largest Army (${state.knightsPlayed[seat]} knights).",
)
}
// -----------------------------------------------------------------------
// Resources
// -----------------------------------------------------------------------
/** The bank's remaining cards of [resource]: what is not in players' hands. */
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 =
cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need }
private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState =
state.copy(resources = withCounts(state.resources, seat, cost, -1))
private fun countOf(cards: List<Resource>): Map<Resource, Int> =
cards.groupingBy { it }.eachCount()
/** Hands with [delta] of each resource added to (or, with negative sign, removed from) [seat]'s. */
private fun withCounts(
resources: List<Map<Resource, Int>>,
seat: Int,
delta: Map<Resource, Int>,
sign: Int,
): List<Map<Resource, Int>> {
val hand = resources[seat].toMutableMap()
for ((resource, n) in delta) hand[resource] = (hand[resource] ?: 0) + sign * n
return resources.toMutableList().also { it[seat] = hand }
}
/** Hands with each card of [cards] added to (or removed from) [seat]'s. */
private fun withCards(
resources: List<Map<Resource, Int>>,
seat: Int,
cards: List<Resource>,
sign: Int,
): List<Map<Resource, Int>> = withCounts(resources, seat, countOf(cards), sign)
/** The card with id [card], removed from [seat]'s hand. */
private fun removeCard(state: CatanState, seat: Int, card: Int): List<List<DevCard>> =
state.devHands.toMutableList().also { hands ->
hands[seat] = hands[seat].filterNot { it.id == card }
}
/**
* The best ratio [seat] can trade [give] at: 2:1 through a matching
* resource harbour, 3:1 through a generic one, otherwise the bank's 4:1.
*/
fun bestPortRatio(state: CatanState, seat: Int, give: Resource): Int {
var best = 4
for (port in state.ports) {
if (port.resource != null && port.resource != give) continue
if (port.ratio >= best) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
if (mine) best = port.ratio
}
return best
}
// -----------------------------------------------------------------------
// Results, redaction and wire format
// -----------------------------------------------------------------------
override fun isFinished(state: CatanState): Boolean = state.phase == CatanPhase.GAME_OVER
override fun summary(state: CatanState): String = when (val winner = state.winner) {
null -> publicVictoryPoints(state)
.mapIndexed { seat, points -> "S$seat $points" }
.joinToString(" ")
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 encodeState(state: CatanState): String = json.encodeToString(state)
override fun decodeState(json: String): CatanState = this.json.decodeFromString(json)
override fun encodeMove(move: CatanMove): String = json.encodeToString(move)
override fun decodeMove(json: String): CatanMove = this.json.decodeFromString(json)
// -----------------------------------------------------------------------
// Wording
// -----------------------------------------------------------------------
private fun describe(counts: Map<Resource, Int>): String =
counts.entries.joinToString(" + ") { (resource, n) -> "$n ${resource.name.lowercase()}" }
private fun terrainName(terrain: Terrain): String =
terrain.name.lowercase().replace('_', ' ')
}
@@ -64,6 +64,21 @@ interface GameRules<S : Any, M : Any> {
fun legalMoves(state: S, seat: Int): List<M> fun legalMoves(state: S, seat: Int): List<M>
/**
* Whether [move] is currently legal for [seat].
*
* Defaults to membership in [legalMoves], which is everything a game
* with a small, enumerable move set needs. A game whose move space is
* combinatorial — Catan's trade offers name any two multisets of cards —
* overrides this and validates structurally instead, because enumerating
* every intent would explode the list the UI and the network driver work
* from. Gates that check a specific move should call this method rather
* than testing list membership directly, so both kinds of game behave the
* same way.
*/
fun isLegalMove(state: S, seat: Int, move: M): Boolean =
move in legalMoves(state, seat)
/** /**
* Applies [move] on behalf of [seat]. * Applies [move] on behalf of [seat].
* *
@@ -349,7 +349,7 @@ class MatchController<S : Any, M : Any>(
seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) } seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) }
return return
} }
if (move !in rules.legalMoves(current, seat)) { if (!rules.isLegalMove(current, seat, move)) {
seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) } seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) }
return return
} }
@@ -424,7 +424,7 @@ class MatchController<S : Any, M : Any>(
// throws on a move that is no longer legal, and there is nothing // throws on a move that is no longer legal, and there is nothing
// above this to catch it. // above this to catch it.
if (rules.currentSeat(live) != seat) return@withLock false if (rules.currentSeat(live) != seat) return@withLock false
if (move !in rules.legalMoves(live, seat)) return@withLock false if (!rules.isLegalMove(live, seat, move)) return@withLock false
authoritative = rules.applyMove(live, seat, move) authoritative = rules.applyMove(live, seat, move)
publish() publish()
true true
@@ -0,0 +1,145 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
class CatanAiTest {
private val json = Json { encodeDefaults = true }
private fun config(options: CatanOptions, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
/** Plays one table of engines out to a result, refusing every illegal move. */
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 4_000): CatanState {
val ai = CatanAi()
var state = CatanRules.initialState(config(options, seed))
var moves = 0
while (!CatanRules.isFinished(state)) {
assertTrue("seed $seed ran past $cap moves", moves++ < cap)
val seat = CatanRules.currentSeat(state) ?: break
val move = ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat))
assertTrue(
"seed $seed: seat $seat chose the illegal move $move",
CatanRules.isLegalMove(state, seat, move),
)
state = CatanRules.applyMove(state, seat, move)
}
return state
}
@Test
fun `three engines finish a game at ten points`() {
listOf(11L, 22L, 33L).forEach { seed ->
val state = playOut(CatanOptions(), seed)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
val winner = state.winner
assertTrue("seed $seed finished without a winner", winner != null)
assertTrue(
"seed $seed: the winner fell short of the target",
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
)
}
}
@Test
fun `four engines finish a game at ten points`() {
listOf(44L, 55L).forEach { seed ->
val state = playOut(CatanOptions(playerCount = 4), seed)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
val winner = state.winner
assertTrue("seed $seed finished without a winner", winner != null)
assertTrue(
"seed $seed: the winner fell short of the target",
CatanRules.victoryPoints(state, winner!!) >= state.options.pointsToWin,
)
}
}
/** A mid-game state on seat 0's turn, one card short of a settlement. */
private fun offeringState(): CatanState =
CatanRules.initialState(config(CatanOptions(), seed = 7L))
.copy(
phase = CatanPhase.PLAYING,
turn = 0,
turnNumber = 1,
// Lumber to spare, brick missing, and a neighbour visibly
// holding brick. No roads or buildings, so no build is legal
// and no port is open — the offer is the only move forward.
resources = listOf(
hand(Resource.LUMBER to 4, Resource.WOOL to 1, Resource.GRAIN to 1),
hand(Resource.BRICK to 2),
hand(),
),
)
private fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
@Test
fun `the AI offers the trade that closes its build`() {
val state = offeringState()
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
assertEquals(
OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)),
move,
)
}
@Test
fun `the AI offers at most one trade a turn`() {
// The same table, but the offer was already made and refused once.
val state = offeringState().copy(
log = listOf(
"Seat 0 rolls 3 + 2.",
"Seat 0 offers 2 lumber for a brick to seat 1.",
"Seat 1 declines the offer.",
),
)
val move = CatanAi().chooseMove(state, 0, CatanRules.legalMoves(state, 0))
assertTrue("the AI re-offered within one turn: $move", move !is OfferTrade)
assertEquals(EndTurn, move)
}
@Test
fun `the AI answers a pending offer with accept or decline`() {
val state = CatanRules.initialState(config(CatanOptions(), seed = 7L))
.copy(
phase = CatanPhase.TRADE_PENDING,
turn = 0,
turnNumber = 1,
pendingTrade = TradeOffer(
by = 0, to = 1,
give = listOf(Resource.LUMBER, Resource.LUMBER),
get = listOf(Resource.BRICK),
),
resources = listOf(
hand(Resource.LUMBER to 3),
hand(Resource.BRICK to 2, Resource.WOOL to 1),
hand(),
),
)
val legal = CatanRules.legalMoves(state, 1)
val move = CatanAi().chooseMove(state, 1, legal)
assertTrue("neither accept nor decline: $move", move == AcceptTrade || move == DeclineTrade)
assertTrue(move in legal)
val after = CatanRules.applyMove(state, 1, move)
assertEquals(CatanPhase.PLAYING, after.phase)
assertNull(after.pendingTrade)
}
}
@@ -0,0 +1,192 @@
package org.prolibertate.games.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CatanBoardTest {
// -- Geometry -----------------------------------------------------------
@Test
fun `the island has 19 hexes, 54 vertices, 72 edges and 30 coastal edges`() {
assertEquals(19, CatanBoard.HEX_COUNT)
assertEquals(54, CatanBoard.VERTEX_COUNT)
assertEquals(72, CatanBoard.EDGE_COUNT)
assertEquals("the coast is one ring of 30 edges", 30, CatanBoard.COASTAL.size)
assertEquals("nine harbour positions", 9, CatanBoard.PORT_POSITIONS.size)
}
@Test
fun `every hex has six distinct corners and every edge two distinct ends`() {
for (h in 0 until CatanBoard.HEX_COUNT) {
assertEquals("hex $h", 6, CatanBoard.HEX_CORNERS[h].size)
assertEquals("hex $h repeats a corner", 6, CatanBoard.HEX_CORNERS[h].distinct().size)
}
CatanBoard.EDGE_VERTICES.forEach { (a, b) -> assertTrue("edge loops on itself", a != b) }
}
@Test
fun `two vertices never share a lattice position`() {
assertEquals(CatanBoard.VERTEX_COUNT, CatanBoard.VERTEX_POS.distinct().size)
assertEquals(CatanBoard.HEX_COUNT, (0 until CatanBoard.HEX_COUNT)
.map { CatanBoard.hexCenter(it) }
.distinct().size)
}
@Test
fun `adjacent hexes share exactly two vertices and one edge`() {
for (h in 0 until CatanBoard.HEX_COUNT) {
for (n in CatanBoard.HEX_NEIGHBORS[h]) {
val shared = CatanBoard.HEX_CORNERS[h].toSet()
.intersect(CatanBoard.HEX_CORNERS[n].toSet())
assertEquals("hexes $h and $n", 2, shared.size)
val sharedEdge = CatanBoard.EDGE_HEXES.indices.count { e ->
CatanBoard.EDGE_HEXES[e].containsAll(listOf(h, n))
}
assertEquals("hexes $h and $n", 1, sharedEdge)
}
}
}
@Test
fun `vertices have two or three edges, each to a distinct neighbour`() {
for (v in 0 until CatanBoard.VERTEX_COUNT) {
assertTrue("vertex $v", CatanBoard.VERTEX_EDGES[v].size in 2..3)
assertEquals("vertex $v", CatanBoard.VERTEX_EDGES[v].size, CatanBoard.VERTEX_NEIGHBORS[v].size)
CatanBoard.VERTEX_NEIGHBORS[v].forEach { n ->
assertTrue(
"vertices $v and $n are not joined by an edge",
CatanBoard.VERTEX_EDGES[v].any { CatanBoard.otherEnd(it, v) == n },
)
}
}
}
@Test
fun `interior edges touch two hexes and coastal edges one`() {
CatanBoard.EDGE_HEXES.forEach { hexes -> assertTrue(hexes.size in 1..2) }
// Nineteen hexes times six edges each, counted per side: 114.
assertEquals(114, CatanBoard.EDGE_HEXES.sumOf { it.size })
val coastal = CatanBoard.EDGE_HEXES.indices.filter { CatanBoard.EDGE_HEXES[it].size == 1 }
assertEquals(CatanBoard.COASTAL.toSet(), coastal.toSet())
}
@Test
fun `the coast is one closed ring and the harbours are spread along it`() {
CatanBoard.COASTAL.forEachIndexed { i, e ->
val next = CatanBoard.COASTAL[(i + 1) % CatanBoard.COASTAL.size]
val shared = ends(e).intersect(ends(next))
assertTrue("coastal edges $e and $next are not consecutive", shared.isNotEmpty())
}
assertEquals(9, CatanBoard.PORT_POSITIONS.distinct().size)
assertTrue(CatanBoard.PORT_POSITIONS.all { it in CatanBoard.COASTAL })
val ringSpots = CatanBoard.PORT_POSITIONS.map { CatanBoard.COASTAL.indexOf(it) }.sorted()
val gaps = ringSpots.zipWithNext { a, b -> b - a } +
(CatanBoard.COASTAL.size - ringSpots.last() + ringSpots.first())
assertTrue("harbours sit closer than three edges apart: $gaps", gaps.all { it >= 3 })
}
@Test
fun `token odds follow the dice`() {
assertEquals(5, CatanBoard.numberOdds(6))
assertEquals(5, CatanBoard.numberOdds(8))
assertEquals(1, CatanBoard.numberOdds(2))
assertEquals(1, CatanBoard.numberOdds(12))
assertEquals(6, (2..12).sumOf { CatanBoard.numberOdds(it) })
}
// -- Island generation --------------------------------------------------
private val terrainCounts = mapOf(
Terrain.FOREST to 4,
Terrain.PASTURE to 4,
Terrain.FIELDS to 4,
Terrain.HILLS to 3,
Terrain.MOUNTAINS to 3,
Terrain.DESERT to 1,
)
/** The 18 number tokens dealt to the land, the seven withheld. */
private val tokens = listOf(6, 6, 8, 8, 2, 3, 3, 4, 4, 5, 5, 9, 9, 10, 10, 11, 11, 12)
@Test
fun `every seed deals the fixed terrain mix`() {
for (seed in 1L..500L) {
val island = CatanBoard.generate(seed)
assertEquals("seed $seed", 19, island.terrains.size)
terrainCounts.forEach { (terrain, n) ->
assertEquals("seed $seed $terrain", n, island.terrains.count { it == terrain })
}
}
}
@Test
fun `every seed deals the full token set and the desert stays bare`() {
for (seed in 1L..500L) {
val island = CatanBoard.generate(seed)
val dealt = island.numbers.filter { it > 0 }
assertEquals("seed $seed", 18, dealt.size)
assertEquals("seed $seed", tokens.sorted(), dealt.sorted())
assertEquals("seed $seed: the desert bears no token", 0, island.numbers[island.robber])
assertEquals(
"seed $seed: the robber does not start on the desert",
island.terrains.indexOf(Terrain.DESERT),
island.robber,
)
}
}
@Test
fun `no seed puts two red tokens on adjacent hexes`() {
for (seed in 1L..500L) {
val island = CatanBoard.generate(seed)
val red = island.numbers.withIndex()
.filter { it.value == 6 || it.value == 8 }
.map { it.index }
assertEquals("seed $seed", 4, red.size)
for (i in red.indices) {
for (j in i + 1 until red.size) {
assertTrue(
"seed $seed: red hexes ${red[i]} and ${red[j]} touch",
!CatanBoard.hexesAdjacent(red[i], red[j]),
)
}
}
}
}
@Test
fun `every seed spreads nine harbours, five special and four generic`() {
for (seed in 1L..500L) {
val island = CatanBoard.generate(seed)
assertEquals("seed $seed", 9, island.ports.size)
assertEquals("seed $seed", 5, island.ports.count { it.resource != null })
assertEquals("seed $seed", 4, island.ports.count { it.resource == null })
assertTrue("seed $seed", island.ports.all { port ->
port.ratio == if (port.resource == null) 3 else 2
})
assertEquals(
"seed $seed: every resource has its own harbour",
Resource.entries,
island.ports.mapNotNull { it.resource }.sorted(),
)
assertEquals("seed $seed", 9, island.ports.map { it.edge }.distinct().size)
assertTrue("seed $seed", island.ports.all { it.edge in CatanBoard.COASTAL })
}
}
@Test
fun `the same seed builds the same island`() {
for (seed in listOf(0L, 7L, 42L, 99L, -1L)) {
assertEquals("seed $seed", CatanBoard.generate(seed), CatanBoard.generate(seed))
}
}
/** The two vertices of [edge], as a set. */
private fun ends(edge: Int): Set<Int> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return setOf(a, b)
}
}
@@ -0,0 +1,858 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
class CatanRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(
options: CatanOptions = CatanOptions(),
seed: Long = 7L,
) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
/**
* A mid-game state on some seat's turn, built from a real island so the
* terrain, tokens, robber and harbours are a genuine layout. What a test
* bends with [adjust] is its own business; the moves it then asks about
* are checked against the rules like any other.
*/
private fun craft(
options: CatanOptions = CatanOptions(),
seed: Long = 7L,
turn: Int = 0,
adjust: CatanState.() -> CatanState = { this },
): CatanState = CatanRules.initialState(config(options, seed))
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
.adjust()
private fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
private fun hands(vararg perSeat: Map<Resource, Int>, players: Int = 3): List<Map<Resource, Int>> =
(0 until players).map { perSeat.getOrNull(it) ?: hand() }
private fun CatanState.withBuilding(
vertex: Int,
type: BuildingType,
owner: Int,
): CatanState = copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
private fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
copy(roads = roads.toMutableList().also { it[edge] = owner })
/** A roll whose dice add up to [total]. */
private fun rollFor(total: Int): RollDice {
val first = (total - 1).coerceAtMost(6)
return RollDice(forced = listOf(first, total - first))
}
/** A producing hex corner whose other hexes bear a different number. */
private data class Spot(val resource: Resource, val number: Int, val vertex: Int)
private fun CatanState.pickSpot(): Spot {
for (h in 0 until CatanBoard.HEX_COUNT) {
val resource = terrains[h].produces ?: continue
val number = numbers[h]
val vertex = CatanBoard.HEX_CORNERS[h].firstOrNull { corner ->
CatanBoard.VERTEX_HEXES[corner].all { it == h || numbers[it] != number }
}
if (vertex != null) return Spot(resource, number, vertex)
}
error("no clean producing corner on this island")
}
/** The six edges of hex [h], in walking order around it. */
private fun hexRing(h: Int): List<Int> {
val corners = CatanBoard.HEX_CORNERS[h]
return (0..5).map { k ->
val a = corners[k]
val b = corners[(k + 1) % 6]
CatanBoard.EDGE_VERTICES.indices.first { e ->
val (x, y) = CatanBoard.EDGE_VERTICES[e]
(x == a && y == b) || (x == b && y == a)
}
}
}
private fun sharedVertex(a: Int, b: Int): Int =
ends(a).intersect(ends(b)).single()
/** The two vertices of [edge], as a set. */
private fun ends(edge: Int): Set<Int> {
val (x, y) = CatanBoard.EDGE_VERTICES[edge]
return setOf(x, y)
}
/** [count] vertices at least two edges apart, for standing buildings on. */
private fun scattered(count: Int): List<Int> {
val picked = mutableListOf<Int>()
var v = 0
while (picked.size < count) {
if (CatanBoard.VERTEX_NEIGHBORS[v].none { it in picked }) picked += v
v++
}
return picked
}
// -- Setup ---------------------------------------------------------------
@Test
fun `setup runs in snake order and then seat zero leads`() {
var state = CatanRules.initialState(config())
val order = mutableListOf<Int>()
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
val seat = CatanRules.currentSeat(state)!!
order += seat
state = CatanRules.applyMove(
state, seat,
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
)
}
assertEquals(listOf(0, 1, 2, 2, 1, 0), order)
assertEquals(CatanPhase.AWAIT_ROLL, state.phase)
assertEquals(0, state.turn)
assertEquals(1, state.turnNumber)
}
@Test
fun `the second settlement earns one card from each adjacent land`() {
var state = CatanRules.initialState(config())
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
val seat = CatanRules.currentSeat(state)!!
val move = CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first()
val second = state.phase == CatanPhase.SETUP_SECOND
val before = state.resources[seat]
state = CatanRules.applyMove(state, seat, move)
if (second) {
val expected = CatanBoard.VERTEX_HEXES[move.vertex]
.mapNotNull { state.terrains[it].produces }
.groupingBy { it }.eachCount()
.let { earned ->
before.toMutableMap().also { hand ->
earned.forEach { (res, n) -> hand[res] = (hand[res] ?: 0) + n }
}
}
assertEquals("seat $seat's second income", expected, state.resources[seat])
} else {
assertEquals("no income during the first pass", before, state.resources[seat])
}
}
}
@Test
fun `setup leaves every settlement two edges from its neighbour`() {
var state = CatanRules.initialState(config())
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
val seat = CatanRules.currentSeat(state)!!
state = CatanRules.applyMove(
state, seat,
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
)
}
val placed = state.buildings.withIndex().filter { it.value != null }.map { it.index }
assertEquals(6, placed.size)
placed.forEach { v ->
assertTrue(
"a settlement stands beside $v",
CatanBoard.VERTEX_NEIGHBORS[v].none { state.buildings[it] != null },
)
}
// Every road laid in setup touches the settlement it was laid with.
state.roads.withIndex().filter { it.value != NO_PLAYER }.forEach { (edge, _) ->
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
assertTrue(
"road $edge touches no settlement",
state.buildings[a] != null || state.buildings[b] != null,
)
}
}
@Test
fun `the island seats three or four`() {
assertThrows { CatanOptions(playerCount = 2) }
assertThrows { CatanOptions(playerCount = 5) }
val fourSeats = config(CatanOptions(playerCount = 3))
.let { it.copy(seats = it.seats.drop(1)) }
assertThrows { CatanRules.initialState(fourSeats) }
}
// -- Production ----------------------------------------------------------
@Test
fun `a roll pays settlements singly and cities double`() {
val base = craft()
val spot = base.pickSpot()
val settled = base.withBuilding(spot.vertex, BuildingType.SETTLEMENT, 0)
.copy(resources = hands(hand()))
val afterSettlement = CatanRules.applyMove(settled, 0, rollFor(spot.number))
assertEquals(1, afterSettlement.resources[0][spot.resource])
assertEquals(CatanPhase.PLAYING, afterSettlement.phase)
val raised = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
.copy(resources = hands(hand()))
val afterCity = CatanRules.applyMove(raised, 0, rollFor(spot.number))
assertEquals(2, afterCity.resources[0][spot.resource])
}
@Test
fun `the robber's hex produces nothing`() {
val base = craft()
val spot = base.pickSpot()
val hex = CatanBoard.VERTEX_HEXES[spot.vertex].first { base.numbers[it] == spot.number }
val state = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
.copy(robber = hex, resources = hands(hand()))
val after = CatanRules.applyMove(state, 0, rollFor(spot.number))
assertEquals("the blocked city takes nothing", 0, after.resources[0][spot.resource])
}
@Test
fun `a bank-short resource pays nobody`() {
val base = craft()
val spot = base.pickSpot()
// A city wants two; the bank holds one, because seat 1 hoards the rest.
val state = base.withBuilding(spot.vertex, BuildingType.CITY, 0)
.copy(resources = hands(hand(), hand(spot.resource to 18)))
val after = CatanRules.applyMove(state, 0, rollFor(spot.number))
assertEquals("the city takes nothing while the bank is short", 0, after.resources[0][spot.resource])
assertEquals(18, after.resources[1][spot.resource])
}
// -- The robber ----------------------------------------------------------
@Test
fun `a seven with no overfull hand rides the robber directly`() {
val state = craft().copy(
phase = CatanPhase.AWAIT_ROLL,
resources = hands(hand(), hand(Resource.LUMBER to 7), hand(Resource.BRICK to 7)),
)
val after = CatanRules.applyMove(state, 0, RollDice(forced = listOf(6, 1)))
assertEquals(CatanPhase.ROBBER, after.phase)
assertNull(after.discardSeat)
assertTrue(after.log.any { it.startsWith("Seat 0 rolls 7") })
}
@Test
fun `a seven makes overfull hands discard half in seat order`() {
var state = craft().copy(
phase = CatanPhase.AWAIT_ROLL,
resources = hands(
hand(),
hand(Resource.LUMBER to 2, Resource.BRICK to 3, Resource.WOOL to 4), // 9 cards
hand(Resource.GRAIN to 8), // 8 cards
),
)
state = CatanRules.applyMove(state, 0, RollDice(forced = listOf(3, 4)))
assertEquals(CatanPhase.DISCARDING, state.phase)
assertEquals(1, state.discardSeat)
assertEquals(1, CatanRules.currentSeat(state))
// Nine cards means four gone, exactly, and no other number of them.
assertTrue(CatanRules.isLegalMove(state, 1, Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.BRICK))))
assertFalse(CatanRules.isLegalMove(state, 1, Discard(listOf(Resource.LUMBER))))
assertFalse(CatanRules.isLegalMove(state, 1, Discard(List(5) { Resource.WOOL })))
state = CatanRules.applyMove(
state, 1,
Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.BRICK)),
)
assertEquals("seat 2 still owes the robber", CatanPhase.DISCARDING, state.phase)
assertEquals(2, state.discardSeat)
state = CatanRules.applyMove(state, 2, Discard(List(4) { Resource.GRAIN }))
assertEquals(CatanPhase.ROBBER, state.phase)
assertEquals("the roller moves the robber", 0, CatanRules.currentSeat(state))
assertEquals(5, state.resourceCount(1))
assertEquals(4, state.resourceCount(2))
}
@Test
fun `the robber must move and steals one card from a built-up hex`() {
val base = craft()
val spot = base.pickSpot()
val hex = CatanBoard.VERTEX_HEXES[spot.vertex].first { base.numbers[it] == spot.number }
val elsewhere = (0 until CatanBoard.HEX_COUNT)
.first { it != base.robber && it != hex }
val state = base.withBuilding(spot.vertex, BuildingType.SETTLEMENT, 1)
.copy(
robber = elsewhere,
resources = hands(hand(), hand(Resource.WOOL to 3)),
)
val moves = CatanRules.legalMoves(state, 0).filterIsInstance<MoveRobber>()
assertTrue(
"the robber may not stay put",
moves.all { it.hex != CatanBoard.HEXES[state.robber] },
)
assertEquals("every other hex is reachable", 18, moves.map { it.hex }.distinct().size)
val steal = moves.first { it.hex == CatanBoard.HEXES[hex] && it.victim == 1 }
val after = CatanRules.applyMove(state, 0, steal)
assertEquals(hex, after.robber)
assertEquals(CatanPhase.PLAYING, after.phase)
assertEquals(2, after.resources[1][Resource.WOOL])
assertEquals(1, after.resourceCount(0))
}
@Test
fun `a forced roll must be two honest dice, and only tests may send it`() {
val state = craft().copy(phase = CatanPhase.AWAIT_ROLL)
assertTrue(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(3, 4))))
assertFalse(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(7, 0))))
assertFalse(CatanRules.isLegalMove(state, 0, RollDice(forced = listOf(3))))
// The derived roll is the enumerated move; a forced one is structural only.
assertTrue(RollDice() in CatanRules.legalMoves(state, 0))
assertFalse(RollDice(forced = listOf(3, 4)) in CatanRules.legalMoves(state, 0))
}
// -- Building ------------------------------------------------------------
@Test
fun `a road must attach to the builder's own network`() {
val state = craft().copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
assertTrue("nothing to attach to", CatanRules.legalMoves(state, 0).none { it is BuildRoad })
val e0 = CatanBoard.VERTEX_EDGES[0][0]
val attached = state.withRoad(e0, 0)
val roads = CatanRules.legalMoves(attached, 0).filterIsInstance<BuildRoad>()
assertEquals(
"roads may only fan out from the network",
CatanBoard.EDGE_NEIGHBORS[e0].map { BuildRoad(it) }.toSet(),
roads.toSet(),
)
}
@Test
fun `a settlement needs an open vertex on the network, a city one of yours`() {
val base = craft()
val homes = scattered(3)
val state = base.withBuilding(homes[0], BuildingType.SETTLEMENT, 0)
.withRoad(CatanBoard.VERTEX_EDGES[homes[1]][0], 0)
.copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 2, Resource.ORE to 3)))
// Reachable: open, two edges from anything standing, on seat 0's road.
assertTrue(BuildSettlement(homes[1]) in CatanRules.legalMoves(state, 0))
// One edge from a standing settlement is too close, network or not.
val tooClose = CatanBoard.VERTEX_NEIGHBORS[homes[0]].first()
assertTrue(BuildSettlement(tooClose) !in CatanRules.legalMoves(state, 0))
// Open but off the network is out of reach.
assertTrue(BuildSettlement(homes[2]) !in CatanRules.legalMoves(state, 0))
// Raising costs two grain and three ore.
val raised = CatanRules.applyMove(state, 0, BuildCity(homes[0]))
assertEquals(BuildingType.CITY, raised.buildings[homes[0]]?.type)
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 0, Resource.ORE to 0), raised.resources[0])
}
// -- Trade ---------------------------------------------------------------
@Test
fun `a domestic offer names any two multisets and waits on the table`() {
val state = craft().copy(
resources = hands(
hand(Resource.LUMBER to 3),
hand(Resource.BRICK to 2),
hand(),
),
)
val offer = OfferTrade(to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK))
assertTrue(CatanRules.isLegalMove(state, 0, offer))
assertFalse("not the addressee's turn", CatanRules.isLegalMove(state, 1, offer))
assertFalse("never to oneself", CatanRules.isLegalMove(state, 0, offer.copy(to = 0)))
assertFalse(
"like for like is not a trade",
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.LUMBER, Resource.BRICK), listOf(Resource.BRICK, Resource.LUMBER))),
)
assertFalse(
"an empty leg is not a trade",
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.LUMBER), emptyList())),
)
assertFalse(
"cards the offerer does not hold cannot be promised",
CatanRules.isLegalMove(state, 0, OfferTrade(1, listOf(Resource.ORE), listOf(Resource.BRICK))),
)
val table = CatanRules.applyMove(state, 0, offer)
assertEquals(CatanPhase.TRADE_PENDING, table.phase)
assertEquals(1, CatanRules.currentSeat(table))
assertEquals(TradeOffer(by = 0, to = 1, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK)), table.pendingTrade)
}
@Test
fun `an accepted offer crosses the cards and a declined one clears the table`() {
val offered = craft().copy(
phase = CatanPhase.TRADE_PENDING,
pendingTrade = TradeOffer(
by = 0, to = 1,
give = listOf(Resource.LUMBER, Resource.LUMBER),
get = listOf(Resource.BRICK),
),
resources = hands(
hand(Resource.LUMBER to 3),
hand(Resource.BRICK to 2),
hand(),
),
)
val accepted = CatanRules.applyMove(offered, 1, AcceptTrade)
assertEquals(CatanPhase.PLAYING, accepted.phase)
assertNull(accepted.pendingTrade)
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), accepted.resources[0])
assertEquals(hand(Resource.LUMBER to 2, Resource.BRICK to 1), accepted.resources[1])
val declined = CatanRules.applyMove(offered, 1, DeclineTrade)
assertEquals(CatanPhase.PLAYING, declined.phase)
assertNull(declined.pendingTrade)
assertEquals("a refusal leaves the cards where they were", offered.resources, declined.resources)
}
@Test
fun `an offer the addressee cannot honour can only be declined`() {
val offered = craft().copy(
phase = CatanPhase.TRADE_PENDING,
pendingTrade = TradeOffer(
by = 0, to = 1,
give = listOf(Resource.LUMBER, Resource.LUMBER),
get = listOf(Resource.BRICK),
),
resources = hands(hand(Resource.LUMBER to 3), hand(), hand()),
)
assertEquals(listOf<CatanMove>(DeclineTrade), CatanRules.legalMoves(offered, 1))
}
@Test
fun `the bank trades four for one and harbours do better`() {
val base = craft()
val lumberPort = base.ports.first { it.resource == Resource.LUMBER }
val genericPort = base.ports.first { it.resource == null }
val portSide = { port: Port -> CatanBoard.EDGE_VERTICES[port.edge].first }
val plain = base.copy(resources = hands(hand(Resource.LUMBER to 4)))
assertEquals(4, CatanRules.bestPortRatio(plain, 0, Resource.LUMBER))
val viaBank = CatanRules.applyMove(plain, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
assertEquals(hand(Resource.BRICK to 1), viaBank.resources[0])
val special = base.withBuilding(portSide(lumberPort), BuildingType.SETTLEMENT, 0)
.copy(resources = hands(hand(Resource.LUMBER to 2)))
assertEquals(2, CatanRules.bestPortRatio(special, 0, Resource.LUMBER))
assertEquals(4, CatanRules.bestPortRatio(special, 0, Resource.BRICK))
val viaPort = CatanRules.applyMove(special, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
assertEquals(hand(Resource.BRICK to 1), viaPort.resources[0])
val generic = base.withBuilding(portSide(genericPort), BuildingType.SETTLEMENT, 0)
.copy(resources = hands(hand(Resource.LUMBER to 3)))
assertEquals(3, CatanRules.bestPortRatio(generic, 0, Resource.LUMBER))
val viaGeneric = CatanRules.applyMove(generic, 0, MaritimeTrade(Resource.LUMBER, Resource.BRICK))
assertEquals(hand(Resource.BRICK to 1), viaGeneric.resources[0])
}
@Test
fun `a bank trade needs the cards and a bank with something to give`() {
val base = craft()
assertFalse(
"three is one short of the bank's four",
CatanRules.isLegalMove(
base.copy(resources = hands(hand(Resource.LUMBER to 3))),
0,
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
),
)
assertFalse(
"the bank holds no brick to give",
CatanRules.isLegalMove(
base.copy(resources = hands(hand(Resource.LUMBER to 4), hand(Resource.BRICK to 19))),
0,
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
),
)
assertFalse(
"no resource trades for itself",
CatanRules.isLegalMove(
base.copy(resources = hands(hand(Resource.LUMBER to 4))),
0,
MaritimeTrade(Resource.LUMBER, Resource.LUMBER),
),
)
assertTrue(
CatanRules.isLegalMove(
base.copy(resources = hands(hand(Resource.LUMBER to 4))),
0,
MaritimeTrade(Resource.LUMBER, Resource.BRICK),
),
)
}
// -- Development cards ---------------------------------------------------
@Test
fun `buying a card costs wool, grain and ore and hides it until next turn`() {
val base = craft().copy(resources = hands(hand(Resource.WOOL to 1, Resource.GRAIN to 1, Resource.ORE to 1)))
val top = base.devDeck.first()
val state = CatanRules.applyMove(base, 0, BuyDevCard)
assertEquals(hand(), state.resources[0])
assertEquals(listOf(top.copy(boughtTurn = 1)), state.devHands[0])
assertEquals(listOf(1, 0, 0), state.devHandCounts)
assertEquals(base.devDeck.drop(1), state.devDeck)
assertTrue("bought this turn, playable next", CatanRules.legalMoves(state, 0).none { it is PlayKnight })
}
@Test
fun `a knight may not be played before the roll`() {
val state = craft().copy(
phase = CatanPhase.AWAIT_ROLL,
devHands = listOf(listOf(DevCard(90, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
devHandCounts = listOf(1, 0, 0),
)
assertEquals(listOf<CatanMove>(RollDice()), CatanRules.legalMoves(state, 0))
}
@Test
fun `a card bought on an earlier turn is playable, one per turn`() {
val knight = DevCard(90, DevCardType.KNIGHT, 0)
val roadBuilding = DevCard(91, DevCardType.ROAD_BUILDING, 0)
val state = craft().copy(
devHands = listOf(listOf(knight, roadBuilding), emptyList(), emptyList()),
devHandCounts = listOf(2, 0, 0),
)
val moves = CatanRules.legalMoves(state, 0)
assertTrue(PlayKnight(90) in moves)
assertTrue(PlayRoadBuilding(91) in moves)
val after = CatanRules.applyMove(state, 0, PlayKnight(90))
assertEquals(CatanPhase.ROBBER, after.phase)
assertEquals(1, after.knightsPlayed[0])
assertEquals(listOf(roadBuilding), after.devHands[0])
assertTrue(after.devCardPlayedThisTurn)
// The robber rides first, then the turn owner may build and trade —
// but not play a second card.
val robberMove = CatanRules.legalMoves(after, 0).filterIsInstance<MoveRobber>().first()
val playing = CatanRules.applyMove(after, 0, robberMove)
assertEquals(CatanPhase.PLAYING, playing.phase)
assertTrue(
"one development card a turn",
CatanRules.legalMoves(playing, 0).none { it is PlayRoadBuilding },
)
}
@Test
fun `three knights take Largest Army, and only a strictly larger army steals it`() {
val firstToThree = craft().copy(
devHands = listOf(listOf(DevCard(90, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
devHandCounts = listOf(1, 0, 0),
knightsPlayed = listOf(2, 0, 0),
)
val took = CatanRules.applyMove(firstToThree, 0, PlayKnight(90))
assertEquals(0, took.largestArmyOwner)
assertEquals(5, CatanRules.publicVictoryPoints(took)[0])
// A tie does not steal it.
val tied = craft().copy(
devHands = listOf(listOf(DevCard(91, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
devHandCounts = listOf(1, 0, 0),
knightsPlayed = listOf(2, 3, 0),
largestArmyOwner = 1,
)
val kept = CatanRules.applyMove(tied, 0, PlayKnight(91))
assertEquals("the incumbent keeps a tie", 1, kept.largestArmyOwner)
// A fourth knight does.
val stronger = craft().copy(
devHands = listOf(listOf(DevCard(92, DevCardType.KNIGHT, 0)), emptyList(), emptyList()),
devHandCounts = listOf(1, 0, 0),
knightsPlayed = listOf(3, 3, 0),
largestArmyOwner = 1,
)
val stolen = CatanRules.applyMove(stronger, 0, PlayKnight(92))
assertEquals(0, stolen.largestArmyOwner)
assertEquals(5, CatanRules.publicVictoryPoints(stolen)[0])
assertEquals(0, CatanRules.publicVictoryPoints(stolen)[1])
}
@Test
fun `Road Building lays two free segments and may be forfeited`() {
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
val ring = hexRing(centre)
val base = craft()
.copy(
resources = hands(hand(Resource.LUMBER to 9, Resource.BRICK to 9)),
devHands = listOf(listOf(DevCard(93, DevCardType.ROAD_BUILDING, 0)), emptyList(), emptyList()),
devHandCounts = listOf(1, 0, 0),
)
.withRoad(ring[0], 0)
val opened = CatanRules.applyMove(base, 0, PlayRoadBuilding(93))
assertEquals(CatanPhase.FREE_ROADS, opened.phase)
assertEquals(2, opened.pendingFreeRoads)
assertTrue(EndFreeRoads in CatanRules.legalMoves(opened, 0))
val first = CatanRules.applyMove(opened, 0, BuildRoad(ring[1]))
assertEquals(CatanPhase.FREE_ROADS, first.phase)
assertEquals(1, first.pendingFreeRoads)
assertEquals("free roads cost nothing", base.resources[0], first.resources[0])
val forfeited = CatanRules.applyMove(first, 0, EndFreeRoads)
assertEquals(CatanPhase.PLAYING, forfeited.phase)
assertEquals(0, forfeited.pendingFreeRoads)
assertEquals(base.resources[0], forfeited.resources[0])
assertEquals(2, forfeited.roads.count { it == 0 })
}
@Test
fun `Monopoly seizes every card of the named resource`() {
val state = craft().copy(
resources = hands(
hand(Resource.WOOL to 2, Resource.GRAIN to 1),
hand(Resource.WOOL to 3, Resource.BRICK to 1),
hand(Resource.WOOL to 1),
),
devHands = listOf(listOf(DevCard(94, DevCardType.MONOPOLY, 0)), emptyList(), emptyList()),
devHandCounts = listOf(1, 0, 0),
)
val after = CatanRules.applyMove(state, 0, PlayMonopoly(94, Resource.WOOL))
assertEquals(hand(Resource.WOOL to 6, Resource.GRAIN to 1), after.resources[0])
assertEquals(hand(Resource.BRICK to 1), after.resources[1])
assertEquals(hand(), after.resources[2])
assertTrue(after.devHands[0].isEmpty())
assertTrue(after.devCardPlayedThisTurn)
assertEquals(CatanPhase.PLAYING, after.phase)
}
@Test
fun `Year of Plenty takes two cards the bank can pay`() {
val base = craft().copy(
devHands = listOf(listOf(DevCard(95, DevCardType.YEAR_OF_PLENTY, 0)), emptyList(), emptyList()),
devHandCounts = listOf(1, 0, 0),
)
val took = CatanRules.applyMove(base, 0, PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.ORE)))
assertEquals(hand(Resource.GRAIN to 1, Resource.ORE to 1), took.resources[0])
// The bank holds a single grain, seat 1 keeping the other eighteen:
// two grain cannot be taken.
val shortBank = base.copy(resources = hands(hand(), hand(Resource.GRAIN to 18), hand()))
assertTrue(
"two of a kind needs a bank that can pay both",
PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.GRAIN)) !in CatanRules.legalMoves(shortBank, 0),
)
assertTrue(
PlayYearOfPlenty(95, listOf(Resource.GRAIN, Resource.ORE)) in CatanRules.legalMoves(shortBank, 0),
)
}
// -- Longest Road --------------------------------------------------------
@Test
fun `a closed ring counts as a simple path, not as two loops`() {
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
val ring = hexRing(centre)
var state = craft()
ring.forEach { state = state.withRoad(it, 0) }
assertEquals("six segments around one hex count five", 5, CatanRules.longestRoadFor(state, 0))
}
@Test
fun `five segments take Longest Road, a tie keeps it, a settlement breaks it`() {
val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
val ring = hexRing(centre)
// A challenger's path on a hex that touches neither the centre nor its ring.
val far = (0 until CatanBoard.HEX_COUNT).first { h ->
h != centre && CatanBoard.HEX_NEIGHBORS[h].none { it == centre }
}.let { hexRing(it) }
var state = craft().copy(resources = hands(hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
ring.take(4).forEach { state = state.withRoad(it, 0) }
state = CatanRules.applyMove(state, 0, BuildRoad(ring[4]))
assertEquals("the fifth segment takes the award", 0, state.longestRoadOwner)
assertEquals(5, CatanRules.longestRoadFor(state, 0))
// An equal five does not steal it.
var challenger = state.copy(turn = 1, resources = hands(hand(), hand(Resource.LUMBER to 1, Resource.BRICK to 1)))
far.take(4).forEach { challenger = challenger.withRoad(it, 1) }
challenger = CatanRules.applyMove(challenger, 1, BuildRoad(far[4]))
assertEquals("the incumbent keeps a tie", 0, challenger.longestRoadOwner)
assertEquals(5, CatanRules.longestRoadFor(challenger, 1))
// An opponent's settlement in the middle breaks the holder's road in
// two, and the award falls to the challenger.
val breakAt = sharedVertex(ring[2], ring[3])
val spoke = CatanBoard.VERTEX_EDGES[breakAt].first { it != ring[2] && it != ring[3] }
val breaker = challenger
.copy(
resources = hands(
hand(),
hand(Resource.LUMBER to 1, Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1),
),
)
.withRoad(spoke, 1)
val broken = CatanRules.applyMove(breaker, 1, BuildSettlement(breakAt))
assertEquals("the broken road loses the award", 1, broken.longestRoadOwner)
assertEquals(3, CatanRules.longestRoadFor(broken, 0))
assertEquals(5, CatanRules.longestRoadFor(broken, 1))
assertEquals(6, CatanRules.publicVictoryPoints(broken)[1])
assertEquals(0, CatanRules.publicVictoryPoints(broken)[0])
}
// -- Winning -------------------------------------------------------------
@Test
fun `a tenth point on the builder's own move wins`() {
val homes = scattered(7)
var state = craft()
.withBuilding(homes[0], BuildingType.CITY, 0)
.withBuilding(homes[1], BuildingType.CITY, 0)
homes.drop(2).forEach { state = state.withBuilding(it, BuildingType.SETTLEMENT, 0) }
state = state.copy(resources = hands(hand(Resource.GRAIN to 2, Resource.ORE to 3)))
val won = CatanRules.applyMove(state, 0, BuildCity(homes[2]))
assertEquals(CatanPhase.GAME_OVER, won.phase)
assertEquals(0, won.winner)
assertTrue(won.log.any { it.contains("Seat 0 reveals 10 victory points and wins") })
}
@Test
fun `hidden victory-point cards count and are revealed by winning`() {
val homes = scattered(7)
var state = craft()
.withBuilding(homes[0], BuildingType.CITY, 0)
.withBuilding(homes[1], BuildingType.CITY, 0)
homes.drop(2).forEach { state = state.withBuilding(it, BuildingType.SETTLEMENT, 0) }
state = state.copy(
devHands = listOf(listOf(DevCard(96, DevCardType.VICTORY_POINT, 0)), emptyList(), emptyList()),
devHandCounts = listOf(1, 0, 0),
)
// Nine points stand in public, the tenth hides in hand: ending the
// turn wins before the turn passes.
val won = CatanRules.applyMove(state, 0, EndTurn)
assertEquals(CatanPhase.GAME_OVER, won.phase)
assertEquals(0, won.winner)
assertTrue(won.log.any { it.contains("Seat 0 reveals 10 victory points and wins") })
}
@Test
fun `a seat already at the target wins when its own turn arrives`() {
val homes = scattered(5)
var state = craft(turn = 1)
homes.forEach { state = state.withBuilding(it, BuildingType.CITY, 0) }
val won = CatanRules.applyMove(state, 1, EndTurn)
assertEquals(CatanPhase.GAME_OVER, won.phase)
assertEquals(0, won.winner)
assertTrue("seat 0 never had to roll", won.log.none { it.startsWith("Seat 0 rolls") })
}
// -- Redaction and wire format -------------------------------------------
@Test
fun `a seat view hides the deck and other hands but not the counts`() {
val state = craft().copy(
devDeck = listOf(
DevCard(1, DevCardType.KNIGHT),
DevCard(2, DevCardType.VICTORY_POINT),
),
devHands = listOf(
listOf(DevCard(3, DevCardType.KNIGHT, 1)),
listOf(DevCard(4, DevCardType.MONOPOLY, 1)),
listOf(DevCard(5, DevCardType.YEAR_OF_PLENTY, 1)),
),
devHandCounts = listOf(1, 1, 1),
)
val view = CatanRules.viewFor(state, seat = 1)
assertTrue("the deck order is hidden", view.devDeck.isEmpty())
assertEquals(state.devHands[1], view.devHands[1])
assertTrue(view.devHands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() })
assertEquals(state.devHandCounts, view.devHandCounts)
// Public information travels whole.
assertEquals(state.resources, view.resources)
assertEquals(state.buildings, view.buildings)
assertEquals(state.log, view.log)
}
@Test
fun `state and every move survive a wire round trip`() {
val homes = scattered(3)
val state = craft(
options = CatanOptions(playerCount = 4),
seed = 99L,
)
.withBuilding(homes[0], BuildingType.CITY, 2)
.withRoad(CatanBoard.VERTEX_EDGES[homes[1]][0], 3)
.copy(
resources = hands(
hand(Resource.LUMBER to 2),
hand(Resource.BRICK to 1),
hand(Resource.WOOL to 1, Resource.GRAIN to 1),
hand(Resource.ORE to 4),
players = 4,
),
pendingTrade = TradeOffer(
by = 0, to = 1,
give = listOf(Resource.LUMBER),
get = listOf(Resource.BRICK, Resource.WOOL),
),
)
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
val moves = listOf<CatanMove>(
SetupPlace(3, 5),
RollDice(),
RollDice(listOf(3, 4)),
Discard(listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK)),
MoveRobber(HexCoord(1, -1), 2),
MoveRobber(HexCoord(-2, 0)),
BuildRoad(11),
BuildSettlement(13),
BuildCity(13),
BuyDevCard,
PlayKnight(21),
PlayRoadBuilding(22),
PlayMonopoly(23, Resource.ORE),
PlayYearOfPlenty(24, listOf(Resource.GRAIN, Resource.GRAIN)),
OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)),
AcceptTrade,
DeclineTrade,
MaritimeTrade(Resource.WOOL, Resource.GRAIN),
EndTurn,
EndFreeRoads,
)
moves.forEach { assertEquals(it, CatanRules.decodeMove(CatanRules.encodeMove(it))) }
}
@Test
fun `summary reports the standing points until somebody wins`() {
val state = craft()
assertTrue(CatanRules.summary(state).contains("S0 0"))
val homes = scattered(5)
var won = craft(turn = 1)
homes.forEach { won = won.withBuilding(it, BuildingType.CITY, 0) }
won = CatanRules.applyMove(won, 1, EndTurn)
assertEquals("Seat 0 wins", CatanRules.summary(won))
}
// -- Helpers -------------------------------------------------------------
private fun assertThrows(block: () -> Unit) {
try {
block()
throw AssertionError("expected the rules to reject that")
} catch (expected: IllegalArgumentException) {
// Rejected, which is the point.
}
}
}