The island game as a pure-Kotlin state machine under game/catan/, wired to the upstream GameRules / GameAi contract and fully unit-tested on the JVM. - game/catan/: CatanModel (state, moves, phases), CatanBoard (19-hex geometry, seeded island generation with the 6/8 separation rule, shared vertex/edge graph), CatanRules (setup, production, the seven and the robber, domestic and maritime trade, building, development cards, Longest Road, Largest Army, win detection), CatanAi (a heuristic opponent that reads only public information). - GameRules.isLegalMove: a defaulted structural legality check so a game with a combinatorial move space (Catan's trade offers) is gated the same way as one with an enumerated move list; MatchController uses it. - GameCatalog.CATAN: the catalogue entry, listed but not yet available (descriptor and screen land with the screen task). - Tests: 51 across CatanBoardTest, CatanRulesTest and CatanAiTest — geometry invariants, generation balance, setup, production, the seven/robber, both trade types, development-card timing, Longest Road edge cases, Largest Army, win detection, and AI games played to a result. - docs/RULES-catan.md, in the per-game style, with the rule choices and the known gaps called out. Two real engine bugs found and fixed while getting the suite green: VERTEX_EDGES counted each shared edge once per adjacent hex, so an interior vertex looked like it had six edges (which also double-counted segments in the Longest Road search and duplicated setup moves); and the applyRoll seven-transition was a discarded expression, so a seven never left AWAIT_ROLL. Three test fixtures rolled from the PLAYING phase and two assumed a two-seat rotation; corrected to the phases and seat order the engine actually uses. Verified: ./gradlew :app:testDebugUnitTest --offline — 679 tests, 0 failures. Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
co-authored by
Paperclip
parent
5c797c8807
commit
3a930f5423
@@ -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