Katan: Cities & Knights #8
@@ -20,10 +20,14 @@ import org.prolibertate.games.game.engine.GameAi
|
|||||||
*/
|
*/
|
||||||
class CatanAi : GameAi<CatanState, CatanMove> {
|
class CatanAi : GameAi<CatanState, CatanMove> {
|
||||||
|
|
||||||
|
/** Plays the Cities & Knights tables; this class keeps the base game. */
|
||||||
|
private val ck = CatanCkAi(this)
|
||||||
|
|
||||||
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
|
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
|
||||||
"No legal move for seat $seat"
|
"No legal move for seat $seat"
|
||||||
}
|
}
|
||||||
|
if (state.isCk) return ck.choose(state, seat, legal)
|
||||||
return when (state.phase) {
|
return when (state.phase) {
|
||||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||||
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
||||||
@@ -40,8 +44,8 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
|
|
||||||
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
||||||
|
|
||||||
CatanPhase.GAME_OVER -> legal.firstOrNull()
|
CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||||
?: error("The game is over; nobody chooses a move")
|
?: error("Nothing to choose in phase ${state.phase}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +53,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
// Setup
|
// Setup
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
private fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
|
internal fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
|
||||||
require(places.isNotEmpty()) { "No legal settlement placement" }
|
require(places.isNotEmpty()) { "No legal settlement placement" }
|
||||||
return places.maxByOrNull { place ->
|
return places.maxByOrNull { place ->
|
||||||
spotValue(state, place.vertex) +
|
spotValue(state, place.vertex) +
|
||||||
@@ -276,7 +280,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/** What a seat would think of building on [vertex]: production and ports. */
|
/** What a seat would think of building on [vertex]: production and ports. */
|
||||||
private fun spotValue(state: CatanState, vertex: Int): Double {
|
internal fun spotValue(state: CatanState, vertex: Int): Double {
|
||||||
if (state.buildings[vertex] != null) return -1.0
|
if (state.buildings[vertex] != null) return -1.0
|
||||||
var value = 0.0
|
var value = 0.0
|
||||||
val kinds = mutableSetOf<Resource>()
|
val kinds = mutableSetOf<Resource>()
|
||||||
@@ -297,7 +301,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
* What a road on this edge leads to: the best settlement spot it brings
|
* What a road on this edge leads to: the best settlement spot it brings
|
||||||
* within reach, discounted for being one step further out.
|
* within reach, discounted for being one step further out.
|
||||||
*/
|
*/
|
||||||
private fun roadValue(state: CatanState, move: BuildRoad): Double {
|
internal fun roadValue(state: CatanState, move: BuildRoad): Double {
|
||||||
var best = 0.0
|
var best = 0.0
|
||||||
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
|
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
|
||||||
for (endpoint in listOf(from, to)) {
|
for (endpoint in listOf(from, to)) {
|
||||||
@@ -309,7 +313,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** How much the AI wants to hold one card of [resource]. */
|
/** How much the AI wants to hold one card of [resource]. */
|
||||||
private fun want(resource: Resource): Double = when (resource) {
|
internal fun want(resource: Resource): Double = when (resource) {
|
||||||
Resource.ORE -> 1.20 // the city
|
Resource.ORE -> 1.20 // the city
|
||||||
Resource.GRAIN -> 1.15 // the city and the dev cards
|
Resource.GRAIN -> 1.15 // the city and the dev cards
|
||||||
Resource.LUMBER -> 1.0 // every road and settlement
|
Resource.LUMBER -> 1.0 // every road and settlement
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The computer opponent for Cities & Knights, reached through [CatanAi].
|
||||||
|
*
|
||||||
|
* It plays the same cautious game as the base AI — settle well, raise cities —
|
||||||
|
* and adds the three things this rule set asks of a player: keep enough active
|
||||||
|
* knights standing when the barbarians land, spend commodities on improvements
|
||||||
|
* as soon as it can, and answer every chore the table raises with the card it
|
||||||
|
* values least. It reads only what a player at the table could see, plus its
|
||||||
|
* own hand.
|
||||||
|
*
|
||||||
|
* It is not a strong player. It never moves knights about (they stay where
|
||||||
|
* hired, and are activated when the barbarians draw near), never plays the
|
||||||
|
* progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat,
|
||||||
|
* Deserter, Intrigue), and makes no domestic offers, though it answers them.
|
||||||
|
*/
|
||||||
|
internal class CatanCkAi(private val ai: CatanAi) {
|
||||||
|
|
||||||
|
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove = when (state.phase) {
|
||||||
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
|
||||||
|
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
|
||||||
|
|
||||||
|
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
|
||||||
|
|
||||||
|
CatanPhase.CHORE -> chooseChore(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
|
||||||
|
|
||||||
|
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
|
||||||
|
|
||||||
|
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||||
|
?: error("Nothing to choose in phase ${state.phase}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Chores
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val ck = state.ck!!
|
||||||
|
return when (val chore = ck.chores.first()) {
|
||||||
|
is DiscardChore -> {
|
||||||
|
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
|
||||||
|
DiscardGoods(CatanCk.goodsList(state, seat).sortedBy { worth(state, seat, it) }.take(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
is GiveChore -> {
|
||||||
|
val pool = CatanCk.goodsList(state, seat)
|
||||||
|
.filter { !chore.commoditiesOnly || it.isCommodity }
|
||||||
|
.sortedBy { worth(state, seat, it) }
|
||||||
|
GiveGoods(pool.take(minOf(chore.count, pool.size)))
|
||||||
|
}
|
||||||
|
|
||||||
|
is PillageChore ->
|
||||||
|
legal.filterIsInstance<PillageCity>().minByOrNull { production(state, it.vertex) } ?: legal.first()
|
||||||
|
|
||||||
|
is PickDeckChore -> {
|
||||||
|
val picks = legal.filterIsInstance<PickDeck>()
|
||||||
|
picks.maxByOrNull { CatanCk.improvement(state, seat, it.track) } ?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
is DropProgressChore ->
|
||||||
|
legal.filterIsInstance<DropProgress>().minByOrNull { drop ->
|
||||||
|
ck.progressHands[seat].first { it.id == drop.card }.kind.let(::cardWorth)
|
||||||
|
} ?: legal.first()
|
||||||
|
|
||||||
|
is MetropolisChore ->
|
||||||
|
legal.filterIsInstance<PlaceMetropolis>().maxByOrNull { production(state, it.vertex) }
|
||||||
|
?: legal.first()
|
||||||
|
|
||||||
|
is AqueductChore ->
|
||||||
|
legal.filterIsInstance<TakeAqueduct>().maxByOrNull { ai.want(it.resource) } ?: legal.first()
|
||||||
|
|
||||||
|
is RelocateChore -> legal.first()
|
||||||
|
|
||||||
|
is RemoveKnightChore ->
|
||||||
|
legal.filterIsInstance<RemoveKnight>().minByOrNull {
|
||||||
|
CatanCk.knightAt(state, it.vertex)?.level ?: 0
|
||||||
|
} ?: legal.first()
|
||||||
|
|
||||||
|
is PlaceKnightChore -> legal.first()
|
||||||
|
|
||||||
|
is SpyChore -> legal.first()
|
||||||
|
|
||||||
|
is SmithChore ->
|
||||||
|
legal.filterIsInstance<PromoteKnight>().firstOrNull() ?: legal.first { it is EndSmith }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// The robber
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
|
||||||
|
require(moves.isNotEmpty()) { "No legal robber move" }
|
||||||
|
val points = CatanRules.publicVictoryPoints(state)
|
||||||
|
return moves.maxByOrNull { move ->
|
||||||
|
val hex = CatanBoard.hexIndex.getValue(move.hex)
|
||||||
|
var score = 0.0
|
||||||
|
for (v in CatanBoard.HEX_CORNERS[hex]) {
|
||||||
|
val building = state.buildings[v] ?: continue
|
||||||
|
val worth = if (building.type == BuildingType.CITY) 2 else 1
|
||||||
|
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
|
||||||
|
score += if (building.owner == seat) -worth * odds else worth * odds
|
||||||
|
}
|
||||||
|
if (move.victim != null) {
|
||||||
|
score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim]
|
||||||
|
}
|
||||||
|
score
|
||||||
|
} ?: moves.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// The main phase
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val ck = state.ck!!
|
||||||
|
val soon = ck.barbarians <= 3
|
||||||
|
|
||||||
|
// Chase the robber off a hex it is squatting on.
|
||||||
|
if (ck.attacked) {
|
||||||
|
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
|
||||||
|
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
|
||||||
|
if (onMine != null && robberHurtsMe) return onMine
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cheap upgrade first, then the full-price one.
|
||||||
|
legal.filterIsInstance<PlayProgress>()
|
||||||
|
.filter { kindOf(state, seat, it) == ProgressType.MEDICINE }
|
||||||
|
.maxByOrNull { production(state, it.vertex!!) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildCity>().maxByOrNull { production(state, it.vertex) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildSettlement>().maxByOrNull { ai.spotValue(state, it.vertex) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
// Improvements spend cards nothing else needs. Favour the track furthest along.
|
||||||
|
legal.filterIsInstance<BuyImprovement>()
|
||||||
|
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
// Knights: stand ready by the time the barbarians land.
|
||||||
|
defence(state, seat, legal, soon)?.let { return it }
|
||||||
|
|
||||||
|
progress(state, seat, legal)?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
|
||||||
|
if (CatanCk.goodsCount(state, seat) >= 7) return it
|
||||||
|
}
|
||||||
|
|
||||||
|
legal.filterIsInstance<BuildRoad>()
|
||||||
|
.filter { ai.roadValue(state, it) > 0.1 }
|
||||||
|
.maxByOrNull { ai.roadValue(state, it) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
legal.filterIsInstance<GoodsBankTrade>()
|
||||||
|
.filter { completes(state, seat, it) }
|
||||||
|
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
return legal.lastOrNull { it is EndTurn } ?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hire, promote and activate so the knights are standing when the barbarians land. */
|
||||||
|
private fun defence(state: CatanState, seat: Int, legal: List<CatanMove>, soon: Boolean): CatanMove? {
|
||||||
|
val mine = CatanCk.knightsOf(state, seat)
|
||||||
|
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
|
||||||
|
|
||||||
|
if (soon) {
|
||||||
|
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
val want = if (soon) 4 else 2
|
||||||
|
if (mine.size < want) {
|
||||||
|
legal.filterIsInstance<HireKnight>().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire ->
|
||||||
|
// Keep a grain back for waking it, once the ship is close.
|
||||||
|
if (!soon || grainLeft >= 1) return hire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (soon || mine.size >= 2) {
|
||||||
|
legal.filterIsInstance<PromoteKnight>().firstOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
if (soon) {
|
||||||
|
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progress cards worth playing without a plan. */
|
||||||
|
private fun progress(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove? {
|
||||||
|
val ck = state.ck!!
|
||||||
|
val soon = ck.barbarians <= 4
|
||||||
|
val plays = legal.filterIsInstance<PlayProgress>()
|
||||||
|
fun kindOf(play: PlayProgress) = ck.progressHands[seat].first { it.id == play.card }.kind
|
||||||
|
fun first(kind: ProgressType) = plays.firstOrNull { kindOf(it) == kind }
|
||||||
|
|
||||||
|
first(ProgressType.IRRIGATION)?.let { return it }
|
||||||
|
first(ProgressType.MINING)?.let { return it }
|
||||||
|
first(ProgressType.ENGINEER)?.let { return it }
|
||||||
|
first(ProgressType.SMITH)?.let { return it }
|
||||||
|
first(ProgressType.WEDDING)?.let { return it }
|
||||||
|
if (soon) first(ProgressType.WARLORD)?.let { return it }
|
||||||
|
|
||||||
|
plays.filter { kindOf(it) == ProgressType.MASTER_MERCHANT }
|
||||||
|
.maxByOrNull { play -> play.goods.orEmpty().sumOf { worth(state, seat, it) } }
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
first(ProgressType.SPY)?.let { return it }
|
||||||
|
first(ProgressType.BISHOP)?.let { return it }
|
||||||
|
|
||||||
|
if (legal.filterIsInstance<BuildRoad>().size >= 2) first(ProgressType.ROAD_BUILDING)?.let { return it }
|
||||||
|
|
||||||
|
// Crane: only when it makes the difference to an improvement.
|
||||||
|
first(ProgressType.CRANE)?.let { crane ->
|
||||||
|
val helps = Track.entries.any { track ->
|
||||||
|
val level = CatanCk.improvement(state, seat, track)
|
||||||
|
val have = ck.commodities[seat][track.commodity] ?: 0
|
||||||
|
level < IMPROVEMENT_MAX && have == level // one short of the next level's price
|
||||||
|
}
|
||||||
|
if (helps) return crane
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saboteur: when someone level or ahead is sitting on a pile.
|
||||||
|
first(ProgressType.SABOTEUR)?.let { saboteur ->
|
||||||
|
val vp = CatanRules.publicVictoryPoints(state)
|
||||||
|
val pile = (0 until state.playerCount).filter { it != seat && vp[it] >= vp[seat] }
|
||||||
|
.sumOf { CatanCk.goodsCount(state, it) / 2 }
|
||||||
|
if (pile >= 4) return saboteur
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monopolies: only when the table is holding a pile of it.
|
||||||
|
plays.filter { kindOf(it) == ProgressType.RESOURCE_MONOPOLY || kindOf(it) == ProgressType.TRADE_MONOPOLY }
|
||||||
|
.maxByOrNull { held(state, seat, it.good!!) }
|
||||||
|
?.let { best ->
|
||||||
|
val each = if (kindOf(best) == ProgressType.RESOURCE_MONOPOLY) 2 else 1
|
||||||
|
val take = (0 until state.playerCount).filter { it != seat }
|
||||||
|
.sumOf { minOf(each, CatanCk.goodsOf(state, it)[best.good!!] ?: 0) }
|
||||||
|
if (take >= 3) return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// The merchant, on the best hex beside a building of ours.
|
||||||
|
plays.filter { kindOf(it) == ProgressType.MERCHANT }
|
||||||
|
.maxByOrNull { play ->
|
||||||
|
val hex = play.hexA!!
|
||||||
|
CatanBoard.numberOdds(state.numbers[hex]).toDouble()
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
// A commercial harbour turns spare resources into commodities.
|
||||||
|
plays.filter { kindOf(it) == ProgressType.COMMERCIAL_HARBOR }
|
||||||
|
.maxByOrNull { held(state, seat, it.good!!) }
|
||||||
|
?.let { if (held(state, seat, it.good!!) >= 2) return it }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun kindOf(state: CatanState, seat: Int, play: PlayProgress): ProgressType =
|
||||||
|
state.ck!!.progressHands[seat].first { it.id == play.card }.kind
|
||||||
|
|
||||||
|
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
|
||||||
|
private fun production(state: CatanState, vertex: Int): Double =
|
||||||
|
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
|
||||||
|
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
|
||||||
|
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun held(state: CatanState, seat: Int, good: Good): Int = CatanCk.goodsOf(state, seat)[good] ?: 0
|
||||||
|
|
||||||
|
/** Whether the trade leaves a hand that can pay for a city, a settlement or an improvement. */
|
||||||
|
private fun completes(state: CatanState, seat: Int, trade: GoodsBankTrade): Boolean {
|
||||||
|
val hand = CatanCk.goodsOf(state, seat).toMutableMap()
|
||||||
|
hand[trade.give] = (hand[trade.give] ?: 0) - CatanCk.bankRatio(state, seat, trade.give)
|
||||||
|
hand[trade.get] = (hand[trade.get] ?: 0) + 1
|
||||||
|
fun covers(cost: Map<Resource, Int>) = cost.all { (r, n) -> (hand[Good.of(r)] ?: 0) >= n }
|
||||||
|
if (covers(CatanRules.CITY_COST) || covers(CatanRules.SETTLEMENT_COST)) return true
|
||||||
|
return Track.entries.any { track ->
|
||||||
|
val level = CatanCk.improvement(state, seat, track)
|
||||||
|
level < IMPROVEMENT_MAX &&
|
||||||
|
(hand[Good.of(track.commodity)] ?: 0) >= level + 1 &&
|
||||||
|
(state.ck!!.commodities[seat][track.commodity] ?: 0) < level + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove =
|
||||||
|
legal.filterIsInstance<BuildRoad>()
|
||||||
|
.filter { ai.roadValue(state, it) > 0.05 }
|
||||||
|
.maxByOrNull { ai.roadValue(state, it) }
|
||||||
|
?: legal.firstOrNull { it is EndFreeRoads }
|
||||||
|
?: legal.first()
|
||||||
|
|
||||||
|
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
||||||
|
val decline = legal.first { it is DeclineTrade }
|
||||||
|
val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline
|
||||||
|
val offer = state.ck?.goodsOffer
|
||||||
|
if (offer != null) {
|
||||||
|
val incoming = offer.give.sumOf { worth(state, seat, it) }
|
||||||
|
val outgoing = offer.get.sumOf { worth(state, seat, it) }
|
||||||
|
return if (incoming > outgoing) accept else decline
|
||||||
|
}
|
||||||
|
val plain = state.pendingTrade ?: return decline
|
||||||
|
val incoming = plain.give.sumOf { ai.want(it) }
|
||||||
|
val outgoing = plain.get.sumOf { ai.want(it) }
|
||||||
|
return if (incoming > outgoing) accept else decline
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Valuations
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
|
||||||
|
private fun worth(state: CatanState, seat: Int, good: Good): Double =
|
||||||
|
good.resource?.let { ai.want(it) } ?: when (good) {
|
||||||
|
Good.PAPER -> 0.95
|
||||||
|
Good.COIN -> 0.9
|
||||||
|
else -> 0.85
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which track to lean toward when two are level: politics for the knights, then science. */
|
||||||
|
private fun trackLean(track: Track): Int = when (track) {
|
||||||
|
Track.POLITICS -> 2
|
||||||
|
Track.SCIENCE -> 1
|
||||||
|
Track.TRADE -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cardWorth(kind: ProgressType): Double = when (kind) {
|
||||||
|
ProgressType.INVENTOR, ProgressType.ALCHEMIST, ProgressType.DIPLOMAT,
|
||||||
|
ProgressType.DESERTER, ProgressType.INTRIGUE, ProgressType.MERCHANT_FLEET,
|
||||||
|
-> 0.0
|
||||||
|
|
||||||
|
ProgressType.MERCHANT, ProgressType.COMMERCIAL_HARBOR, ProgressType.CRANE -> 0.5
|
||||||
|
else -> 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The data of the Cities & Knights rule set. The rules themselves live in
|
||||||
|
* [CatanCk]; docs/RULES-catan-ck.md describes them and the choices made.
|
||||||
|
*
|
||||||
|
* Everything here is reached through [CatanState.ck], which is null for the
|
||||||
|
* base game, so a base game never sees any of it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Steps the barbarian ship must make before it lands. */
|
||||||
|
const val BARBARIAN_STEPS = 7
|
||||||
|
|
||||||
|
/** Commodity cards of each type the bank starts with (resources have 19). */
|
||||||
|
const val BANK_PER_COMMODITY = 12
|
||||||
|
|
||||||
|
/** Knights of each strength one player owns. */
|
||||||
|
const val KNIGHTS_PER_LEVEL = 2
|
||||||
|
|
||||||
|
/** City walls one player owns. */
|
||||||
|
const val MAX_WALLS = 3
|
||||||
|
|
||||||
|
/** Levels of city improvement in each track. */
|
||||||
|
const val IMPROVEMENT_MAX = 5
|
||||||
|
|
||||||
|
/** Progress cards a player may keep in hand. */
|
||||||
|
const val PROGRESS_HAND_MAX = 4
|
||||||
|
|
||||||
|
/** Cards a player may hold through a seven, before walls. */
|
||||||
|
const val BASE_HAND_LIMIT = 7
|
||||||
|
|
||||||
|
/** Longest Road is worth two points here; there is no Largest Army. */
|
||||||
|
const val CK_LONGEST_ROAD_VP = 2
|
||||||
|
|
||||||
|
/** Improvement level that unlocks the track's special power. */
|
||||||
|
const val IMPROVEMENT_POWER_LEVEL = 3
|
||||||
|
|
||||||
|
/** Level at which a track can claim its metropolis, and the level that can steal one. */
|
||||||
|
const val METROPOLIS_LEVEL = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the three cities-of-industry give in place of a second resource:
|
||||||
|
* forest gives paper, pasture cloth and mountains coin.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Commodity { CLOTH, COIN, PAPER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any card a player can hold in hand and trade: a resource or a commodity.
|
||||||
|
* Progress cards are not goods.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Good {
|
||||||
|
LUMBER, BRICK, WOOL, GRAIN, ORE, CLOTH, COIN, PAPER;
|
||||||
|
|
||||||
|
val resource: Resource?
|
||||||
|
get() = when (this) {
|
||||||
|
LUMBER -> Resource.LUMBER
|
||||||
|
BRICK -> Resource.BRICK
|
||||||
|
WOOL -> Resource.WOOL
|
||||||
|
GRAIN -> Resource.GRAIN
|
||||||
|
ORE -> Resource.ORE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val commodity: Commodity?
|
||||||
|
get() = when (this) {
|
||||||
|
CLOTH -> Commodity.CLOTH
|
||||||
|
COIN -> Commodity.COIN
|
||||||
|
PAPER -> Commodity.PAPER
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val isCommodity: Boolean get() = commodity != null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(resource: Resource): Good = when (resource) {
|
||||||
|
Resource.LUMBER -> LUMBER
|
||||||
|
Resource.BRICK -> BRICK
|
||||||
|
Resource.WOOL -> WOOL
|
||||||
|
Resource.GRAIN -> GRAIN
|
||||||
|
Resource.ORE -> ORE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun of(commodity: Commodity): Good = when (commodity) {
|
||||||
|
Commodity.CLOTH -> CLOTH
|
||||||
|
Commodity.COIN -> COIN
|
||||||
|
Commodity.PAPER -> PAPER
|
||||||
|
}
|
||||||
|
|
||||||
|
val RESOURCES: List<Good> = Resource.entries.map { of(it) }
|
||||||
|
val COMMODITIES: List<Good> = Commodity.entries.map { of(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three tracks of city improvement. Each is bought with one commodity, has
|
||||||
|
* a colour of progress-card deck and a power that unlocks at level three.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Track(val commodity: Commodity) {
|
||||||
|
/** Yellow, bought with cloth: trade commodities two for one. */
|
||||||
|
TRADE(Commodity.CLOTH),
|
||||||
|
|
||||||
|
/** Blue, bought with coin: knights may become mighty. */
|
||||||
|
POLITICS(Commodity.COIN),
|
||||||
|
|
||||||
|
/** Green, bought with paper: the aqueduct. */
|
||||||
|
SCIENCE(Commodity.PAPER),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One face of the event die: a ship, or a city gate of a track's colour.
|
||||||
|
* Three faces are ships.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class EventFace { SHIP, TRADE, POLITICS, SCIENCE }
|
||||||
|
|
||||||
|
/** The progress cards: [copies] of each in the deck of its [track]. */
|
||||||
|
@Serializable
|
||||||
|
enum class ProgressType(val track: Track, val copies: Int, val scoresAtOnce: Boolean = false) {
|
||||||
|
// Science (green)
|
||||||
|
ALCHEMIST(Track.SCIENCE, 2),
|
||||||
|
CRANE(Track.SCIENCE, 2),
|
||||||
|
ENGINEER(Track.SCIENCE, 1),
|
||||||
|
INVENTOR(Track.SCIENCE, 2),
|
||||||
|
IRRIGATION(Track.SCIENCE, 2),
|
||||||
|
MEDICINE(Track.SCIENCE, 2),
|
||||||
|
MINING(Track.SCIENCE, 2),
|
||||||
|
PRINTER(Track.SCIENCE, 1, scoresAtOnce = true),
|
||||||
|
ROAD_BUILDING(Track.SCIENCE, 2),
|
||||||
|
SMITH(Track.SCIENCE, 2),
|
||||||
|
|
||||||
|
// Politics (blue)
|
||||||
|
BISHOP(Track.POLITICS, 2),
|
||||||
|
CONSTITUTION(Track.POLITICS, 1, scoresAtOnce = true),
|
||||||
|
DESERTER(Track.POLITICS, 2),
|
||||||
|
DIPLOMAT(Track.POLITICS, 2),
|
||||||
|
INTRIGUE(Track.POLITICS, 2),
|
||||||
|
SABOTEUR(Track.POLITICS, 2),
|
||||||
|
SPY(Track.POLITICS, 3),
|
||||||
|
WARLORD(Track.POLITICS, 2),
|
||||||
|
WEDDING(Track.POLITICS, 2),
|
||||||
|
|
||||||
|
// Trade (yellow)
|
||||||
|
COMMERCIAL_HARBOR(Track.TRADE, 2),
|
||||||
|
MASTER_MERCHANT(Track.TRADE, 2),
|
||||||
|
MERCHANT(Track.TRADE, 6),
|
||||||
|
MERCHANT_FLEET(Track.TRADE, 2),
|
||||||
|
RESOURCE_MONOPOLY(Track.TRADE, 4),
|
||||||
|
TRADE_MONOPOLY(Track.TRADE, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One progress card; [id] is stable for the life of the game. */
|
||||||
|
@Serializable
|
||||||
|
data class ProgressCard(val id: Int, val kind: ProgressType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A knight on the board. [level] is its strength: 1 basic, 2 strong, 3 mighty.
|
||||||
|
*
|
||||||
|
* [activatedTurn] and [promotedTurn] record the turn number each happened on:
|
||||||
|
* a knight cannot act on the turn it was activated, and cannot be promoted twice
|
||||||
|
* in one turn.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Knight(
|
||||||
|
val vertex: Int,
|
||||||
|
val owner: Int,
|
||||||
|
val level: Int,
|
||||||
|
val active: Boolean = false,
|
||||||
|
val activatedTurn: Int = 0,
|
||||||
|
val promotedTurn: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A metropolis stands on one city: [vertex] of seat [seat]. */
|
||||||
|
@Serializable
|
||||||
|
data class MetropolisAt(val seat: Int, val vertex: Int)
|
||||||
|
|
||||||
|
/** The merchant sits on a hex, controlled by [seat] (who holds its point). */
|
||||||
|
@Serializable
|
||||||
|
data class MerchantAt(val seat: Int, val hex: Int)
|
||||||
|
|
||||||
|
/** A domestic trade offer in goods, which may include commodities. */
|
||||||
|
@Serializable
|
||||||
|
data class GoodsOffer(val by: Int, val to: Int, val give: List<Good>, val get: List<Good>)
|
||||||
|
|
||||||
|
/** Where play goes when the queue of [Chore]s runs out. */
|
||||||
|
@Serializable
|
||||||
|
enum class Back { PLAYING, AWAIT_ROLL, AFTER_EVENT, AFTER_DISCARDS, AFTER_PRODUCTION }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something one seat must decide before the game can go on. A chore is queued
|
||||||
|
* when a roll, an attack or a card needs an answer from somebody (possibly not
|
||||||
|
* the player whose turn it is); the first in [CkState.chores] is the one being
|
||||||
|
* asked.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed interface Chore {
|
||||||
|
val seat: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give [count] cards to the bank, of the seat's choosing (a seven, a saboteur). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_discard")
|
||||||
|
data class DiscardChore(override val seat: Int, val count: Int) : Chore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand [count] cards of the seat's choosing to seat [to] (a wedding, a
|
||||||
|
* commercial harbour). [commoditiesOnly] restricts them to commodities, and
|
||||||
|
* [reward] is the resource [to] gives back for each.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_give")
|
||||||
|
data class GiveChore(
|
||||||
|
override val seat: Int,
|
||||||
|
val count: Int,
|
||||||
|
val to: Int,
|
||||||
|
val commoditiesOnly: Boolean = false,
|
||||||
|
val reward: Good? = null,
|
||||||
|
) : Chore
|
||||||
|
|
||||||
|
/** The barbarians won and this seat lost the contest: pick a city to fall. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_pillage")
|
||||||
|
data class PillageChore(override val seat: Int) : Chore
|
||||||
|
|
||||||
|
/** Choose a colour of progress deck to draw from. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_pickDeck")
|
||||||
|
data class PickDeckChore(override val seat: Int) : Chore
|
||||||
|
|
||||||
|
/** More progress cards than the hand holds: give one back. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_dropProgress")
|
||||||
|
data class DropProgressChore(override val seat: Int) : Chore
|
||||||
|
|
||||||
|
/** Choose the city that will bear the [track] metropolis. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_metropolis")
|
||||||
|
data class MetropolisChore(override val seat: Int, val track: Track) : Chore
|
||||||
|
|
||||||
|
/** The aqueduct pays out: take any one resource. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_aqueduct")
|
||||||
|
data class AqueductChore(override val seat: Int) : Chore
|
||||||
|
|
||||||
|
/** A knight was driven from its place; move it, or lose it. [knight] stood on [Knight.vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_relocate")
|
||||||
|
data class RelocateChore(override val seat: Int, val knight: Knight) : Chore
|
||||||
|
|
||||||
|
/** A deserter was played against this seat: remove one of your knights. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_removeKnight")
|
||||||
|
data class RemoveKnightChore(override val seat: Int, val by: Int) : Chore
|
||||||
|
|
||||||
|
/** Place a knight of [level] on the board for free, active or not as given. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_placeKnight")
|
||||||
|
data class PlaceKnightChore(override val seat: Int, val level: Int, val active: Boolean) : Chore
|
||||||
|
|
||||||
|
/** Choose one of [target]'s progress cards to take. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_spy")
|
||||||
|
data class SpyChore(override val seat: Int, val target: Int) : Chore
|
||||||
|
|
||||||
|
/** Promote up to [left] more knights for free. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("c_smith")
|
||||||
|
data class SmithChore(override val seat: Int, val left: Int) : Chore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything Cities & Knights adds to the table.
|
||||||
|
*
|
||||||
|
* Counts that are public ([progressCounts], the knights, the improvement
|
||||||
|
* levels) are stored directly; the decks and the other seats' progress hands are
|
||||||
|
* here in full because the host owns the truth, and [CatanRules.viewFor] keeps
|
||||||
|
* them off the wire.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CkState(
|
||||||
|
/** Commodity cards per seat. */
|
||||||
|
val commodities: List<Map<Commodity, Int>>,
|
||||||
|
/** Improvement level (0..5) per seat and track. */
|
||||||
|
val improvements: List<Map<Track, Int>>,
|
||||||
|
/** The metropolis of each track that has one. */
|
||||||
|
val metropolises: Map<Track, MetropolisAt> = emptyMap(),
|
||||||
|
val knights: List<Knight> = emptyList(),
|
||||||
|
/** Vertices whose city has a wall. */
|
||||||
|
val walls: List<Int> = emptyList(),
|
||||||
|
/** Steps left before the barbarians land, [BARBARIAN_STEPS] at the start. */
|
||||||
|
val barbarians: Int = BARBARIAN_STEPS,
|
||||||
|
/** Whether the barbarians have landed once: until then the robber sleeps. */
|
||||||
|
val attacked: Boolean = false,
|
||||||
|
val eventDie: EventFace? = null,
|
||||||
|
/** Progress decks, top first. Order is hidden. */
|
||||||
|
val decks: Map<Track, List<ProgressCard>>,
|
||||||
|
val progressHands: List<List<ProgressCard>>,
|
||||||
|
/** Public mirror of the progress-hand sizes. */
|
||||||
|
val progressCounts: List<Int>,
|
||||||
|
/** Victory points from Constitution and Printer, played face up at once. */
|
||||||
|
val progressVp: List<Int>,
|
||||||
|
/** Defender of Catan cards won, one point each. */
|
||||||
|
val defenderVp: List<Int>,
|
||||||
|
val merchant: MerchantAt? = null,
|
||||||
|
/** A crane was played this turn and its discount is unspent. */
|
||||||
|
val crane: Boolean = false,
|
||||||
|
/** A merchant fleet was played this turn for this good. */
|
||||||
|
val fleet: Good? = null,
|
||||||
|
/** Dice named by an alchemist, used by the next roll. */
|
||||||
|
val alchemist: List<Int>? = null,
|
||||||
|
/** The robber is being moved by a bishop, which robs everyone nearby. */
|
||||||
|
val bishop: Boolean = false,
|
||||||
|
val chores: List<Chore> = emptyList(),
|
||||||
|
val back: Back = Back.PLAYING,
|
||||||
|
val goodsOffer: GoodsOffer? = null,
|
||||||
|
/** Seats that have already drawn a defender or winners' card this attack, for the log only. */
|
||||||
|
val lastAttack: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Moves
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Buy the next level of improvement in [track]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("buyImprovement")
|
||||||
|
data class BuyImprovement(val track: Track) : CatanMove
|
||||||
|
|
||||||
|
/** Hire a basic knight onto [vertex], an empty crossing at the end of one of your roads. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("hireKnight")
|
||||||
|
data class HireKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Pay a grain to activate the knight on [vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("activateKnight")
|
||||||
|
data class ActivateKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Promote the knight on [vertex] one level (paid, or free under a Smith). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("promoteKnight")
|
||||||
|
data class PromoteKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Move the active knight on [from] along your roads to [to], driving off a weaker knight there. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("moveKnight")
|
||||||
|
data class MoveKnight(val from: Int, val to: Int) : CatanMove
|
||||||
|
|
||||||
|
/** The active knight on [vertex] chases the robber; the robber move follows. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("chaseRobber")
|
||||||
|
data class ChaseRobber(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Raise a wall around the city on [vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("buildWall")
|
||||||
|
data class BuildWall(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a progress card. Which of the optional arguments are used depends on
|
||||||
|
* the card (see [CatanCk]); the engine's legal list names every combination it
|
||||||
|
* accepts, so a client only ever chooses among them.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("progress")
|
||||||
|
data class PlayProgress(
|
||||||
|
val card: Int,
|
||||||
|
val target: Int? = null,
|
||||||
|
val vertex: Int? = null,
|
||||||
|
val edge: Int? = null,
|
||||||
|
val hexA: Int? = null,
|
||||||
|
val hexB: Int? = null,
|
||||||
|
val good: Good? = null,
|
||||||
|
val dice: List<Int>? = null,
|
||||||
|
val goods: List<Good>? = null,
|
||||||
|
) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade [give] to the bank for [get], at the best ratio this seat has for the
|
||||||
|
* card it gives. Replaces [MaritimeTrade] in Cities & Knights, where
|
||||||
|
* commodities trade too.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("goodsBank")
|
||||||
|
data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer a trade in goods to seat [to]. Free-form like [OfferTrade], so it is
|
||||||
|
* checked structurally and is not in the enumerated move list.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("offerGoods")
|
||||||
|
data class OfferGoods(val to: Int, val give: List<Good>, val get: List<Good>) : CatanMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give up exactly the cards named, to a [DiscardChore]. Structural, not
|
||||||
|
* enumerated; the legal list carries one example so a seat is never without a move.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("discardGoods")
|
||||||
|
data class DiscardGoods(val cards: List<Good>) : CatanMove
|
||||||
|
|
||||||
|
/** Hand over the cards named to a [GiveChore]. Structural, like [DiscardGoods]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("giveGoods")
|
||||||
|
data class GiveGoods(val cards: List<Good>) : CatanMove
|
||||||
|
|
||||||
|
/** Let the city on [vertex] fall to the barbarians. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pillageCity")
|
||||||
|
data class PillageCity(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Draw from the deck of this track's colour. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pickDeck")
|
||||||
|
data class PickDeck(val track: Track) : CatanMove
|
||||||
|
|
||||||
|
/** Give up one progress card from a hand that is over the limit. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("dropProgress")
|
||||||
|
data class DropProgress(val card: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Put the metropolis on the city at [vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("placeMetropolis")
|
||||||
|
data class PlaceMetropolis(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Take one [resource] from the bank for an aqueduct. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("aqueduct")
|
||||||
|
data class TakeAqueduct(val resource: Resource) : CatanMove
|
||||||
|
|
||||||
|
/** Move the knight that was driven off to [to]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("relocateKnight")
|
||||||
|
data class RelocateKnight(val to: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Take the knight on [vertex] off the board, for a deserter. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("removeKnight")
|
||||||
|
data class RemoveKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Place the free knight from a deserter on [vertex]. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("placeKnight")
|
||||||
|
data class PlaceKnight(val vertex: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Take the spied progress card with this id. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("spyTake")
|
||||||
|
data class SpyTake(val card: Int) : CatanMove
|
||||||
|
|
||||||
|
/** Stop promoting knights under a Smith. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("endSmith")
|
||||||
|
data object EndSmith : CatanMove
|
||||||
@@ -113,12 +113,30 @@ enum class CatanPhase {
|
|||||||
TRADE_PENDING,
|
TRADE_PENDING,
|
||||||
|
|
||||||
GAME_OVER,
|
GAME_OVER,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cities & Knights only: the first seat in [CkState.chores] has something
|
||||||
|
* to decide (a discard, a city to give up, a card to draw…) and the game
|
||||||
|
* waits on it. Play resumes where [CkState.back] says.
|
||||||
|
*/
|
||||||
|
CHORE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
|
||||||
|
@Serializable
|
||||||
|
enum class CatanRuleSet {
|
||||||
|
/** The base game: development cards, Largest Army, a robber from the start. */
|
||||||
|
BASE,
|
||||||
|
|
||||||
|
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
|
||||||
|
CITIES_AND_KNIGHTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CatanOptions(
|
data class CatanOptions(
|
||||||
val playerCount: Int = 3,
|
val playerCount: Int = 3,
|
||||||
val pointsToWin: Int = 10,
|
val pointsToWin: Int = 10,
|
||||||
|
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
require(playerCount in 3..4) { "The island seats three or four players" }
|
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||||
@@ -190,9 +208,14 @@ data class CatanState(
|
|||||||
val rngStep: Int,
|
val rngStep: Int,
|
||||||
val winner: Int?,
|
val winner: Int?,
|
||||||
val log: List<String>,
|
val log: List<String>,
|
||||||
|
/** Everything Cities & Knights adds; null in the base game. */
|
||||||
|
val ck: CkState? = null,
|
||||||
) {
|
) {
|
||||||
val playerCount: Int get() = options.playerCount
|
val playerCount: Int get() = options.playerCount
|
||||||
|
|
||||||
|
/** Whether this table plays Cities & Knights. */
|
||||||
|
val isCk: Boolean get() = ck != null
|
||||||
|
|
||||||
/** Total resource cards held by [seat]. */
|
/** Total resource cards held by [seat]. */
|
||||||
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
|
fun resourceCount(seat: Int): Int = resources[seat].values.sum()
|
||||||
|
|
||||||
@@ -227,7 +250,11 @@ data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
|
|||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("roll")
|
@SerialName("roll")
|
||||||
data class RollDice(val forced: List<Int>? = null) : CatanMove
|
data class RollDice(
|
||||||
|
val forced: List<Int>? = null,
|
||||||
|
/** Test-only, like [forced]: the event die of a Cities & Knights roll. */
|
||||||
|
val event: EventFace? = null,
|
||||||
|
) : CatanMove
|
||||||
|
|
||||||
/** Discard exactly these cards. */
|
/** Discard exactly these cards. */
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -57,9 +57,16 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
val board = CatanBoard.generate(config.seed)
|
val board = CatanBoard.generate(config.seed)
|
||||||
// A different random stream than the island, so the same seed still
|
// A different random stream than the island, so the same seed still
|
||||||
// produces both a fixed island and a fixed deck.
|
// produces both a fixed island and a fixed deck.
|
||||||
val devDeck = DEV_DECK
|
val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
|
||||||
|
// Cities & Knights has no development cards: knights and progress
|
||||||
|
// cards take their place.
|
||||||
|
val devDeck = if (ck) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
DEV_DECK
|
||||||
.mapIndexed { id, type -> DevCard(id, type) }
|
.mapIndexed { id, type -> DevCard(id, type) }
|
||||||
.shuffled(Random(config.seed + 1))
|
.shuffled(Random(config.seed + 1))
|
||||||
|
}
|
||||||
|
|
||||||
return CatanState(
|
return CatanState(
|
||||||
options = options,
|
options = options,
|
||||||
@@ -91,8 +98,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
rngStep = 0,
|
rngStep = 0,
|
||||||
winner = null,
|
winner = null,
|
||||||
log = listOf(
|
log = listOf(
|
||||||
"Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.",
|
"Catan${if (ck) " — Cities & Knights" else ""} — ${options.playerCount} players, " +
|
||||||
|
"first to ${options.pointsToWin} victory points.",
|
||||||
),
|
),
|
||||||
|
ck = if (ck) CatanCk.initial(options, config.seed) else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +110,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
CatanPhase.SETUP_FIRST -> state.setupIndex
|
CatanPhase.SETUP_FIRST -> state.setupIndex
|
||||||
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
|
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
|
||||||
CatanPhase.DISCARDING -> state.discardSeat
|
CatanPhase.DISCARDING -> state.discardSeat
|
||||||
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to
|
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to ?: state.ck?.goodsOffer?.to
|
||||||
|
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
|
||||||
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
||||||
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
||||||
-> state.turn
|
-> state.turn
|
||||||
@@ -130,12 +140,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
|
|
||||||
is OfferTrade -> offerLegal(state, seat, move)
|
is OfferTrade -> offerLegal(state, seat, move)
|
||||||
|
|
||||||
else -> move in legalMoves(state, seat)
|
else -> if (state.isCk) {
|
||||||
|
CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat))
|
||||||
|
} else {
|
||||||
|
move in legalMoves(state, seat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
|
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
|
||||||
if (currentSeat(state) != seat) return emptyList()
|
if (currentSeat(state) != seat) return emptyList()
|
||||||
|
if (state.isCk) return CatanCk.legalMoves(state, seat)
|
||||||
return when (state.phase) {
|
return when (state.phase) {
|
||||||
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
|
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
|
||||||
|
|
||||||
@@ -152,11 +167,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
|
|
||||||
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
||||||
|
|
||||||
CatanPhase.GAME_OVER -> emptyList()
|
CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupMoves(state: CatanState): List<SetupPlace> {
|
internal fun setupMoves(state: CatanState): List<SetupPlace> {
|
||||||
val moves = mutableListOf<SetupPlace>()
|
val moves = mutableListOf<SetupPlace>()
|
||||||
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
for (v in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
if (!vertexOpenForSettlement(state, v)) continue
|
if (!vertexOpenForSettlement(state, v)) continue
|
||||||
@@ -276,7 +291,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
* Road placements that connect to [seat]'s network, cost aside. A player
|
* Road placements that connect to [seat]'s network, cost aside. A player
|
||||||
* with all fifteen roads laid has none.
|
* with all fifteen roads laid has none.
|
||||||
*/
|
*/
|
||||||
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
|
internal fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
|
||||||
if (state.roads.count { it == seat } >= MAX_ROADS) {
|
if (state.roads.count { it == seat } >= MAX_ROADS) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
@@ -291,7 +306,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
* connection requirement does not apply — that is why setup uses its
|
* connection requirement does not apply — that is why setup uses its
|
||||||
* own move.)
|
* own move.)
|
||||||
*/
|
*/
|
||||||
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
|
internal fun settlementVertices(state: CatanState, seat: Int): List<Int> =
|
||||||
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
|
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
@@ -301,7 +316,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
|
internal fun cityVertices(state: CatanState, seat: Int): List<Int> =
|
||||||
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
|
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
@@ -310,11 +325,14 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasKnight(state: CatanState, vertex: Int): Boolean =
|
||||||
|
state.ck?.knights?.any { it.vertex == vertex } == true
|
||||||
|
|
||||||
/** How many settlements or cities [seat] currently has standing. */
|
/** How many settlements or cities [seat] currently has standing. */
|
||||||
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
|
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
|
||||||
state.buildings.count { it?.owner == seat && it.type == type }
|
state.buildings.count { it?.owner == seat && it.type == type }
|
||||||
|
|
||||||
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
|
internal fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
|
||||||
val offer = state.pendingTrade ?: return emptyList()
|
val offer = state.pendingTrade ?: return emptyList()
|
||||||
val responses = mutableListOf<CatanMove>()
|
val responses = mutableListOf<CatanMove>()
|
||||||
if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) {
|
if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) {
|
||||||
@@ -325,8 +343,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** An empty vertex with no building on it or one edge away. */
|
/** An empty vertex with no building on it or one edge away. */
|
||||||
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||||
state.buildings[v] == null &&
|
state.buildings[v] == null && !hasKnight(state, v) &&
|
||||||
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -345,6 +363,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
/** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
|
/** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
|
||||||
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
|
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
|
||||||
val building = state.buildings[vertex]
|
val building = state.buildings[vertex]
|
||||||
|
// A knight is a wall to everyone but its owner, like a building.
|
||||||
|
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
|
||||||
|
if (knight != null && knight.owner != seat) return false
|
||||||
// Your own building is always a way in; anyone else's is a wall.
|
// Your own building is always a way in; anyone else's is a wall.
|
||||||
if (building != null) return building.owner == seat
|
if (building != null) return building.owner == seat
|
||||||
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
|
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
|
||||||
@@ -387,18 +408,22 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
|
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
|
||||||
EndTurn -> applyEndTurn(state, seat)
|
EndTurn -> applyEndTurn(state, seat)
|
||||||
EndFreeRoads -> applyEndFreeRoads(state, seat)
|
EndFreeRoads -> applyEndFreeRoads(state, seat)
|
||||||
}
|
else -> CatanCk.apply(state, seat, move)
|
||||||
|
}.let { if (it.isCk) CatanCk.settle(it) else it }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
|
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
|
||||||
val n = state.playerCount
|
val n = state.playerCount
|
||||||
val second = state.phase == CatanPhase.SETUP_SECOND
|
val second = state.phase == CatanPhase.SETUP_SECOND
|
||||||
|
// In Cities & Knights the second placement is a city.
|
||||||
|
val first = if (state.isCk && second) BuildingType.CITY else BuildingType.SETTLEMENT
|
||||||
var s = state.copy(
|
var s = state.copy(
|
||||||
buildings = state.buildings.toMutableList().also {
|
buildings = state.buildings.toMutableList().also {
|
||||||
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat)
|
it[move.vertex] = CatanBuilding(first, seat)
|
||||||
},
|
},
|
||||||
roads = state.roads.toMutableList().also { it[move.edge] = seat },
|
roads = state.roads.toMutableList().also { it[move.edge] = seat },
|
||||||
log = state.log + "Seat $seat settles at ${move.vertex} and lays a road on ${move.edge}.",
|
log = state.log + ("Seat $seat ${if (first == BuildingType.CITY) "raises a city" else "settles"} " +
|
||||||
|
"at ${move.vertex} and lays a road on ${move.edge}."),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (second) {
|
if (second) {
|
||||||
@@ -432,6 +457,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
|
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
|
||||||
|
if (state.isCk) return CatanCk.applyRoll(state, seat, move)
|
||||||
val dice = move.forced ?: derivedDice(state)
|
val dice = move.forced ?: derivedDice(state)
|
||||||
val total = dice[0] + dice[1]
|
val total = dice[0] + dice[1]
|
||||||
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
|
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
|
||||||
@@ -506,6 +532,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
|
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
|
||||||
|
|
||||||
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
|
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
|
||||||
|
if (state.isCk) return CatanCk.applyRobber(state, seat, move)
|
||||||
val hex = CatanBoard.hexIndex[move.hex]
|
val hex = CatanBoard.hexIndex[move.hex]
|
||||||
?: throw IllegalArgumentException("No hex at ${move.hex}")
|
?: throw IllegalArgumentException("No hex at ${move.hex}")
|
||||||
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
|
var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
|
||||||
@@ -522,8 +549,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
return s.copy(
|
return s.copy(
|
||||||
phase = CatanPhase.PLAYING,
|
phase = CatanPhase.PLAYING,
|
||||||
log = s.log + "Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
|
log = s.log + ("Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
|
||||||
" (${move.hex.q},${move.hex.r})$stolen.",
|
" (${move.hex.q},${move.hex.r})$stolen."),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +562,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
if (free) {
|
if (free) {
|
||||||
val left = s.pendingFreeRoads - 1
|
val left = s.pendingFreeRoads - 1
|
||||||
s = s.copy(pendingFreeRoads = left)
|
s = s.copy(pendingFreeRoads = left)
|
||||||
if (left == 0) s = s.copy(phase = CatanPhase.PLAYING)
|
if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
|
||||||
}
|
}
|
||||||
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
|
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
|
||||||
}
|
}
|
||||||
@@ -560,7 +587,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
s = payCost(s, seat, CITY_COST)
|
s = payCost(s, seat, CITY_COST)
|
||||||
return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}."))
|
s = s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.")
|
||||||
|
// A city can be what a waiting metropolis needed.
|
||||||
|
if (s.isCk) s = CatanCk.afterCityBuilt(s, seat)
|
||||||
|
return checkWin(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
|
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
|
||||||
@@ -637,11 +667,12 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
state.copy(
|
state.copy(
|
||||||
phase = CatanPhase.TRADE_PENDING,
|
phase = CatanPhase.TRADE_PENDING,
|
||||||
pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get),
|
pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get),
|
||||||
log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " +
|
log = state.log + ("Seat $seat offers ${describe(countOf(move.give))} for " +
|
||||||
"${describe(countOf(move.get))} to seat ${move.to}.",
|
"${describe(countOf(move.get))} to seat ${move.to}."),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
|
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
|
||||||
|
if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true)
|
||||||
val offer = state.pendingTrade!!
|
val offer = state.pendingTrade!!
|
||||||
var resources = withCards(state.resources, offer.to, offer.get, -1)
|
var resources = withCards(state.resources, offer.to, offer.get, -1)
|
||||||
resources = withCards(resources, offer.by, offer.give, -1)
|
resources = withCards(resources, offer.by, offer.give, -1)
|
||||||
@@ -656,7 +687,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
|
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
|
||||||
state.copy(
|
if (state.ck?.goodsOffer != null) CatanCk.answerOffer(state, seat, accept = false) else state.copy(
|
||||||
phase = CatanPhase.PLAYING,
|
phase = CatanPhase.PLAYING,
|
||||||
pendingTrade = null,
|
pendingTrade = null,
|
||||||
log = state.log + "Seat $seat declines the offer.",
|
log = state.log + "Seat $seat declines the offer.",
|
||||||
@@ -668,8 +699,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1))
|
val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1))
|
||||||
val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour"
|
val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour"
|
||||||
return s.copy(
|
return s.copy(
|
||||||
log = s.log + "Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
|
log = s.log + ("Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
|
||||||
"${move.get.name.lowercase()} via $via.",
|
"${move.get.name.lowercase()} via $via."),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,7 +710,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
if (settled.phase == CatanPhase.GAME_OVER) return settled
|
if (settled.phase == CatanPhase.GAME_OVER) return settled
|
||||||
|
|
||||||
val next = (state.turn + 1) % state.playerCount
|
val next = (state.turn + 1) % state.playerCount
|
||||||
val s = state.copy(
|
val s = (if (state.isCk) CatanCk.endTurn(state) else state).copy(
|
||||||
turn = next,
|
turn = next,
|
||||||
turnNumber = state.turnNumber + 1,
|
turnNumber = state.turnNumber + 1,
|
||||||
phase = CatanPhase.AWAIT_ROLL,
|
phase = CatanPhase.AWAIT_ROLL,
|
||||||
@@ -692,9 +723,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
return checkWin(s)
|
return checkWin(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where play goes after free roads: a card played before the roll returns to the roll. */
|
||||||
|
private fun phaseAfterRoads(state: CatanState): CatanPhase =
|
||||||
|
if (state.ck?.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING
|
||||||
|
|
||||||
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
|
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
|
||||||
state.copy(
|
state.copy(
|
||||||
phase = CatanPhase.PLAYING,
|
phase = phaseAfterRoads(state),
|
||||||
pendingFreeRoads = 0,
|
pendingFreeRoads = 0,
|
||||||
log = state.log + "Seat $seat forfeits the remaining free road.",
|
log = state.log + "Seat $seat forfeits the remaining free road.",
|
||||||
)
|
)
|
||||||
@@ -710,6 +745,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
* view or the host's copy.
|
* view or the host's copy.
|
||||||
*/
|
*/
|
||||||
fun publicVictoryPoints(state: CatanState): List<Int> {
|
fun publicVictoryPoints(state: CatanState): List<Int> {
|
||||||
|
if (state.isCk) return CatanCk.publicVictoryPoints(state)
|
||||||
val points = IntArray(state.playerCount)
|
val points = IntArray(state.playerCount)
|
||||||
state.buildings.forEach { building ->
|
state.buildings.forEach { building ->
|
||||||
if (building != null) {
|
if (building != null) {
|
||||||
@@ -725,7 +761,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
publicVictoryPoints(state)[seat] +
|
publicVictoryPoints(state)[seat] +
|
||||||
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
||||||
|
|
||||||
private fun checkWin(state: CatanState): CatanState {
|
internal fun checkWin(state: CatanState): CatanState {
|
||||||
if (state.phase == CatanPhase.GAME_OVER) return state
|
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||||
val seat = state.turn
|
val seat = state.turn
|
||||||
val points = victoryPoints(state, seat)
|
val points = victoryPoints(state, seat)
|
||||||
@@ -746,7 +782,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
* road falls below five segments the award reopens to the longest road
|
* road falls below five segments the award reopens to the longest road
|
||||||
* of five or more, lowest seat first on a tie.
|
* of five or more, lowest seat first on a tie.
|
||||||
*/
|
*/
|
||||||
private fun refreshLongestRoad(state: CatanState): CatanState {
|
internal fun refreshLongestRoad(state: CatanState): CatanState {
|
||||||
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
|
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
|
||||||
val holder = state.longestRoadOwner
|
val holder = state.longestRoadOwner
|
||||||
val best = lengths
|
val best = lengths
|
||||||
@@ -782,6 +818,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
val building = state.buildings[v]
|
val building = state.buildings[v]
|
||||||
if (building != null && building.owner != seat) blocked[v] = true
|
if (building != null && building.owner != seat) blocked[v] = true
|
||||||
}
|
}
|
||||||
|
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
|
||||||
for (start in 0 until CatanBoard.VERTEX_COUNT) {
|
for (start in 0 until CatanBoard.VERTEX_COUNT) {
|
||||||
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
|
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
|
||||||
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
|
val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
|
||||||
@@ -843,7 +880,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
fun bankSupply(state: CatanState, resource: Resource): Int =
|
fun bankSupply(state: CatanState, resource: Resource): Int =
|
||||||
BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 }
|
BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 }
|
||||||
|
|
||||||
private fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
|
internal fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
|
||||||
cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need }
|
cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need }
|
||||||
|
|
||||||
private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState =
|
private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState =
|
||||||
@@ -894,6 +931,16 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 3 if [seat] has a settlement on a generic harbour, else the bank's 4. */
|
||||||
|
fun genericPortRatio(state: CatanState, seat: Int): Int {
|
||||||
|
for (port in state.ports) {
|
||||||
|
if (port.resource != null) continue
|
||||||
|
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
|
||||||
|
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
|
||||||
|
}
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Results, redaction and wire format
|
// Results, redaction and wire format
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -908,12 +955,15 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
else -> "Seat $winner wins"
|
else -> "Seat $winner wins"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun viewFor(state: CatanState, seat: Int): CatanState = state.copy(
|
override fun viewFor(state: CatanState, seat: Int): CatanState = CatanCk.viewFor(
|
||||||
|
state.copy(
|
||||||
// Hidden information stays with the holder and the host.
|
// Hidden information stays with the holder and the host.
|
||||||
devDeck = emptyList(),
|
devDeck = emptyList(),
|
||||||
devHands = state.devHands.mapIndexed { index, hand ->
|
devHands = state.devHands.mapIndexed { index, hand ->
|
||||||
if (index == seat) hand else emptyList()
|
if (index == seat) hand else emptyList()
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
seat,
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun encodeState(state: CatanState): String = json.encodeToString(state)
|
override fun encodeState(state: CatanState): String = json.encodeToString(state)
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ object CatanText {
|
|||||||
"$turn is placing free roads…"
|
"$turn is placing free roads…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CatanPhase.CHORE -> "Waiting for ${seats.label(onClock ?: state.turn)}…"
|
||||||
|
|
||||||
CatanPhase.TRADE_PENDING -> {
|
CatanPhase.TRADE_PENDING -> {
|
||||||
val offer = state.pendingTrade
|
val offer = state.pendingTrade
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -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