Merge feature/catan-engine into dev

Brings in the Katan rules engine, board, model and AI (WALA-119), its 51 unit
tests and docs/RULES-catan.md. Only conflict was docs/README.md, which the
GitHub-lineage migration had dropped: kept the engine branch's index and
pointed the CI link (docs/CI.md no longer exists) at the root README.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
This commit is contained in:
Claude
2026-10-14 20:50:28 +00:00
12 changed files with 3422 additions and 2 deletions
@@ -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(36, (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,861 @@
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(phase = CatanPhase.AWAIT_ROLL, 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(phase = CatanPhase.AWAIT_ROLL, 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(phase = CatanPhase.AWAIT_ROLL, 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(phase = CatanPhase.AWAIT_ROLL, 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(
phase = CatanPhase.ROBBER,
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)
// Seat 2 ends its turn, so seat 0 is next in the three-seat rotation.
var state = craft(turn = 2)
homes.forEach { state = state.withBuilding(it, BuildingType.CITY, 0) }
val won = CatanRules.applyMove(state, 2, 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)
// Seat 2 ends, seat 0 is next and wins on arrival.
var won = craft(turn = 2)
homes.forEach { won = won.withBuilding(it, BuildingType.CITY, 0) }
won = CatanRules.applyMove(won, 2, 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.
}
}
}