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:
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user