Katan: Cities & Knights engine, AI and rules tests
A second rule set chosen at setup (CatanRuleSet): commodities, knights, walls, barbarians and the event die, city improvements and metropolises, and the 54 progress cards. Everything lives behind CatanState.ck, so the base game's state and rules are untouched. Multi-seat decisions run through one chore queue. Also wraps three multi-part log lines so each event is one entry. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
This commit is contained in:
@@ -0,0 +1,956 @@
|
||||
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.assertNotNull
|
||||
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
|
||||
|
||||
/** The Cities & Knights rules, one at a time, on crafted positions. */
|
||||
class CatanCkRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(players: Int = 3, seed: Long = 7L, ck: Boolean = true) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(
|
||||
CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 13,
|
||||
ruleSet = if (ck) CatanRuleSet.CITIES_AND_KNIGHTS else CatanRuleSet.BASE,
|
||||
),
|
||||
),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/** A mid-game table on seat 0's turn, ready to act. */
|
||||
private fun table(players: Int = 3, seed: Long = 7L): CatanState =
|
||||
CatanRules.initialState(config(players, seed)).let {
|
||||
it.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
turn = 0,
|
||||
turnNumber = 1,
|
||||
// Well away from the middle hex the tests play on.
|
||||
robber = CatanBoard.hexIndex.getValue(HexCoord(2, -2)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun CatanState.hand(seat: Int, vararg cards: Pair<Good, Int>): CatanState {
|
||||
val want = cards.toMap()
|
||||
val resources = resources.toMutableList().also { list ->
|
||||
list[seat] = Resource.entries.associateWith { want[Good.of(it)] ?: 0 }
|
||||
}
|
||||
val state = ck!!
|
||||
val commodities = state.commodities.toMutableList().also { list ->
|
||||
list[seat] = Commodity.entries.associateWith { want[Good.of(it)] ?: 0 }
|
||||
}
|
||||
return copy(resources = resources, ck = state.copy(commodities = commodities))
|
||||
}
|
||||
|
||||
private fun CatanState.build(vertex: Int, type: BuildingType, owner: Int): CatanState =
|
||||
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
|
||||
|
||||
private fun CatanState.road(edge: Int, owner: Int): CatanState =
|
||||
copy(roads = roads.toMutableList().also { it[edge] = owner })
|
||||
|
||||
private fun CatanState.knight(vertex: Int, owner: Int, level: Int = 1, active: Boolean = false): CatanState =
|
||||
ck!!.let { state -> copy(ck = state.copy(knights = state.knights + Knight(vertex, owner, level, active))) }
|
||||
|
||||
private fun CatanState.improve(seat: Int, track: Track, level: Int): CatanState =
|
||||
ck!!.let { state ->
|
||||
copy(ck = state.copy(improvements = state.improvements.toMutableList().also {
|
||||
it[seat] = it[seat] + (track to level)
|
||||
}))
|
||||
}
|
||||
|
||||
/** Moves a card of [kind] from its deck into [seat]'s hand. */
|
||||
private fun CatanState.holding(seat: Int, kind: ProgressType): CatanState {
|
||||
val state = ck!!
|
||||
val deck = state.decks.getValue(kind.track)
|
||||
val card = deck.first { it.kind == kind }
|
||||
val hands = state.progressHands.toMutableList().also { it[seat] = it[seat] + card }
|
||||
return copy(
|
||||
ck = state.copy(
|
||||
decks = state.decks + (kind.track to deck.filter { it.id != card.id }),
|
||||
progressHands = hands,
|
||||
progressCounts = hands.map { it.size },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun CatanState.cardId(seat: Int, kind: ProgressType): Int =
|
||||
ck!!.progressHands[seat].first { it.kind == kind }.id
|
||||
|
||||
/**
|
||||
* Sets one hex, and takes the tokens off its neighbours (and makes them
|
||||
* hills) so only it can pay, or match a terrain, in the tests that follow.
|
||||
*/
|
||||
private fun CatanState.withHex(hex: Int, terrain: Terrain, number: Int): CatanState =
|
||||
copy(
|
||||
terrains = terrains.toMutableList().also {
|
||||
CatanBoard.HEX_NEIGHBORS[hex].forEach { n -> it[n] = Terrain.HILLS }
|
||||
it[hex] = terrain
|
||||
},
|
||||
numbers = numbers.toMutableList().also {
|
||||
CatanBoard.HEX_NEIGHBORS[hex].forEach { n -> it[n] = 0 }
|
||||
it[hex] = number
|
||||
},
|
||||
)
|
||||
|
||||
/** A path of roads along [vertices] in order, all for [owner]. */
|
||||
private fun CatanState.path(owner: Int, vararg vertices: Int): CatanState {
|
||||
var s = this
|
||||
for (i in 0 until vertices.size - 1) {
|
||||
val a = vertices[i]
|
||||
val b = vertices[i + 1]
|
||||
val edge = CatanBoard.EDGE_VERTICES.indexOfFirst { it == minOf(a, b) to maxOf(a, b) }
|
||||
check(edge >= 0) { "no edge between $a and $b" }
|
||||
s = s.road(edge, owner)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private fun apply(state: CatanState, seat: Int, move: CatanMove): CatanState {
|
||||
assertTrue("illegal $move in ${state.phase} for seat $seat", CatanRules.isLegalMove(state, seat, move))
|
||||
return CatanRules.applyMove(state, seat, move)
|
||||
}
|
||||
|
||||
private val centre = CatanBoard.hexIndex.getValue(HexCoord(0, 0))
|
||||
private val ring = CatanBoard.HEX_CORNERS[centre] // six crossings around the middle hex
|
||||
|
||||
private fun roll(state: CatanState, dice: List<Int>, event: EventFace): CatanState =
|
||||
apply(state.copy(phase = CatanPhase.AWAIT_ROLL), state.turn, RollDice(forced = dice, event = event))
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The rule set is chosen at setup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the base game carries none of the expansion`() {
|
||||
val base = CatanRules.initialState(config(ck = false))
|
||||
assertNull(base.ck)
|
||||
assertEquals(25, base.devDeck.size)
|
||||
assertEquals(CatanRuleSet.BASE, base.options.ruleSet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expansion table has no development cards and a full progress deck`() {
|
||||
val s = CatanRules.initialState(config())
|
||||
assertNotNull(s.ck)
|
||||
assertTrue(s.devDeck.isEmpty())
|
||||
assertEquals(3, s.ck!!.decks.size)
|
||||
s.ck!!.decks.values.forEach { assertEquals(18, it.size) }
|
||||
assertEquals(BARBARIAN_STEPS, s.ck!!.barbarians)
|
||||
assertFalse(s.ck!!.attacked)
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is BuyDevCard })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the options without a rule set still read as the base game`() {
|
||||
val old = """{"playerCount":4,"pointsToWin":10}"""
|
||||
val options = Json { ignoreUnknownKeys = true }.decodeFromString<CatanOptions>(old)
|
||||
assertEquals(CatanRuleSet.BASE, options.ruleSet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the second placement is a city and pays resources only`() {
|
||||
var s = CatanRules.initialState(config())
|
||||
val ai = CatanAi()
|
||||
while (s.phase == CatanPhase.SETUP_FIRST || s.phase == CatanPhase.SETUP_SECOND) {
|
||||
val seat = CatanRules.currentSeat(s)!!
|
||||
s = apply(s, seat, ai.chooseMove(s, seat, CatanRules.legalMoves(s, seat)))
|
||||
}
|
||||
for (seat in 0 until 3) {
|
||||
assertEquals(1, s.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) })
|
||||
assertEquals(1, s.buildings.count { it == CatanBuilding(BuildingType.CITY, seat) })
|
||||
assertEquals(0, s.ck!!.commodities[seat].values.sum())
|
||||
}
|
||||
assertEquals(CatanPhase.AWAIT_ROLL, s.phase)
|
||||
// Three cities and two points each... a settlement and a city make three.
|
||||
assertEquals(listOf(3, 3, 3), CatanRules.publicVictoryPoints(s))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Production
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a city on a forest pays lumber and paper, on hills two brick, a settlement just the resource`() {
|
||||
var s = table().withHex(centre, Terrain.FOREST, 8)
|
||||
.build(ring[0], BuildingType.CITY, 0)
|
||||
.build(ring[3], BuildingType.SETTLEMENT, 1)
|
||||
s = roll(s, listOf(4, 4), EventFace.SHIP)
|
||||
assertEquals(1, s.resources[0][Resource.LUMBER])
|
||||
assertEquals(1, s.ck!!.commodities[0][Commodity.PAPER])
|
||||
assertEquals(1, s.resources[1][Resource.LUMBER])
|
||||
assertEquals(0, s.ck!!.commodities[1].values.sum())
|
||||
|
||||
val hills = table().withHex(centre, Terrain.HILLS, 8).build(ring[0], BuildingType.CITY, 0)
|
||||
val rolled = roll(hills, listOf(3, 5), EventFace.SHIP)
|
||||
assertEquals(2, rolled.resources[0][Resource.BRICK])
|
||||
assertEquals(0, rolled.ck!!.commodities[0].values.sum())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pasture pays cloth and mountains coin to a city`() {
|
||||
val pasture = roll(
|
||||
table().withHex(centre, Terrain.PASTURE, 6).build(ring[0], BuildingType.CITY, 0),
|
||||
listOf(3, 3), EventFace.SHIP,
|
||||
)
|
||||
assertEquals(1, pasture.resources[0][Resource.WOOL])
|
||||
assertEquals(1, pasture.ck!!.commodities[0][Commodity.CLOTH])
|
||||
val mountains = roll(
|
||||
table().withHex(centre, Terrain.MOUNTAINS, 6).build(ring[0], BuildingType.CITY, 0),
|
||||
listOf(2, 4), EventFace.SHIP,
|
||||
)
|
||||
assertEquals(1, mountains.resources[0][Resource.ORE])
|
||||
assertEquals(1, mountains.ck!!.commodities[0][Commodity.COIN])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bank short of a commodity pays a lone claimant what is left and two claimants nothing`() {
|
||||
// Eleven paper already out; two cities would want two more.
|
||||
val base = table().withHex(centre, Terrain.FOREST, 8).hand(2, Good.PAPER to 11)
|
||||
val lone = roll(
|
||||
base.build(ring[0], BuildingType.CITY, 0).build(ring[2], BuildingType.CITY, 0),
|
||||
listOf(4, 4), EventFace.SHIP,
|
||||
)
|
||||
assertEquals("the lone claimant takes the last paper", 1, lone.ck!!.commodities[0][Commodity.PAPER])
|
||||
|
||||
val two = roll(
|
||||
base.build(ring[0], BuildingType.CITY, 0).build(ring[3], BuildingType.CITY, 1),
|
||||
listOf(4, 4), EventFace.SHIP,
|
||||
)
|
||||
assertEquals(0, two.ck!!.commodities[0][Commodity.PAPER])
|
||||
assertEquals(0, two.ck!!.commodities[1][Commodity.PAPER])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the robber does not stand on the board until the barbarians land`() {
|
||||
// A seven before any attack: nobody holding eight cards under a wall is bothered, and nothing is stolen.
|
||||
var s = table().hand(0, Good.LUMBER to 8)
|
||||
s = roll(s, listOf(3, 4), EventFace.SHIP)
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
assertEquals(DiscardChore(0, 4), s.ck!!.chores.first())
|
||||
s = apply(s, 0, DiscardGoods(List(4) { Good.LUMBER }))
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
assertEquals(4, s.resources[0][Resource.LUMBER])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after the first attack a seven moves the robber`() {
|
||||
var s = table().copy(ck = table().ck!!.copy(attacked = true))
|
||||
s = roll(s, listOf(3, 4), EventFace.SHIP)
|
||||
assertEquals(CatanPhase.ROBBER, s.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wall raises the hand limit by two`() {
|
||||
val walled = table().hand(0, Good.LUMBER to 9)
|
||||
.build(ring[0], BuildingType.CITY, 0)
|
||||
.let { it.copy(ck = it.ck!!.copy(walls = listOf(ring[0]))) }
|
||||
val s = roll(walled, listOf(3, 4), EventFace.SHIP)
|
||||
// Nine cards is not more than seven plus a wall's two.
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
assertEquals(9, CatanCk.handLimit(walled, 0))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The barbarians
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun nearlyLanded(s: CatanState): CatanState = s.copy(ck = s.ck!!.copy(barbarians = 1))
|
||||
|
||||
@Test
|
||||
fun `a ship moves the barbarians one step and a gate does not`() {
|
||||
val s = roll(table(), listOf(2, 3), EventFace.SHIP)
|
||||
assertEquals(BARBARIAN_STEPS - 1, s.ck!!.barbarians)
|
||||
val gate = roll(table(), listOf(2, 3), EventFace.TRADE)
|
||||
assertEquals(BARBARIAN_STEPS, gate.ck!!.barbarians)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `barbarians that beat the knights pillage the weakest seat's city`() {
|
||||
// Three cities against one knight's worth of defence: the seat that
|
||||
// contributed least loses a city, and keeps a settlement in its place.
|
||||
var s = nearlyLanded(
|
||||
table()
|
||||
.build(ring[0], BuildingType.CITY, 0)
|
||||
.build(ring[2], BuildingType.CITY, 1)
|
||||
.build(ring[4], BuildingType.CITY, 2)
|
||||
.knight(CatanBoard.VERTEX_NEIGHBORS[ring[0]].first(), 0, level = 1, active = true),
|
||||
)
|
||||
s = roll(s, listOf(4, 6), EventFace.SHIP)
|
||||
// Seats 1 and 2 tie for weakest, each with a lone city: both fall.
|
||||
assertEquals(CatanBuilding(BuildingType.CITY, 0), s.buildings[ring[0]])
|
||||
assertEquals(CatanBuilding(BuildingType.SETTLEMENT, 1), s.buildings[ring[2]])
|
||||
assertEquals(CatanBuilding(BuildingType.SETTLEMENT, 2), s.buildings[ring[4]])
|
||||
assertTrue(s.ck!!.attacked)
|
||||
assertEquals(BARBARIAN_STEPS, s.ck!!.barbarians)
|
||||
assertTrue("every knight stands down", s.ck!!.knights.none { it.active })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seat with several cities chooses which falls`() {
|
||||
var s = nearlyLanded(
|
||||
table()
|
||||
.build(ring[0], BuildingType.CITY, 0)
|
||||
.build(ring[2], BuildingType.CITY, 0)
|
||||
.build(ring[4], BuildingType.CITY, 1)
|
||||
.knight(CatanBoard.VERTEX_NEIGHBORS[ring[4]].first(), 1, level = 1, active = true),
|
||||
)
|
||||
s = roll(s, listOf(4, 6), EventFace.SHIP)
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
assertEquals(0, CatanRules.currentSeat(s))
|
||||
val legal = CatanRules.legalMoves(s, 0)
|
||||
assertEquals(setOf<CatanMove>(PillageCity(ring[0]), PillageCity(ring[2])), legal.toSet())
|
||||
s = apply(s, 0, PillageCity(ring[2]))
|
||||
assertEquals(CatanBuilding(BuildingType.SETTLEMENT, 0), s.buildings[ring[2]])
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a metropolis cannot be pillaged`() {
|
||||
var s = nearlyLanded(
|
||||
table()
|
||||
.build(ring[0], BuildingType.CITY, 0)
|
||||
.build(ring[2], BuildingType.CITY, 1),
|
||||
)
|
||||
s = s.copy(ck = s.ck!!.copy(metropolises = mapOf(Track.TRADE to MetropolisAt(0, ring[0]))))
|
||||
s = roll(s, listOf(4, 6), EventFace.SHIP)
|
||||
assertEquals(CatanBuilding(BuildingType.CITY, 0), s.buildings[ring[0]])
|
||||
assertEquals(CatanBuilding(BuildingType.SETTLEMENT, 1), s.buildings[ring[2]])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the knights hold the strongest defender takes a victory point`() {
|
||||
var s = nearlyLanded(
|
||||
table()
|
||||
.build(ring[0], BuildingType.CITY, 0)
|
||||
.build(ring[2], BuildingType.CITY, 1)
|
||||
.knight(CatanBoard.VERTEX_NEIGHBORS[ring[0]].first(), 0, level = 3, active = true)
|
||||
.knight(CatanBoard.VERTEX_NEIGHBORS[ring[2]].first(), 1, level = 1, active = true),
|
||||
)
|
||||
val points = CatanRules.publicVictoryPoints(s)
|
||||
s = roll(s, listOf(4, 6), EventFace.SHIP)
|
||||
assertEquals(points[0] + 1, CatanRules.publicVictoryPoints(s)[0])
|
||||
assertEquals(points[1], CatanRules.publicVictoryPoints(s)[1])
|
||||
assertEquals(CatanBuilding(BuildingType.CITY, 1), s.buildings[ring[2]])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tie for strongest defender draws a progress card of the winner's choice instead`() {
|
||||
var s = nearlyLanded(
|
||||
table()
|
||||
.build(ring[0], BuildingType.CITY, 0)
|
||||
.knight(CatanBoard.VERTEX_NEIGHBORS[ring[0]].first(), 0, level = 2, active = true)
|
||||
.knight(CatanBoard.VERTEX_NEIGHBORS[ring[3]].first(), 1, level = 2, active = true),
|
||||
)
|
||||
s = roll(s, listOf(4, 6), EventFace.SHIP)
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
assertTrue(s.ck!!.chores.first() is PickDeckChore)
|
||||
assertEquals(0, CatanRules.currentSeat(s))
|
||||
s = apply(s, 0, PickDeck(Track.SCIENCE))
|
||||
assertEquals(1, s.ck!!.progressHands[0].size + s.ck!!.progressVp[0])
|
||||
// The other tied seat chooses too.
|
||||
assertEquals(1, CatanRules.currentSeat(s))
|
||||
s = apply(s, 1, PickDeck(Track.TRADE))
|
||||
assertEquals(1, s.ck!!.progressHands[1].size + s.ck!!.progressVp[1])
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Knights
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private val a = ring[0]
|
||||
private val b = ring[1]
|
||||
private val c = ring[2]
|
||||
private val d = ring[3]
|
||||
|
||||
@Test
|
||||
fun `a knight is hired at the end of a road for an ore and a wool, two of each strength`() {
|
||||
val s = table().hand(0, Good.ORE to 4, Good.WOOL to 4).path(0, a, b)
|
||||
val hires = CatanRules.legalMoves(s, 0).filterIsInstance<HireKnight>().map { it.vertex }.toSet()
|
||||
assertEquals(setOf(a, b), hires)
|
||||
|
||||
var t = apply(s, 0, HireKnight(b))
|
||||
assertEquals(Knight(b, 0, 1), t.ck!!.knights.single())
|
||||
assertEquals(3, t.resources[0][Resource.ORE])
|
||||
t = apply(t, 0, HireKnight(a))
|
||||
// A third basic knight is refused: two of each strength.
|
||||
val more = t.path(0, b, c)
|
||||
assertTrue(CatanRules.legalMoves(more, 0).none { it is HireKnight })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a knight cannot be hired where it has no road or on a building`() {
|
||||
val s = table().hand(0, Good.ORE to 2, Good.WOOL to 2).path(0, a, b).build(b, BuildingType.SETTLEMENT, 0)
|
||||
val hires = CatanRules.legalMoves(s, 0).filterIsInstance<HireKnight>().map { it.vertex }.toSet()
|
||||
assertEquals(setOf(a), hires)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activating costs a grain and a knight cannot act the turn it wakes`() {
|
||||
var s = table().hand(0, Good.GRAIN to 1).path(0, a, b, c).knight(a, 0)
|
||||
s = apply(s, 0, ActivateKnight(a))
|
||||
assertTrue(s.ck!!.knights.single().active)
|
||||
assertEquals(0, s.resources[0][Resource.GRAIN])
|
||||
assertTrue("it woke this turn and may not move", CatanRules.legalMoves(s, 0).none { it is MoveKnight })
|
||||
// Next turn it can.
|
||||
val next = s.copy(turnNumber = s.turnNumber + 1)
|
||||
assertTrue(CatanRules.legalMoves(next, 0).any { it is MoveKnight })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an active knight moves along its own roads and then rests`() {
|
||||
var s = table().path(0, a, b, c).knight(a, 0, active = true)
|
||||
val destinations = CatanRules.legalMoves(s, 0).filterIsInstance<MoveKnight>().map { it.to }.toSet()
|
||||
assertEquals(setOf(b, c), destinations)
|
||||
s = apply(s, 0, MoveKnight(a, c))
|
||||
assertEquals(c, s.ck!!.knights.single().vertex)
|
||||
assertFalse(s.ck!!.knights.single().active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a knight cannot pass an enemy knight, but can drive off a weaker one`() {
|
||||
// a - b - c on seat 0's roads; seat 1's knight stands on b.
|
||||
val base = table().path(0, a, b, c)
|
||||
val weak = base.knight(a, 0, level = 2, active = true).knight(b, 1, level = 1)
|
||||
val dests = CatanRules.legalMoves(weak, 0).filterIsInstance<MoveKnight>().map { it.to }.toSet()
|
||||
assertEquals("it can take b but not run past it", setOf(b), dests)
|
||||
|
||||
val strong = base.knight(a, 0, level = 1, active = true).knight(b, 1, level = 2)
|
||||
assertTrue(CatanRules.legalMoves(strong, 0).none { it is MoveKnight })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a driven knight moves along its owner's roads or is lost`() {
|
||||
// Seat 1's knight sits on b with a road on to c.
|
||||
val s = table().path(0, a, b).path(1, b, c).knight(a, 0, level = 2, active = true).knight(b, 1)
|
||||
val driven = apply(s, 0, MoveKnight(a, b))
|
||||
assertEquals(CatanPhase.CHORE, driven.phase)
|
||||
assertEquals(1, CatanRules.currentSeat(driven))
|
||||
assertEquals(listOf<CatanMove>(RelocateKnight(c)), CatanRules.legalMoves(driven, 1))
|
||||
val done = apply(driven, 1, RelocateKnight(c))
|
||||
assertEquals(setOf(b to 0, c to 1), done.ck!!.knights.map { it.vertex to it.owner }.toSet())
|
||||
assertEquals(CatanPhase.PLAYING, done.phase)
|
||||
|
||||
// With no road to flee along, the knight is simply lost.
|
||||
val trapped = table().path(0, a, b).knight(a, 0, level = 2, active = true).knight(b, 1)
|
||||
val gone = apply(trapped, 0, MoveKnight(a, b))
|
||||
assertEquals(CatanPhase.PLAYING, gone.phase)
|
||||
assertEquals(1, gone.ck!!.knights.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promotion costs an ore and a wool and a mighty knight needs the fortress`() {
|
||||
var s = table().hand(0, Good.ORE to 9, Good.WOOL to 9).path(0, a, b).knight(a, 0).knight(b, 0, level = 2)
|
||||
val promotable = CatanRules.legalMoves(s, 0).filterIsInstance<PromoteKnight>().map { it.vertex }.toSet()
|
||||
assertEquals("the strong knight needs politics level 3", setOf(a), promotable)
|
||||
|
||||
s = s.improve(0, Track.POLITICS, 3)
|
||||
assertEquals(
|
||||
setOf(a, b),
|
||||
CatanRules.legalMoves(s, 0).filterIsInstance<PromoteKnight>().map { it.vertex }.toSet(),
|
||||
)
|
||||
s = apply(s, 0, PromoteKnight(b))
|
||||
assertEquals(3, s.ck!!.knights.first { it.vertex == b }.level)
|
||||
s = apply(s, 0, PromoteKnight(a))
|
||||
assertEquals(2, s.ck!!.knights.first { it.vertex == a }.level)
|
||||
// A knight is promoted once a turn, so the new strong knight stays as it is.
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is PromoteKnight && it.vertex == a })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an active knight beside the robber can chase it, once the barbarians have landed`() {
|
||||
var s = table().path(0, a, b).knight(a, 0, active = true)
|
||||
assertTrue(CatanRules.legalMoves(s.copy(robber = centre), 0).none { it is ChaseRobber })
|
||||
s = s.copy(robber = centre, ck = s.ck!!.copy(attacked = true))
|
||||
val chase = ChaseRobber(a)
|
||||
assertTrue(chase in CatanRules.legalMoves(s, 0))
|
||||
s = apply(s, 0, chase)
|
||||
assertEquals(CatanPhase.ROBBER, s.phase)
|
||||
assertFalse(s.ck!!.knights.single().active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enemy knight blocks a road and a settlement`() {
|
||||
val s = table().hand(0, Good.LUMBER to 5, Good.BRICK to 5, Good.WOOL to 5, Good.GRAIN to 5)
|
||||
.path(0, a, b).knight(b, 1)
|
||||
val roads = CatanRules.legalMoves(s, 0).filterIsInstance<BuildRoad>().map { CatanBoard.EDGE_VERTICES[it.edge] }
|
||||
assertTrue("no road may run past the knight on b", roads.none { it.first == b || it.second == b })
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildSettlement && it.vertex == b })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a knight cuts the longest road`() {
|
||||
val line = intArrayOf(a, b, c, d, ring[4], ring[5])
|
||||
val s = table().path(0, *line)
|
||||
assertEquals(5, CatanRules.longestRoadFor(s, 0))
|
||||
val cut = s.knight(c, 1)
|
||||
assertTrue(CatanRules.longestRoadFor(cut, 0) < 5)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Walls
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a wall costs two brick, goes on a city, and three are the most`() {
|
||||
var s = table().hand(0, Good.BRICK to 8)
|
||||
.build(a, BuildingType.CITY, 0).build(c, BuildingType.CITY, 0).build(ring[4], BuildingType.CITY, 0)
|
||||
.build(CatanBoard.VERTEX_NEIGHBORS[a].first { it !in ring }, BuildingType.CITY, 0)
|
||||
val sites = CatanRules.legalMoves(s, 0).filterIsInstance<BuildWall>().map { it.vertex }.toSet()
|
||||
assertEquals(setOf(a, c, ring[4], CatanBoard.VERTEX_NEIGHBORS[a].first { it !in ring }), sites)
|
||||
s = apply(s, 0, BuildWall(a))
|
||||
assertEquals(6, s.resources[0][Resource.BRICK])
|
||||
s = apply(s, 0, BuildWall(c))
|
||||
s = apply(s, 0, BuildWall(ring[4]))
|
||||
assertEquals("three walls are all a seat owns", 0, CatanRules.legalMoves(s, 0).count { it is BuildWall })
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// City improvements and metropolises
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `an improvement needs a city and costs its next level in the track's commodity`() {
|
||||
val noCity = table().hand(0, Good.CLOTH to 5)
|
||||
assertTrue(CatanRules.legalMoves(noCity, 0).none { it is BuyImprovement })
|
||||
|
||||
var s = table().hand(0, Good.CLOTH to 3, Good.COIN to 0).build(a, BuildingType.CITY, 0)
|
||||
assertEquals(listOf<CatanMove>(BuyImprovement(Track.TRADE)), CatanRules.legalMoves(s, 0).filterIsInstance<BuyImprovement>())
|
||||
s = apply(s, 0, BuyImprovement(Track.TRADE))
|
||||
assertEquals(1, CatanCk.improvement(s, 0, Track.TRADE))
|
||||
assertEquals(2, s.ck!!.commodities[0][Commodity.CLOTH])
|
||||
s = apply(s, 0, BuyImprovement(Track.TRADE))
|
||||
assertEquals(2, CatanCk.improvement(s, 0, Track.TRADE))
|
||||
assertEquals("the second level costs two", 0, s.ck!!.commodities[0][Commodity.CLOTH])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a crane takes a commodity off the next improvement only`() {
|
||||
var s = table().hand(0, Good.COIN to 2).build(a, BuildingType.CITY, 0)
|
||||
.improve(0, Track.POLITICS, 1).holding(0, ProgressType.CRANE)
|
||||
assertEquals(2, CatanCk.improvementCost(s, 0, Track.POLITICS))
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.CRANE)))
|
||||
assertEquals(1, CatanCk.improvementCost(s, 0, Track.POLITICS))
|
||||
s = apply(s, 0, BuyImprovement(Track.POLITICS))
|
||||
assertEquals(1, s.ck!!.commodities[0][Commodity.COIN])
|
||||
assertEquals(3, CatanCk.improvementCost(s, 0, Track.POLITICS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first to level four takes the metropolis, level five takes it from a rival at four`() {
|
||||
var s = table().hand(0, Good.CLOTH to 4)
|
||||
.build(a, BuildingType.CITY, 0).build(c, BuildingType.CITY, 1)
|
||||
.improve(0, Track.TRADE, 3).improve(1, Track.TRADE, 4)
|
||||
.let { it.copy(ck = it.ck!!.copy(metropolises = mapOf(Track.TRADE to MetropolisAt(1, c)))) }
|
||||
// Seat 1 holds the metropolis at level four; seat 0 reaches four only: no change.
|
||||
s = apply(s, 0, BuyImprovement(Track.TRADE))
|
||||
assertEquals(MetropolisAt(1, c), s.ck!!.metropolises[Track.TRADE])
|
||||
|
||||
// Reaching five takes it, and with one city the placement is automatic.
|
||||
s = s.hand(0, Good.CLOTH to 5)
|
||||
s = apply(s, 0, BuyImprovement(Track.TRADE))
|
||||
assertEquals(MetropolisAt(0, a), s.ck!!.metropolises[Track.TRADE])
|
||||
assertEquals(4, CatanRules.publicVictoryPoints(s)[0])
|
||||
assertEquals(2, CatanRules.publicVictoryPoints(s)[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seat with several cities picks where the metropolis goes`() {
|
||||
var s = table().hand(0, Good.PAPER to 4)
|
||||
.build(a, BuildingType.CITY, 0).build(c, BuildingType.CITY, 0)
|
||||
.improve(0, Track.SCIENCE, 3)
|
||||
s = apply(s, 0, BuyImprovement(Track.SCIENCE))
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
assertEquals(setOf<CatanMove>(PlaceMetropolis(a), PlaceMetropolis(c)), CatanRules.legalMoves(s, 0).toSet())
|
||||
s = apply(s, 0, PlaceMetropolis(c))
|
||||
assertEquals(MetropolisAt(0, c), s.ck!!.metropolises[Track.SCIENCE])
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the trade track trades commodities two for one and the aqueduct feeds a dry seat`() {
|
||||
val trader = table().hand(0, Good.CLOTH to 2).improve(0, Track.TRADE, 3)
|
||||
assertEquals(2, CatanCk.bankRatio(trader, 0, Good.CLOTH))
|
||||
assertEquals(4, CatanCk.bankRatio(table(), 0, Good.CLOTH))
|
||||
val traded = apply(trader, 0, GoodsBankTrade(Good.CLOTH, Good.ORE))
|
||||
assertEquals(1, traded.resources[0][Resource.ORE])
|
||||
|
||||
// Seat 0 has the aqueduct and no building on a producing hex: a dry roll earns a free resource.
|
||||
var s = table().improve(0, Track.SCIENCE, 3)
|
||||
s = roll(s, listOf(1, 2), EventFace.TRADE)
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
assertTrue(s.ck!!.chores.first() is AqueductChore)
|
||||
s = apply(s, 0, TakeAqueduct(Resource.ORE))
|
||||
assertEquals(1, s.resources[0][Resource.ORE])
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a city gate draws progress cards for those whose red die and improvements match`() {
|
||||
var s = table().improve(0, Track.SCIENCE, 2).improve(1, Track.SCIENCE, 1).improve(2, Track.TRADE, 5)
|
||||
// Science gate, red die 2: seat 0 (level 2) draws; seat 1 (level 1) needs a 1.
|
||||
s = roll(s, listOf(3, 2), EventFace.SCIENCE)
|
||||
val drawn = (0 until 3).map { s.ck!!.progressHands[it].size + s.ck!!.progressVp[it] }
|
||||
assertEquals(listOf(1, 0, 0), drawn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fifth progress card must be given back`() {
|
||||
var s = table().improve(0, Track.SCIENCE, 5)
|
||||
.holding(0, ProgressType.CRANE).holding(0, ProgressType.MINING)
|
||||
.holding(0, ProgressType.SMITH).holding(0, ProgressType.ALCHEMIST)
|
||||
s = roll(s, listOf(1, 1), EventFace.SCIENCE)
|
||||
if (s.ck!!.progressVp[0] == 0) {
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
assertTrue(s.ck!!.chores.first() is DropProgressChore)
|
||||
val drop = CatanRules.legalMoves(s, 0).first()
|
||||
s = apply(s, 0, drop)
|
||||
assertEquals(PROGRESS_HAND_MAX, s.ck!!.progressHands[0].size)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Progress cards
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `irrigation and mining pay two for each matching hex, cities or not`() {
|
||||
val s0 = table().withHex(centre, Terrain.FIELDS, 5)
|
||||
.build(ring[0], BuildingType.CITY, 0).build(ring[2], BuildingType.SETTLEMENT, 0)
|
||||
.holding(0, ProgressType.IRRIGATION)
|
||||
val s = apply(s0, 0, PlayProgress(s0.cardId(0, ProgressType.IRRIGATION)))
|
||||
assertEquals("one hex touched by two buildings still pays two", 2, s.resources[0][Resource.GRAIN])
|
||||
|
||||
val m0 = table().withHex(centre, Terrain.MOUNTAINS, 5).build(ring[0], BuildingType.CITY, 0)
|
||||
.holding(0, ProgressType.MINING)
|
||||
val m = apply(m0, 0, PlayProgress(m0.cardId(0, ProgressType.MINING)))
|
||||
assertEquals(2, m.resources[0][Resource.ORE])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a played card goes to the bottom of its deck`() {
|
||||
val s0 = table().withHex(centre, Terrain.FIELDS, 5).build(ring[0], BuildingType.CITY, 0)
|
||||
.holding(0, ProgressType.IRRIGATION)
|
||||
val id = s0.cardId(0, ProgressType.IRRIGATION)
|
||||
val s = apply(s0, 0, PlayProgress(id))
|
||||
assertTrue(s.ck!!.progressHands[0].isEmpty())
|
||||
assertEquals(id, s.ck!!.decks.getValue(Track.SCIENCE).last().id)
|
||||
assertEquals(18, s.ck!!.decks.getValue(Track.SCIENCE).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alchemist names the dice before the roll`() {
|
||||
var s = table().holding(0, ProgressType.ALCHEMIST).copy(phase = CatanPhase.AWAIT_ROLL)
|
||||
val plays = CatanRules.legalMoves(s, 0).filterIsInstance<PlayProgress>()
|
||||
assertEquals(36, plays.size)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.ALCHEMIST), dice = listOf(3, 5)))
|
||||
assertEquals(CatanPhase.AWAIT_ROLL, s.phase)
|
||||
s = apply(s, 0, RollDice(event = EventFace.TRADE))
|
||||
assertEquals(listOf(3, 5), s.lastDice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alchemist cannot be played after the roll`() {
|
||||
val s = table().holding(0, ProgressType.ALCHEMIST)
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is PlayProgress })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `printer and constitution score at once and are never held`() {
|
||||
// Pull every card of the deck out until a victory-point card is on top, then draw it.
|
||||
val s0 = table().improve(0, Track.SCIENCE, 5)
|
||||
val deck = s0.ck!!.decks.getValue(Track.SCIENCE)
|
||||
val printer = deck.first { it.kind == ProgressType.PRINTER }
|
||||
val rigged = s0.copy(
|
||||
ck = s0.ck!!.copy(decks = s0.ck!!.decks + (Track.SCIENCE to listOf(printer) + deck.filter { it.id != printer.id })),
|
||||
)
|
||||
val s = roll(rigged, listOf(1, 1), EventFace.SCIENCE)
|
||||
assertEquals(1, s.ck!!.progressVp[0])
|
||||
assertTrue(s.ck!!.progressHands[0].isEmpty())
|
||||
assertEquals(1, CatanRules.publicVictoryPoints(s)[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the merchant is worth a point and trades its hex's resource two for one`() {
|
||||
val s0 = table().withHex(centre, Terrain.PASTURE, 6).build(ring[0], BuildingType.SETTLEMENT, 0)
|
||||
.hand(0, Good.WOOL to 2).holding(0, ProgressType.MERCHANT)
|
||||
val s = apply(s0, 0, PlayProgress(s0.cardId(0, ProgressType.MERCHANT), hexA = centre))
|
||||
assertEquals(MerchantAt(0, centre), s.ck!!.merchant)
|
||||
assertEquals("a settlement and the merchant", 2, CatanRules.publicVictoryPoints(s)[0])
|
||||
assertEquals(2, CatanCk.bankRatio(s, 0, Good.WOOL))
|
||||
assertEquals(4, CatanCk.bankRatio(s, 0, Good.LUMBER))
|
||||
|
||||
// Another player's merchant takes it over, with the point.
|
||||
val t0 = s.holding(1, ProgressType.MERCHANT).build(ring[3], BuildingType.SETTLEMENT, 1).copy(turn = 1)
|
||||
val t = apply(t0, 1, PlayProgress(t0.cardId(1, ProgressType.MERCHANT), hexA = centre))
|
||||
assertEquals(MerchantAt(1, centre), t.ck!!.merchant)
|
||||
assertEquals(1, CatanRules.publicVictoryPoints(t)[0])
|
||||
assertEquals(2, CatanRules.publicVictoryPoints(t)[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resource monopoly takes two of a resource from each other seat and trade monopoly one commodity`() {
|
||||
var s = table().hand(1, Good.GRAIN to 3, Good.CLOTH to 2).hand(2, Good.GRAIN to 1, Good.CLOTH to 0)
|
||||
.holding(0, ProgressType.RESOURCE_MONOPOLY).holding(0, ProgressType.TRADE_MONOPOLY)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.RESOURCE_MONOPOLY), good = Good.GRAIN))
|
||||
assertEquals(3, s.resources[0][Resource.GRAIN])
|
||||
assertEquals(1, s.resources[1][Resource.GRAIN])
|
||||
assertEquals(0, s.resources[2][Resource.GRAIN])
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.TRADE_MONOPOLY), good = Good.CLOTH))
|
||||
assertEquals(1, s.ck!!.commodities[0][Commodity.CLOTH])
|
||||
assertEquals(1, s.ck!!.commodities[1][Commodity.CLOTH])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saboteur makes everyone level or ahead discard half`() {
|
||||
// Seat 0 has one point (a settlement); seat 1 has two (a city); seat 2 has none.
|
||||
var s = table().build(a, BuildingType.SETTLEMENT, 0).build(c, BuildingType.CITY, 1)
|
||||
.hand(1, Good.LUMBER to 6).hand(2, Good.LUMBER to 6)
|
||||
.holding(0, ProgressType.SABOTEUR)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.SABOTEUR)))
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
assertEquals(1, CatanRules.currentSeat(s))
|
||||
s = apply(s, 1, DiscardGoods(List(3) { Good.LUMBER }))
|
||||
assertEquals("seat 2 has fewer points and keeps its hand", CatanPhase.PLAYING, s.phase)
|
||||
assertEquals(6, s.resources[2][Resource.LUMBER])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wedding takes two cards of their choosing from each seat that is ahead`() {
|
||||
var s = table().build(a, BuildingType.CITY, 1).hand(1, Good.LUMBER to 1, Good.ORE to 1, Good.WOOL to 3)
|
||||
.holding(0, ProgressType.WEDDING)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.WEDDING)))
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
assertEquals(1, CatanRules.currentSeat(s))
|
||||
s = apply(s, 1, GiveGoods(listOf(Good.WOOL, Good.WOOL)))
|
||||
assertEquals(2, s.resources[0][Resource.WOOL])
|
||||
assertEquals(1, s.resources[1][Resource.WOOL])
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `master merchant takes two cards from a seat with more points, and only from one`() {
|
||||
val s0 = table().build(a, BuildingType.CITY, 1).hand(1, Good.ORE to 2, Good.COIN to 1)
|
||||
.holding(0, ProgressType.MASTER_MERCHANT)
|
||||
val plays = CatanRules.legalMoves(s0, 0).filterIsInstance<PlayProgress>()
|
||||
assertTrue(plays.all { it.target == 1 })
|
||||
val s = apply(s0, 0, PlayProgress(s0.cardId(0, ProgressType.MASTER_MERCHANT), target = 1, goods = listOf(Good.ORE, Good.COIN)))
|
||||
assertEquals(1, s.resources[0][Resource.ORE])
|
||||
assertEquals(1, s.ck!!.commodities[0][Commodity.COIN])
|
||||
// A seat that is level or behind may not be picked on.
|
||||
val level = table().build(a, BuildingType.CITY, 0).build(c, BuildingType.CITY, 1).hand(1, Good.ORE to 2)
|
||||
.holding(0, ProgressType.MASTER_MERCHANT)
|
||||
assertTrue(CatanRules.legalMoves(level, 0).none { it is PlayProgress })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `warlord wakes every knight without grain and they still cannot act that turn`() {
|
||||
var s = table().path(0, a, b, c).knight(a, 0).knight(c, 0).holding(0, ProgressType.WARLORD)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.WARLORD)))
|
||||
assertTrue(s.ck!!.knights.all { it.active })
|
||||
assertEquals(0, s.resources[0].values.sum())
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is MoveKnight })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bishop moves the robber and robs every seat beside it once the barbarians have landed`() {
|
||||
val s0 = table().build(ring[0], BuildingType.SETTLEMENT, 1).build(ring[2], BuildingType.SETTLEMENT, 2)
|
||||
.hand(1, Good.LUMBER to 1).hand(2, Good.ORE to 1)
|
||||
.holding(0, ProgressType.BISHOP)
|
||||
assertTrue("the robber sleeps until the first attack", CatanRules.legalMoves(s0, 0).none { it is PlayProgress })
|
||||
val awake = s0.copy(ck = s0.ck!!.copy(attacked = true), robber = 0)
|
||||
var s = apply(awake, 0, PlayProgress(awake.cardId(0, ProgressType.BISHOP)))
|
||||
assertEquals(CatanPhase.ROBBER, s.phase)
|
||||
s = apply(s, 0, MoveRobber(CatanBoard.HEXES[centre]))
|
||||
assertEquals(1, s.resources[0][Resource.LUMBER])
|
||||
assertEquals(1, s.resources[0][Resource.ORE])
|
||||
assertEquals(0, CatanCk.goodsCount(s, 1))
|
||||
assertEquals(0, CatanCk.goodsCount(s, 2))
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `smith promotes two knights for free`() {
|
||||
var s = table().path(0, a, b, c).knight(a, 0).knight(c, 0).holding(0, ProgressType.SMITH)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.SMITH)))
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
s = apply(s, 0, PromoteKnight(a))
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
s = apply(s, 0, PromoteKnight(c))
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
assertEquals(listOf(2, 2), s.ck!!.knights.map { it.level })
|
||||
assertEquals(0, s.resources[0].values.sum())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engineer raises a wall for nothing and medicine upgrades a settlement for less`() {
|
||||
var s = table().build(a, BuildingType.CITY, 0).holding(0, ProgressType.ENGINEER)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.ENGINEER), vertex = a))
|
||||
assertEquals(listOf(a), s.ck!!.walls)
|
||||
|
||||
var m = table().build(c, BuildingType.SETTLEMENT, 0).hand(0, Good.ORE to 2, Good.GRAIN to 1)
|
||||
.holding(0, ProgressType.MEDICINE)
|
||||
m = apply(m, 0, PlayProgress(m.cardId(0, ProgressType.MEDICINE), vertex = c))
|
||||
assertEquals(CatanBuilding(BuildingType.CITY, 0), m.buildings[c])
|
||||
assertEquals(0, m.resources[0].values.sum())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inventor swaps two tokens but never a six, eight, two or twelve`() {
|
||||
val s0 = table().holding(0, ProgressType.INVENTOR)
|
||||
val plays = CatanRules.legalMoves(s0, 0).filterIsInstance<PlayProgress>()
|
||||
assertTrue(plays.isNotEmpty())
|
||||
assertTrue(plays.all { s0.numbers[it.hexA!!] in CatanCk.INVENTOR_NUMBERS && s0.numbers[it.hexB!!] in CatanCk.INVENTOR_NUMBERS })
|
||||
val play = plays.first()
|
||||
val s = apply(s0, 0, play)
|
||||
assertEquals(s0.numbers[play.hexA!!], s.numbers[play.hexB!!])
|
||||
assertEquals(s0.numbers[play.hexB!!], s.numbers[play.hexA!!])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diplomat removes an open road, and replaces your own for free`() {
|
||||
// A short road a - b: both ends open.
|
||||
var s = table().path(0, a, b).build(a, BuildingType.SETTLEMENT, 0).holding(0, ProgressType.DIPLOMAT)
|
||||
val plays = CatanRules.legalMoves(s, 0).filterIsInstance<PlayProgress>()
|
||||
val edge = plays.first().edge!!
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.DIPLOMAT), edge = edge))
|
||||
assertEquals(NO_PLAYER, s.roads[edge])
|
||||
assertEquals(CatanPhase.FREE_ROADS, s.phase)
|
||||
assertEquals(1, s.pendingFreeRoads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserter makes a seat remove a knight and gives its player one free`() {
|
||||
var s = table().path(0, a, b).path(1, c, d).knight(c, 1, level = 2).holding(0, ProgressType.DESERTER)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.DESERTER), target = 1))
|
||||
assertEquals(1, CatanRules.currentSeat(s))
|
||||
s = apply(s, 1, RemoveKnight(c))
|
||||
assertEquals(0, CatanRules.currentSeat(s))
|
||||
assertTrue(s.ck!!.knights.isEmpty())
|
||||
s = apply(s, 0, PlaceKnight(b))
|
||||
assertEquals(Knight(b, 0, 2, active = false), s.ck!!.knights.single())
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intrigue drives an enemy knight off a crossing beside your road`() {
|
||||
var s = table().path(0, a, b).path(1, b, c).knight(b, 1).holding(0, ProgressType.INTRIGUE)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.INTRIGUE), vertex = b))
|
||||
assertEquals(1, CatanRules.currentSeat(s))
|
||||
assertEquals(listOf<CatanMove>(RelocateKnight(c)), CatanRules.legalMoves(s, 1))
|
||||
s = apply(s, 1, RelocateKnight(c))
|
||||
assertEquals(c, s.ck!!.knights.single().vertex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spy lets you see a seat's progress cards and take one`() {
|
||||
var s = table().holding(1, ProgressType.MINING).holding(1, ProgressType.SMITH).holding(0, ProgressType.SPY)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.SPY), target = 1))
|
||||
assertEquals(CatanPhase.CHORE, s.phase)
|
||||
// Only the spy sees the hand while the choice is open.
|
||||
assertEquals(2, CatanRules.viewFor(s, 0).ck!!.progressHands[1].size)
|
||||
assertTrue(CatanRules.viewFor(s, 2).ck!!.progressHands[1].isEmpty())
|
||||
val take = s.cardId(1, ProgressType.SMITH)
|
||||
s = apply(s, 0, SpyTake(take))
|
||||
assertEquals(take, s.ck!!.progressHands[0].single().id)
|
||||
assertEquals(1, s.ck!!.progressHands[1].size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commercial harbour swaps your resource for a commodity of each seat's choosing`() {
|
||||
var s = table().hand(0, Good.WOOL to 2).hand(1, Good.CLOTH to 1, Good.COIN to 1).hand(2)
|
||||
.holding(0, ProgressType.COMMERCIAL_HARBOR)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.COMMERCIAL_HARBOR), good = Good.WOOL))
|
||||
assertEquals(1, CatanRules.currentSeat(s))
|
||||
s = apply(s, 1, GiveGoods(listOf(Good.COIN)))
|
||||
assertEquals(1, s.ck!!.commodities[0][Commodity.COIN])
|
||||
assertEquals(1, s.resources[1][Resource.WOOL])
|
||||
assertEquals(1, s.resources[0][Resource.WOOL])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merchant fleet trades one card type two for one for the turn`() {
|
||||
var s = table().hand(0, Good.PAPER to 4).holding(0, ProgressType.MERCHANT_FLEET)
|
||||
s = apply(s, 0, PlayProgress(s.cardId(0, ProgressType.MERCHANT_FLEET), good = Good.PAPER))
|
||||
assertEquals(2, CatanCk.bankRatio(s, 0, Good.PAPER))
|
||||
s = apply(s, 0, GoodsBankTrade(Good.PAPER, Good.ORE))
|
||||
s = apply(s, 0, GoodsBankTrade(Good.PAPER, Good.ORE))
|
||||
assertEquals(2, s.resources[0][Resource.ORE])
|
||||
val next = apply(s, 0, EndTurn)
|
||||
assertNull(next.ck!!.fleet)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Trading goods between players, and winning
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `players trade commodities`() {
|
||||
var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1)
|
||||
val offer = OfferGoods(1, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))
|
||||
s = apply(s, 0, offer)
|
||||
assertEquals(CatanPhase.TRADE_PENDING, s.phase)
|
||||
assertEquals(1, CatanRules.currentSeat(s))
|
||||
assertTrue(AcceptTrade in CatanRules.legalMoves(s, 1))
|
||||
s = apply(s, 1, AcceptTrade)
|
||||
assertEquals(2, s.ck!!.commodities[1][Commodity.CLOTH])
|
||||
assertEquals(1, s.resources[0][Resource.ORE])
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
assertNull(s.ck!!.goodsOffer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `thirteen points win, with the longest road worth two and no largest army`() {
|
||||
val road = table().build(a, BuildingType.CITY, 0).path(0, *ring.toIntArray())
|
||||
assertEquals("a city is two", 2, CatanRules.publicVictoryPoints(road)[0])
|
||||
assertEquals(5, CatanRules.longestRoadFor(road, 0))
|
||||
assertEquals(4, CatanRules.publicVictoryPoints(road.copy(longestRoadOwner = 0))[0])
|
||||
|
||||
// Four cities, a settlement, the road and two defender cards make thirteen.
|
||||
val far = CatanBoard.VERTEX_NEIGHBORS[a].first { it !in ring }
|
||||
var s = table()
|
||||
.build(ring[0], BuildingType.CITY, 0).build(ring[2], BuildingType.CITY, 0)
|
||||
.build(ring[4], BuildingType.CITY, 0).build(far, BuildingType.CITY, 0)
|
||||
.build(CatanBoard.VERTEX_NEIGHBORS[ring[3]].first { it !in ring }, BuildingType.SETTLEMENT, 0)
|
||||
s = s.copy(longestRoadOwner = 0, ck = s.ck!!.copy(defenderVp = listOf(2, 0, 0)))
|
||||
assertEquals(8 + 1 + 2 + 2, CatanRules.publicVictoryPoints(s)[0])
|
||||
s = s.copy(ck = s.ck!!.copy(defenderVp = listOf(2, 0, 0), progressVp = listOf(2, 0, 0)))
|
||||
s = apply(s, 0, EndTurn)
|
||||
assertEquals(CatanPhase.GAME_OVER, s.phase)
|
||||
assertEquals(0, s.winner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base game victory points are unchanged by the expansion code`() {
|
||||
val base = CatanRules.initialState(config(ck = false)).copy(longestRoadOwner = 0, largestArmyOwner = 1)
|
||||
assertEquals(listOf(5, 5, 0), CatanRules.publicVictoryPoints(base))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole expansion state survives its wire format and keeps hidden cards hidden`() {
|
||||
val s = table().holding(0, ProgressType.SMITH).holding(1, ProgressType.MINING).knight(a, 0, 2, true)
|
||||
.improve(2, Track.TRADE, 3)
|
||||
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
|
||||
val view = CatanRules.viewFor(s, 0)
|
||||
assertEquals(1, view.ck!!.progressHands[0].size)
|
||||
assertTrue(view.ck!!.progressHands[1].isEmpty())
|
||||
assertEquals(listOf(1, 1, 0), view.ck!!.progressCounts)
|
||||
assertTrue(view.ck!!.decks.values.all { it.isEmpty() })
|
||||
val encoded = CatanRules.encodeMove(PlayProgress(3, target = 1, goods = listOf(Good.ORE)))
|
||||
assertEquals(PlayProgress(3, target = 1, goods = listOf(Good.ORE)), CatanRules.decodeMove(encoded))
|
||||
assertEquals(EndSmith, CatanRules.decodeMove(CatanRules.encodeMove(EndSmith)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
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
|
||||
|
||||
/**
|
||||
* Engines playing Cities & Knights against each other. These are the
|
||||
* playtests: every move is checked for legality, the state is round-tripped
|
||||
* through its wire format as it goes, nothing may leave a seat without a move,
|
||||
* and the game has to reach a result.
|
||||
*/
|
||||
class CatanCkSelfPlayTest {
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
/** What one game looked like, for the summary printed at the end. */
|
||||
private data class Report(
|
||||
val seed: Long,
|
||||
val moves: Int,
|
||||
val turns: Int,
|
||||
val attacks: Int,
|
||||
val progressPlayed: Int,
|
||||
val metropolises: Int,
|
||||
val winner: Int?,
|
||||
val points: List<Int>,
|
||||
)
|
||||
|
||||
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 30_000): Pair<CatanState, Report> {
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(options, seed))
|
||||
var moves = 0
|
||||
var attacks = 0
|
||||
var progress = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("seed $seed ran past $cap moves; log tail: ${state.log.takeLast(8)}", moves++ < cap)
|
||||
val seat = CatanRules.currentSeat(state)
|
||||
?: error("seed $seed: nobody on the clock in ${state.phase}; log tail: ${state.log.takeLast(8)}")
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
assertTrue("seed $seed: seat $seat has no legal move in ${state.phase}", legal.isNotEmpty())
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue(
|
||||
"seed $seed: seat $seat chose the illegal move $move in ${state.phase}; " +
|
||||
"log tail: ${state.log.takeLast(5)}",
|
||||
CatanRules.isLegalMove(state, seat, move),
|
||||
)
|
||||
val before = state.ck!!.attacked
|
||||
val wasAttacks = state.log.count { it.contains("The barbarians land") }
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
attacks += state.log.count { it.contains("The barbarians land") } - wasAttacks
|
||||
if (move is PlayProgress) progress++
|
||||
if (!before && state.ck!!.attacked) Unit
|
||||
|
||||
if (moves % 97 == 0) {
|
||||
val back = CatanRules.decodeState(CatanRules.encodeState(state))
|
||||
assertEquals("seed $seed: the state did not survive its wire format", state, back)
|
||||
// A seat's view never carries another seat's progress cards or the decks.
|
||||
for (viewer in 0 until options.playerCount) {
|
||||
val view = CatanRules.viewFor(state, viewer)
|
||||
assertTrue(view.ck!!.decks.values.all { it.isEmpty() })
|
||||
for (other in 0 until options.playerCount) {
|
||||
if (other != viewer) {
|
||||
val spied = (state.ck!!.chores.firstOrNull() as? SpyChore)
|
||||
val allowed = spied != null && spied.seat == viewer && spied.target == other
|
||||
if (!allowed) assertTrue(view.ck!!.progressHands[other].isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
checkInvariants(state, seed)
|
||||
}
|
||||
val ck = state.ck!!
|
||||
return state to Report(
|
||||
seed = seed,
|
||||
moves = moves,
|
||||
turns = state.turnNumber,
|
||||
attacks = attacks,
|
||||
progressPlayed = progress,
|
||||
metropolises = ck.metropolises.size,
|
||||
winner = state.winner,
|
||||
points = CatanRules.publicVictoryPoints(state),
|
||||
)
|
||||
}
|
||||
|
||||
/** Things that must hold in every state of every game. */
|
||||
private fun checkInvariants(state: CatanState, seed: Long) {
|
||||
val ck = state.ck!!
|
||||
val tag = "seed $seed turn ${state.turnNumber}"
|
||||
// Cards are conserved: no good is held in more copies than the bank started with.
|
||||
for (good in Good.entries) {
|
||||
val held = (0 until state.playerCount).sumOf { CatanCk.goodsOf(state, it)[good] ?: 0 }
|
||||
val cap = if (good.isCommodity) BANK_PER_COMMODITY else BANK_PER_RESOURCE
|
||||
assertTrue("$tag: $held ${good} cards exist, the bank only had $cap", held in 0..cap)
|
||||
}
|
||||
for (seat in 0 until state.playerCount) {
|
||||
assertTrue(
|
||||
"$tag: seat $seat holds negative cards",
|
||||
CatanCk.goodsOf(state, seat).values.all { it >= 0 },
|
||||
)
|
||||
for (level in 1..3) {
|
||||
assertTrue(
|
||||
"$tag: seat $seat has more than two level-$level knights",
|
||||
ck.knights.count { it.owner == seat && it.level == level } <= KNIGHTS_PER_LEVEL,
|
||||
)
|
||||
}
|
||||
assertTrue("$tag: too many progress cards in seat $seat's hand", ck.progressHands[seat].size <= PROGRESS_HAND_MAX + 1)
|
||||
assertTrue("$tag: seat $seat has more than three walls", CatanCk.wallsOf(state, seat) <= MAX_WALLS)
|
||||
assertTrue(
|
||||
"$tag: seat $seat has more than five settlements",
|
||||
state.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) } <= MAX_SETTLEMENTS,
|
||||
)
|
||||
assertTrue(
|
||||
"$tag: seat $seat has more than four cities",
|
||||
state.buildings.count { it == CatanBuilding(BuildingType.CITY, seat) } <= MAX_CITIES,
|
||||
)
|
||||
}
|
||||
// A knight stands on a crossing nothing else stands on, and no two share one.
|
||||
assertEquals("$tag: two knights share a crossing", ck.knights.size, ck.knights.map { it.vertex }.toSet().size)
|
||||
for (k in ck.knights) {
|
||||
assertTrue("$tag: a knight stands on a building at ${k.vertex}", state.buildings[k.vertex] == null)
|
||||
}
|
||||
// Metropolises stand on cities of their owners.
|
||||
for ((_, m) in ck.metropolises) {
|
||||
assertEquals(
|
||||
"$tag: a metropolis is not on its owner's city",
|
||||
CatanBuilding(BuildingType.CITY, m.seat),
|
||||
state.buildings[m.vertex],
|
||||
)
|
||||
}
|
||||
// Every progress card exists exactly once, in a deck or a hand, or was played face up.
|
||||
val cards = ck.decks.values.sumOf { it.size } + ck.progressHands.sumOf { it.size } + ck.progressVp.sum()
|
||||
assertEquals("$tag: progress cards were lost or duplicated", 54, cards)
|
||||
assertTrue("$tag: barbarians out of range", ck.barbarians in 1..BARBARIAN_STEPS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three engines play Cities and Knights to a finish`() {
|
||||
val reports = (1L..12L).map { seed ->
|
||||
val (state, report) = playOut(
|
||||
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
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!!) >= 13,
|
||||
)
|
||||
report
|
||||
}
|
||||
reports.forEach { println("CK3 $it") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four engines play Cities and Knights to a finish`() {
|
||||
val reports = (101L..108L).map { seed ->
|
||||
val (state, report) = playOut(
|
||||
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
|
||||
seed,
|
||||
)
|
||||
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
report
|
||||
}
|
||||
reports.forEach { println("CK4 $it") }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user