Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421cbd4c70 | ||
|
|
cd220396f9 | ||
|
|
af279d3592 | ||
|
|
3315790688 | ||
|
|
20b6cb4b5f | ||
|
|
eef024848e | ||
|
|
c3f2efa313 | ||
|
|
385262515e |
@@ -277,6 +277,11 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
pickBest(settlements, profile) { spotValue(state, it.vertex) }?.let { return it }
|
||||
}
|
||||
|
||||
// A raid on a fortress, or the next stage of a wonder, beats a card.
|
||||
legal.filterIsInstance<RaidFortress>().firstOrNull()?.let { return it }
|
||||
legal.filterIsInstance<DigCanal>().firstOrNull()?.let { return it }
|
||||
legal.filterIsInstance<BuildWonder>().firstOrNull()?.let { return it }
|
||||
|
||||
// Seafarers: a ship toward an island nobody has landed on is worth more than a card.
|
||||
legal.filterIsInstance<BuildShip>()
|
||||
.filter { shipValue(state, seat, it.edge) > 0.5 }
|
||||
@@ -592,6 +597,22 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What a seat would think of building on [vertex]: production and ports. */
|
||||
/** What a spot beside a cloth village or an unclaimed tribe chip is worth beyond its cards. */
|
||||
private fun scenarioLure(state: CatanState, vertex: Int): Double {
|
||||
val sea = state.sea ?: return 0.0
|
||||
var lure = 0.0
|
||||
for (h in state.topo.VERTEX_HEXES[vertex]) {
|
||||
// A cloth token is a quarter-point, rolled as often as its number comes up.
|
||||
if (state.terrains[h] == Terrain.VILLAGE && sea.villageCloth.getOrElse(h) { 0 } > 0) {
|
||||
lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH
|
||||
}
|
||||
// An unfinished canal tile is a place to dig: points now, grain land later.
|
||||
if (sea.canalNumbers.getOrElse(h) { 0 } != 0 && state.terrains[h] == Terrain.DESERT) lure += CANAL_LURE
|
||||
if (sea.chips.getOrNull(h).let { it != null && it != TribeChip.NONE }) lure += CHIP_LURE
|
||||
}
|
||||
return lure
|
||||
}
|
||||
|
||||
internal fun spotValue(state: CatanState, vertex: Int): Double {
|
||||
if (state.buildings[vertex] != null) return -1.0
|
||||
var value = 0.0
|
||||
@@ -602,6 +623,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
kinds += resource
|
||||
}
|
||||
value += 0.12 * kinds.size // variety keeps a build plan alive
|
||||
value += scenarioLure(state, vertex)
|
||||
for (e in state.topo.VERTEX_EDGES[vertex]) {
|
||||
val port = state.ports.firstOrNull { it.edge == e } ?: continue
|
||||
value += if (port.resource == null) 0.1 else 0.2
|
||||
@@ -646,6 +668,11 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
if (!seen.add(v)) continue
|
||||
// Fog is worth a look: a card on arrival, and land to settle that nobody has seen.
|
||||
if (topo.VERTEX_HEXES[v].any { state.terrains[it] == Terrain.FOG }) best = maxOf(best, FOG_LURE * Math.pow(0.85, depth.toDouble()))
|
||||
// A fortress is worth a raid once a ship lies beside it.
|
||||
if (sea.fortress.isNotEmpty() && topo.VERTEX_EDGES[v].any { e ->
|
||||
topo.edgeSea[e] && topo.EDGE_HEXES[e].any { sea.fortress.getOrElse(it) { 0 } > 0 }
|
||||
}
|
||||
) best = maxOf(best, FORT_LURE * Math.pow(0.85, depth.toDouble()))
|
||||
if (CatanRules.vertexOpenForSettlement(state, v)) {
|
||||
val island = topo.vertexIsland[v]
|
||||
val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER
|
||||
@@ -678,5 +705,9 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
|
||||
/** What a ship end beside undiscovered land is worth, on the scale of [spotValue]. */
|
||||
const val FOG_LURE = 0.6
|
||||
const val FORT_LURE = 1.2
|
||||
const val CLOTH_WORTH = 2.5
|
||||
const val CHIP_LURE = 0.5
|
||||
const val CANAL_LURE = 0.6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ object CatanCk {
|
||||
if (state.isSea) {
|
||||
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
|
||||
moves += SeaRules.moveShipMoves(state, seat)
|
||||
moves += SeaRules.scenarioMoves(state, seat)
|
||||
}
|
||||
if (affordable(state, seat, CatanRules.SETTLEMENT_COST)) {
|
||||
moves += CatanRules.settlementVertices(state, seat).map { BuildSettlement(it) }
|
||||
@@ -1568,6 +1569,7 @@ object CatanCk {
|
||||
}
|
||||
val said = if (notes.isEmpty()) "nothing for anyone" else notes.joinToString(", ")
|
||||
s = s.copy(log = s.log + "The dice pay: $said.")
|
||||
if (state.isSea) s = SeaRules.payCloth(s, number)
|
||||
|
||||
// The aqueduct: a seat that was paid nothing may take a resource of its choice.
|
||||
val order = (0 until s.playerCount).map { (s.turn + it) % s.playerCount }
|
||||
|
||||
@@ -214,6 +214,10 @@ internal class CatanCkAi(
|
||||
settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it }
|
||||
}
|
||||
|
||||
legal.filterIsInstance<RaidFortress>().firstOrNull()?.let { return it }
|
||||
legal.filterIsInstance<DigCanal>().firstOrNull()?.let { return it }
|
||||
legal.filterIsInstance<BuildWonder>().firstOrNull()?.let { return it }
|
||||
|
||||
// Seafarers: a ship toward land nobody has settled is worth more than a card.
|
||||
legal.filterIsInstance<BuildShip>()
|
||||
.filter { ai.shipValue(state, seat, it.edge) > 0.5 }
|
||||
|
||||
@@ -60,6 +60,15 @@ enum class Terrain(val produces: Resource?) {
|
||||
* (`SeaState.fogDeck`). Until then nothing is built on it, nothing produces, and the robber keeps off.
|
||||
*/
|
||||
FOG(null),
|
||||
|
||||
/** Seafarers, Cloth for Catan: a village. It pays cloth, not cards, to the settlements around it when its number rolls. */
|
||||
VILLAGE(null),
|
||||
|
||||
/**
|
||||
* Seafarers, Pirate Islands: a pirate fortress. It stands until ships raid it (`SeaState.fortress`), and
|
||||
* nobody settles its island meanwhile. Once taken it becomes desert.
|
||||
*/
|
||||
FORTRESS(null),
|
||||
}
|
||||
|
||||
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||
@@ -482,8 +491,65 @@ data class SeaState(
|
||||
val fogDeck: List<FogTile> = emptyList(),
|
||||
/** Where the game goes when the gold queue empties: [CatanPhase.PLAYING] unless a find interrupted something else. */
|
||||
val afterGold: CatanPhase? = null,
|
||||
/** Cloth tokens each seat holds (Cloth for Catan); empty when the scenario has none. Two make a victory point. */
|
||||
val cloth: List<Int> = emptyList(),
|
||||
/** Cloth still on each hex's village, by hex (Cloth for Catan); empty when the scenario has none. */
|
||||
val villageCloth: List<Int> = emptyList(),
|
||||
/** The tribe chip waiting on each hex (Forgotten Tribe), by hex; empty when the scenario has none. */
|
||||
val chips: List<TribeChip> = emptyList(),
|
||||
/** Seats that took a harbour chip and so trade 3:1 anywhere. */
|
||||
val harbourSeats: List<Int> = emptyList(),
|
||||
/** Victory points each seat holds from tribe chips; empty until one is taken. */
|
||||
val chipPoints: List<Int> = emptyList(),
|
||||
/** Strength still left in each hex's pirate fortress, by hex (Pirate Islands); empty when the map has none. */
|
||||
val fortress: List<Int> = emptyList(),
|
||||
/** Victory points each seat holds for taking fortresses; empty until one falls. */
|
||||
val raidPoints: List<Int> = emptyList(),
|
||||
/** Wonder stages each seat has built (Wonders of Catan), by seat; empty when the scenario has none. */
|
||||
val wonders: List<Int> = emptyList(),
|
||||
/** The chest under each hex of fog, by hex (Treasure Islands, Oases); found when the hex is. Hidden from other views. */
|
||||
val treasures: List<Treasure> = emptyList(),
|
||||
/** Units dug so far into each canal tile, by hex (Great Canal); empty when the map has none. */
|
||||
val canalUnits: List<Int> = emptyList(),
|
||||
/** The number token waiting under each canal tile, by hex; it shows when the tile is finished. */
|
||||
val canalNumbers: List<Int> = emptyList(),
|
||||
/** Canal units each seat has dug; every two are a victory point. */
|
||||
val canalDug: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
/** What a treasure chest holds (Treasure Islands). */
|
||||
@Serializable
|
||||
enum class Treasure {
|
||||
NONE,
|
||||
|
||||
/** One brick, one wool and one grain. */
|
||||
BRICK_WOOL_GRAIN,
|
||||
|
||||
/** One resource of the finder's choice. */
|
||||
CHOICE,
|
||||
|
||||
/** A development card (a resource of choice in Cities & Knights, which has none). */
|
||||
DEV_CARD,
|
||||
|
||||
/** Two roads' worth of cards: two lumber and two brick. */
|
||||
ROADS,
|
||||
}
|
||||
|
||||
/** What the first settler beside a tribe hex takes (Forgotten Tribe). */
|
||||
@Serializable
|
||||
enum class TribeChip {
|
||||
NONE,
|
||||
|
||||
/** One victory point. */
|
||||
POINT,
|
||||
|
||||
/** A 3:1 harbour, good wherever the seat trades. */
|
||||
HARBOUR,
|
||||
|
||||
/** Two resources of the seat's choice. */
|
||||
GOLD,
|
||||
}
|
||||
|
||||
/** One tile of the fog: what the hex turns out to be and the number token that comes with it. */
|
||||
@Serializable
|
||||
data class FogTile(val terrain: Terrain, val number: Int)
|
||||
@@ -513,3 +579,18 @@ data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove
|
||||
@Serializable
|
||||
@SerialName("gold")
|
||||
data class PickGold(val cards: List<Resource>) : CatanMove
|
||||
|
||||
/** Raid the pirate fortress on [hex] from a ship beside it: 1 ore, 1 wool and 1 grain take one point of its strength. */
|
||||
@Serializable
|
||||
@SerialName("raid")
|
||||
data class RaidFortress(val hex: HexCoord) : CatanMove
|
||||
|
||||
/** Build the next stage of your wonder, which needs a city of yours; the stage's cost is in `SeaRules.WONDER_STAGES`. */
|
||||
@Serializable
|
||||
@SerialName("wonder")
|
||||
data object BuildWonder : CatanMove
|
||||
|
||||
/** Dig one unit of the canal tile on [hex]: 1 brick and 1 ore, from a seat with a settlement or city beside it. */
|
||||
@Serializable
|
||||
@SerialName("dig")
|
||||
data class DigCanal(val hex: HexCoord) : CatanMove
|
||||
|
||||
@@ -121,6 +121,15 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
home = it.home,
|
||||
starts = it.starts,
|
||||
fogDeck = it.fogDeck,
|
||||
cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 },
|
||||
villageCloth = it.villageCloth,
|
||||
chips = it.chips,
|
||||
fortress = it.fortress,
|
||||
treasures = it.treasures,
|
||||
canalUnits = if (it.canalNumbers.isEmpty()) emptyList() else List(topo.HEX_COUNT) { 0 },
|
||||
canalNumbers = it.canalNumbers,
|
||||
canalDug = if (it.canalNumbers.isEmpty()) emptyList() else List(options.playerCount) { 0 },
|
||||
wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -248,7 +257,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
private fun robberMoves(state: CatanState, seat: Int): List<MoveRobber> {
|
||||
val moves = mutableListOf<MoveRobber>()
|
||||
for (h in 0 until state.topo.HEX_COUNT) {
|
||||
if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG) continue
|
||||
if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG || state.terrains[h] == Terrain.FORTRESS) continue
|
||||
val victims = state.topo.HEX_CORNERS[h]
|
||||
.mapNotNull { state.buildings[it] }
|
||||
.map { it.owner }
|
||||
@@ -280,6 +289,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
if (state.isSea) {
|
||||
if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat)
|
||||
moves += SeaRules.moveShipMoves(state, seat)
|
||||
moves += SeaRules.scenarioMoves(state, seat)
|
||||
}
|
||||
|
||||
if (!state.devCardPlayedThisTurn) {
|
||||
@@ -414,6 +424,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
/** An empty vertex with no building on it or one edge away. */
|
||||
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||
state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] &&
|
||||
!SeaRules.fortressHolds(state, v) &&
|
||||
state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||
|
||||
/**
|
||||
@@ -467,6 +478,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
is MoveShip -> applyMoveShip(state, seat, move)
|
||||
is MovePirate -> applyMovePirate(state, seat, move)
|
||||
is PickGold -> applyPickGold(state, seat, move)
|
||||
is RaidFortress -> SeaRules.applyRaid(state, seat, move)
|
||||
is DigCanal -> SeaRules.applyDig(state, seat, move)
|
||||
BuildWonder -> SeaRules.applyWonder(state, seat)
|
||||
is BuildRoad -> applyBuildRoad(state, seat, move)
|
||||
is BuildSettlement -> applyBuildSettlement(state, seat, move)
|
||||
is BuildCity -> applyBuildCity(state, seat, move)
|
||||
@@ -596,6 +610,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
s = s.copy(
|
||||
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
|
||||
)
|
||||
if (state.isSea) s = SeaRules.payCloth(s, number)
|
||||
if (goldOwed.isEmpty()) return s
|
||||
return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed)))
|
||||
}
|
||||
@@ -743,7 +758,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
},
|
||||
)
|
||||
s = payCost(s, seat, SETTLEMENT_COST)
|
||||
s = SeaRules.claimIsland(s, seat, move.vertex)
|
||||
s = SeaRules.settled(s, seat, move.vertex)
|
||||
// A settlement can break another player's road, so the award is
|
||||
// recomputed for everyone.
|
||||
s = refreshLongestRoad(s)
|
||||
@@ -1184,6 +1199,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
|
||||
if (mine) best = port.ratio
|
||||
}
|
||||
if (best > 3 && state.sea?.harbourSeats?.contains(seat) == true) best = 3
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -1194,7 +1210,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
|
||||
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
|
||||
}
|
||||
return 4
|
||||
return if (state.sea?.harbourSeats?.contains(seat) == true) 3 else 4
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1215,7 +1231,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
state.copy(
|
||||
// Hidden information stays with the holder and the host.
|
||||
devDeck = emptyList(),
|
||||
sea = state.sea?.copy(fogDeck = emptyList()),
|
||||
sea = state.sea?.copy(fogDeck = emptyList(), treasures = emptyList()),
|
||||
devHands = state.devHands.mapIndexed { index, hand ->
|
||||
if (index == seat) hand else emptyList()
|
||||
},
|
||||
|
||||
@@ -24,6 +24,41 @@ enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart
|
||||
"A home island, an islet, and a bank of fog. Land in the fog is face down until a road or ship reaches it; " +
|
||||
"the finder takes a card of what lies there.",
|
||||
),
|
||||
TREASURE_ISLANDS(
|
||||
"The Treasure Islands",
|
||||
"Chests lie hidden on the fog-covered isles beside a home island. Whoever uncovers one opens it: " +
|
||||
"cards, a resource of choice, a development card or a road's worth of materials.",
|
||||
),
|
||||
OASES(
|
||||
"The Oases",
|
||||
"An enormous desert, with fertile oases hidden inside it. Roads that reach an oasis uncover it " +
|
||||
"and whatever treasure is buried there.",
|
||||
),
|
||||
GREAT_CANAL(
|
||||
"The Great Canal",
|
||||
"A desert strip splits the island. Dig the canal tile by tile (a brick and an ore a unit) and each finished " +
|
||||
"tile becomes grain land; every two units dug are a victory point.",
|
||||
),
|
||||
PIRATE_ISLANDS(
|
||||
"Pirate Islands",
|
||||
"Two isles are held by pirate fortresses. Sail ships beside one and raid it with ore, wool and grain; " +
|
||||
"nobody may settle the isle until the fortress falls, and the one who takes it earns two points.",
|
||||
),
|
||||
WONDERS_OF_CATAN(
|
||||
"Wonders of Catan",
|
||||
"Two big islands and room to grow. Raise a city, then build a wonder in four stages; each stage is a " +
|
||||
"victory point and the first to finish all four wins.",
|
||||
),
|
||||
CLOTH_FOR_CATAN(
|
||||
"Cloth for Catan",
|
||||
"Four villages weave cloth. Settle beside one and it pays you a cloth token whenever its number rolls; " +
|
||||
"every two tokens are a victory point.",
|
||||
),
|
||||
FORGOTTEN_TRIBE(
|
||||
"The Forgotten Tribe",
|
||||
"Four small isles hold the chips of a lost tribe. The first to settle beside one takes it: " +
|
||||
"a victory point, a harbour, or gold.",
|
||||
),
|
||||
}
|
||||
|
||||
/** Where the starting settlements may go. */
|
||||
@@ -92,11 +127,98 @@ object SeaBoards {
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `V` is a village; `t` is a resource hex that carries a tribe chip. */
|
||||
private val CLOTH_FOR_CATAN_MAP = listOf(
|
||||
"............",
|
||||
"..V###..V##.",
|
||||
"..#####..##.",
|
||||
"..####V..#..",
|
||||
"...###......",
|
||||
".......##V..",
|
||||
"............",
|
||||
"...##.......",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val FORGOTTEN_TRIBE_MAP = listOf(
|
||||
"............",
|
||||
"..###..t#...",
|
||||
"..#####..#t.",
|
||||
"..#####..#..",
|
||||
"..####......",
|
||||
"........t##.",
|
||||
".#t.........",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val TREASURE_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
"..###..FF...",
|
||||
"..####..FF..",
|
||||
"..#####.....",
|
||||
"..###...F...",
|
||||
".......FF...",
|
||||
"..F.........",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val OASES_MAP = listOf(
|
||||
"............",
|
||||
"..###DD###..",
|
||||
"..#DDFDD#...",
|
||||
"..#DDDFD#...",
|
||||
"..#DFDDD#...",
|
||||
"..###DD###..",
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `C` is a canal tile: desert with a number hidden under it until it is dug out. */
|
||||
private val GREAT_CANAL_MAP = listOf(
|
||||
"............",
|
||||
"..#####.....",
|
||||
"..#####.....",
|
||||
"..CCCCC.....",
|
||||
"..#####.....",
|
||||
"..#####.....",
|
||||
"........$$..",
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `P` is a pirate fortress. */
|
||||
private val PIRATE_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
"..####.P##..",
|
||||
"..####..##..",
|
||||
"..####......",
|
||||
"..####..P#..",
|
||||
"...##...##..",
|
||||
"............",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val WONDERS_OF_CATAN_MAP = listOf(
|
||||
"............",
|
||||
"..####.###..",
|
||||
".#####.####.",
|
||||
"..###D.###..",
|
||||
".......##...",
|
||||
"..$......$..",
|
||||
"...##.......",
|
||||
"............",
|
||||
)
|
||||
|
||||
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
|
||||
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
|
||||
SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP
|
||||
SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP
|
||||
SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP
|
||||
SeaScenario.TREASURE_ISLANDS -> TREASURE_ISLANDS_MAP
|
||||
SeaScenario.OASES -> OASES_MAP
|
||||
SeaScenario.GREAT_CANAL -> GREAT_CANAL_MAP
|
||||
SeaScenario.PIRATE_ISLANDS -> PIRATE_ISLANDS_MAP
|
||||
SeaScenario.WONDERS_OF_CATAN -> WONDERS_OF_CATAN_MAP
|
||||
SeaScenario.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP
|
||||
SeaScenario.FORGOTTEN_TRIBE -> FORGOTTEN_TRIBE_MAP
|
||||
}
|
||||
|
||||
private val topologies = HashMap<SeaScenario, Topology>()
|
||||
@@ -138,11 +260,30 @@ object SeaBoards {
|
||||
val starts: List<Int> = listOf(home),
|
||||
/** The tiles under the fog, in the order they will be found. */
|
||||
val fogDeck: List<FogTile> = emptyList(),
|
||||
/** The tribe chip on each hex, or [TribeChip.NONE]; empty when the map has none. */
|
||||
val chips: List<TribeChip> = emptyList(),
|
||||
/** Cloth waiting on each village, by hex; empty when the map has none. */
|
||||
val villageCloth: List<Int> = emptyList(),
|
||||
/** Strength of the fortress on each hex, by hex; empty when the map has none. */
|
||||
val fortress: List<Int> = emptyList(),
|
||||
/** The chest under each hex, by hex; empty when the map has none. */
|
||||
val treasures: List<Treasure> = emptyList(),
|
||||
/** The number under each canal tile, by hex; empty when the map has none. */
|
||||
val canalNumbers: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
/** An island of this many land hexes or more can be started on. */
|
||||
private const val MAIN_ISLAND_HEXES = 4
|
||||
|
||||
/** Cloth each village starts with. */
|
||||
const val VILLAGE_CLOTH = 6
|
||||
|
||||
/** Ships' raids a pirate fortress takes to fall. */
|
||||
const val FORTRESS_STRENGTH = 3
|
||||
|
||||
/** Chests dealt to the fog, at most. */
|
||||
private const val CHESTS = 5
|
||||
|
||||
private val RESOURCE_TILES = listOf(
|
||||
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
|
||||
)
|
||||
@@ -157,21 +298,24 @@ object SeaBoards {
|
||||
val random = Random(seed)
|
||||
|
||||
// Resources fill the '#' hexes as evenly as the count allows, in shuffled order.
|
||||
val plain = kinds.indices.filter { kinds[it] == '#' }
|
||||
val plain = kinds.indices.filter { kinds[it] == '#' || kinds[it] == 't' }
|
||||
val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random)
|
||||
val terrains = MutableList(kinds.size) { Terrain.SEA }
|
||||
kinds.forEachIndexed { h, ch ->
|
||||
terrains[h] = when (ch) {
|
||||
'$' -> Terrain.GOLD
|
||||
'D' -> Terrain.DESERT
|
||||
'#' -> pool[plain.indexOf(h)]
|
||||
'#', 't' -> pool[plain.indexOf(h)]
|
||||
'V' -> Terrain.VILLAGE
|
||||
'P' -> Terrain.FORTRESS
|
||||
'C' -> Terrain.DESERT
|
||||
'F' -> Terrain.FOG
|
||||
else -> Terrain.SEA
|
||||
}
|
||||
}
|
||||
|
||||
// The fog hexes take part as slots, so the tokens they hide are dealt from the same pile.
|
||||
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT }
|
||||
val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && (terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS || kinds[it] == 'C') }
|
||||
check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" }
|
||||
val tokens = TOKENS.take(numbered.size).shuffled(random)
|
||||
val numbers = IntArray(terrains.size)
|
||||
@@ -209,7 +353,9 @@ object SeaBoards {
|
||||
val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 }
|
||||
?: numbered.first()
|
||||
// Home is the largest island that is not under the fog: nobody starts on land they cannot see.
|
||||
val fogged = fogHexes.map { topo.islandOf[it] }.toSet()
|
||||
val fogged = (0 until topo.islandCount).filter { island ->
|
||||
topo.islandOf.indices.filter { topo.islandOf[it] == island }.all { terrains[it] == Terrain.FOG }
|
||||
}.toSet()
|
||||
val home = (0 until topo.islandCount).filter { it !in fogged }
|
||||
.maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0
|
||||
// The pirate starts in the open sea: the sea hex farthest from every shore.
|
||||
@@ -228,6 +374,45 @@ object SeaBoards {
|
||||
}.ifEmpty { listOf(home) }
|
||||
}
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck)
|
||||
// Tribe chips: one of each kind in turn, dealt to the marked hexes in random order.
|
||||
val chipHexes = kinds.indices.filter { kinds[it] == 't' }
|
||||
val chips = if (chipHexes.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
val kindsOfChip = listOf(TribeChip.POINT, TribeChip.HARBOUR, TribeChip.GOLD)
|
||||
val dealt = List(chipHexes.size) { kindsOfChip[it % kindsOfChip.size] }.shuffled(random)
|
||||
List(terrains.size) { h -> chipHexes.indexOf(h).let { if (it < 0) TribeChip.NONE else dealt[it] } }
|
||||
}
|
||||
val villages = if (kinds.none { it == 'V' }) {
|
||||
emptyList()
|
||||
} else {
|
||||
List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 }
|
||||
}
|
||||
|
||||
val fortress = if (kinds.none { it == 'P' }) {
|
||||
emptyList()
|
||||
} else {
|
||||
List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 }
|
||||
}
|
||||
|
||||
// Chests under the fog: the four rewards in turn, on hexes picked at random.
|
||||
val chestKinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.CHOICE, Treasure.DEV_CARD, Treasure.ROADS)
|
||||
val chestHexes = if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) {
|
||||
fogHexes.shuffled(random).take(CHESTS)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val treasures = if (chestHexes.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
List(terrains.size) { h -> chestHexes.indexOf(h).let { if (it < 0) Treasure.NONE else chestKinds[it % chestKinds.size] } }
|
||||
}
|
||||
|
||||
// Canal tiles keep their number under the tile until it is dug out.
|
||||
val canalHexes = kinds.indices.filter { kinds[it] == 'C' }
|
||||
val canalNumbers = if (canalHexes.isEmpty()) emptyList() else List(terrains.size) { h -> if (h in canalHexes) numbers[h] else 0 }
|
||||
canalHexes.forEach { numbers[it] = 0 }
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress, treasures, canalNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,20 +182,303 @@ internal object SeaRules {
|
||||
} else if (tile.terrain == Terrain.GOLD) {
|
||||
owed += GoldOwed(seat, 1)
|
||||
}
|
||||
val chest = s.sea!!.treasures.getOrNull(hex) ?: Treasure.NONE
|
||||
if (chest != Treasure.NONE) {
|
||||
val (opened, choose) = openChest(s, seat, chest)
|
||||
s = opened
|
||||
if (choose > 0) owed += GoldOwed(seat, choose)
|
||||
}
|
||||
}
|
||||
if (owed.isEmpty()) return s
|
||||
val merged = GoldOwed(seat, owed.sumOf { it.count })
|
||||
val sea = s.sea!!
|
||||
return settleGold(s.copy(sea = sea.copy(gold = sea.gold + merged, afterGold = s.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold)))
|
||||
return owe(s, seat, owed.sumOf { it.count })
|
||||
}
|
||||
|
||||
/**
|
||||
* [seat] opens [chest]: the state after what it pays directly, and the number of cards of the seat's own
|
||||
* choice still owed. Cards the bank cannot cover are simply not paid.
|
||||
*/
|
||||
private fun openChest(state: CatanState, seat: Int, chest: Treasure): Pair<CatanState, Int> {
|
||||
var s = state
|
||||
fun pay(cards: Map<Resource, Int>) {
|
||||
val real = cards.filter { (r, n) -> CatanRules.bankSupply(s, r) >= n }
|
||||
if (real.isNotEmpty()) s = s.copy(resources = CatanRules.withCounts(s.resources, seat, real, 1))
|
||||
}
|
||||
var choose = 0
|
||||
val said = when (chest) {
|
||||
Treasure.BRICK_WOOL_GRAIN -> {
|
||||
pay(mapOf(Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1))
|
||||
"a brick, a wool and a grain"
|
||||
}
|
||||
Treasure.ROADS -> {
|
||||
pay(mapOf(Resource.LUMBER to 2, Resource.BRICK to 2))
|
||||
"materials for two roads"
|
||||
}
|
||||
Treasure.DEV_CARD -> if (!s.isCk && s.devDeck.isNotEmpty()) {
|
||||
val card = s.devDeck.first().copy(boughtTurn = s.turnNumber)
|
||||
val hands = s.devHands.toMutableList().also { it[seat] = it[seat] + card }
|
||||
s = s.copy(devDeck = s.devDeck.drop(1), devHands = hands, devHandCounts = hands.map { it.size })
|
||||
"a development card"
|
||||
} else {
|
||||
choose = 1
|
||||
"a resource of its choice"
|
||||
}
|
||||
else -> {
|
||||
choose = 1
|
||||
"a resource of its choice"
|
||||
}
|
||||
}
|
||||
s = s.copy(log = s.log + "Seat $seat opens a treasure chest: $said.")
|
||||
return s to choose
|
||||
}
|
||||
|
||||
/** Queues [count] cards of [seat]'s choice, to be taken before play resumes where it left off. */
|
||||
private fun owe(state: CatanState, seat: Int, count: Int): CatanState {
|
||||
val sea = state.sea!!
|
||||
val queued = sea.copy(
|
||||
gold = sea.gold + GoldOwed(seat, count),
|
||||
afterGold = state.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold,
|
||||
)
|
||||
return settleGold(state.copy(sea = queued))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Islands and scoring
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The bonus points [seat] holds for first landings. */
|
||||
fun bonusPoints(state: CatanState, seat: Int): Int =
|
||||
(state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS
|
||||
/** The bonus points [seat] holds: first landings, tribe chips, and one for every two cloth. */
|
||||
fun bonusPoints(state: CatanState, seat: Int): Int {
|
||||
val sea = state.sea ?: return 0
|
||||
return sea.islandBonus.count { it == seat } * ISLAND_BONUS +
|
||||
(sea.raidPoints.getOrNull(seat) ?: 0) +
|
||||
(sea.wonders.getOrNull(seat) ?: 0) +
|
||||
clothOf(state, seat) / CLOTH_PER_POINT +
|
||||
(sea.chipPoints.getOrNull(seat) ?: 0) +
|
||||
(sea.canalDug.getOrNull(seat) ?: 0) / CANAL_UNITS_PER_POINT
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The Great Canal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
val DIG_COST: Map<Resource, Int> = mapOf(Resource.BRICK to 1, Resource.ORE to 1)
|
||||
|
||||
/** Units that finish a canal tile. */
|
||||
const val CANAL_TILE_UNITS = 3
|
||||
|
||||
/** Two units dug are a victory point. */
|
||||
const val CANAL_UNITS_PER_POINT = 2
|
||||
|
||||
/** Tiles [seat] may dig a unit of: unfinished, with a settlement or city of the seat's on a corner. */
|
||||
private fun digTargets(state: CatanState, seat: Int): List<Int> {
|
||||
val sea = state.sea!!
|
||||
if (sea.canalUnits.isEmpty()) return emptyList()
|
||||
return sea.canalUnits.indices.filter { h ->
|
||||
sea.canalNumbers[h] != 0 && sea.canalUnits[h] in 0 until CANAL_TILE_UNITS &&
|
||||
state.terrains[h] == Terrain.DESERT &&
|
||||
state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat }
|
||||
}
|
||||
}
|
||||
|
||||
fun applyDig(state: CatanState, seat: Int, move: DigCanal): CatanState {
|
||||
val sea = state.sea!!
|
||||
val hex = state.topo.hexIndex.getValue(move.hex)
|
||||
val units = sea.canalUnits[hex] + 1
|
||||
val dug = sea.canalDug.toMutableList().also { it[seat] += 1 }
|
||||
var s = state.copy(
|
||||
resources = CatanRules.withCounts(state.resources, seat, DIG_COST, -1),
|
||||
sea = sea.copy(canalUnits = sea.canalUnits.toMutableList().also { it[hex] = units }, canalDug = dug),
|
||||
log = state.log + "Seat $seat digs the canal at (${move.hex.q},${move.hex.r}): $units of $CANAL_TILE_UNITS.",
|
||||
)
|
||||
if (units == CANAL_TILE_UNITS) {
|
||||
s = s.copy(
|
||||
terrains = s.terrains.toMutableList().also { it[hex] = Terrain.FIELDS },
|
||||
numbers = s.numbers.toMutableList().also { it[hex] = sea.canalNumbers[hex] },
|
||||
log = s.log + "The tile is dug out: it is fields now, with a ${sea.canalNumbers[hex]}.",
|
||||
)
|
||||
}
|
||||
return CatanRules.checkWin(s)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cloth for Catan
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const val CLOTH_PER_POINT = 2
|
||||
|
||||
fun clothOf(state: CatanState, seat: Int): Int = state.sea?.cloth?.getOrNull(seat) ?: 0
|
||||
|
||||
/**
|
||||
* After the dice pay: every village showing [number] pays one cloth to each settlement or city beside it
|
||||
* (a city is not worth more here), until its stock runs out. A robber on the village stops it.
|
||||
*/
|
||||
fun payCloth(state: CatanState, number: Int): CatanState {
|
||||
val sea = state.sea ?: return state
|
||||
if (sea.villageCloth.isEmpty()) return state
|
||||
var s = state
|
||||
val topo = state.topo
|
||||
for (h in 0 until topo.HEX_COUNT) {
|
||||
if (state.terrains[h] != Terrain.VILLAGE || state.numbers[h] != number || h == state.robber) continue
|
||||
val takers = topo.HEX_CORNERS[h].mapNotNull { state.buildings[it]?.owner }
|
||||
if (takers.isEmpty()) continue
|
||||
// Seats in turn order from the roller get the last of the stock.
|
||||
val order = takers.sortedBy { (it - state.turn + state.playerCount) % state.playerCount }
|
||||
val current = s.sea!!
|
||||
var stock = current.villageCloth[h]
|
||||
val cloth = current.cloth.toMutableList()
|
||||
val said = mutableListOf<String>()
|
||||
for (seat in order) {
|
||||
if (stock == 0) break
|
||||
cloth[seat] += 1
|
||||
stock--
|
||||
said += "seat $seat"
|
||||
}
|
||||
if (said.isEmpty()) continue
|
||||
val stocks = current.villageCloth.toMutableList().also { it[h] = stock }
|
||||
s = s.copy(
|
||||
sea = current.copy(cloth = cloth, villageCloth = stocks),
|
||||
log = s.log + "The village at (${topo.HEXES[h].q},${topo.HEXES[h].r}) pays cloth to ${said.joinToString(", ")}.",
|
||||
)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pirate Islands
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
val RAID_COST: Map<Resource, Int> = mapOf(Resource.ORE to 1, Resource.WOOL to 1, Resource.GRAIN to 1)
|
||||
|
||||
const val FORTRESS_POINTS = 2
|
||||
|
||||
/** Whether a pirate fortress still stands on the island [vertex] is part of, so nobody may settle there. */
|
||||
fun fortressHolds(state: CatanState, vertex: Int): Boolean {
|
||||
val sea = state.sea ?: return false
|
||||
if (sea.fortress.isEmpty()) return false
|
||||
val island = state.topo.vertexIsland[vertex]
|
||||
return island >= 0 && sea.fortress.indices.any { sea.fortress[it] > 0 && state.topo.islandOf[it] == island }
|
||||
}
|
||||
|
||||
/** Fortress hexes [seat] has a ship beside. */
|
||||
private fun raidTargets(state: CatanState, seat: Int): List<Int> {
|
||||
val sea = state.sea!!
|
||||
if (sea.fortress.isEmpty()) return emptyList()
|
||||
val topo = state.topo
|
||||
return sea.fortress.indices.filter { h ->
|
||||
sea.fortress[h] > 0 && (0 until topo.EDGE_COUNT).any { h in topo.EDGE_HEXES[it] && sea.ships[it] == seat }
|
||||
}
|
||||
}
|
||||
|
||||
fun applyRaid(state: CatanState, seat: Int, move: RaidFortress): CatanState {
|
||||
val sea = state.sea!!
|
||||
val hex = state.topo.hexIndex.getValue(move.hex)
|
||||
val left = sea.fortress[hex] - 1
|
||||
var s = state.copy(resources = CatanRules.withCounts(state.resources, seat, RAID_COST, -1))
|
||||
val strength = sea.fortress.toMutableList().also { it[hex] = left }
|
||||
if (left > 0) {
|
||||
return s.copy(
|
||||
sea = sea.copy(fortress = strength),
|
||||
log = s.log + "Seat $seat raids the fortress at (${move.hex.q},${move.hex.r}); $left strength left.",
|
||||
)
|
||||
}
|
||||
val points = sea.raidPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList().also { it[seat] += FORTRESS_POINTS }
|
||||
s = s.copy(
|
||||
terrains = s.terrains.toMutableList().also { it[hex] = Terrain.DESERT },
|
||||
sea = sea.copy(fortress = strength, raidPoints = points),
|
||||
log = s.log + "Seat $seat takes the fortress at (${move.hex.q},${move.hex.r}): +$FORTRESS_POINTS victory points, " +
|
||||
"and the island is open.",
|
||||
)
|
||||
return CatanRules.checkWin(s)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Wonders of Catan
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** What each of the four stages of a wonder costs. */
|
||||
val WONDER_STAGES: List<Map<Resource, Int>> = listOf(
|
||||
mapOf(Resource.BRICK to 2, Resource.LUMBER to 1, Resource.GRAIN to 1),
|
||||
mapOf(Resource.BRICK to 1, Resource.ORE to 2, Resource.WOOL to 1),
|
||||
mapOf(Resource.ORE to 3, Resource.GRAIN to 1, Resource.LUMBER to 1),
|
||||
mapOf(Resource.ORE to 3, Resource.GRAIN to 2, Resource.BRICK to 1),
|
||||
)
|
||||
|
||||
/** The next stage [seat] could build, if the scenario has wonders and the seat has a city to raise it in. */
|
||||
private fun nextStage(state: CatanState, seat: Int): Map<Resource, Int>? {
|
||||
val stage = state.sea?.wonders?.getOrNull(seat) ?: return null
|
||||
if (stage >= WONDER_STAGES.size) return null
|
||||
if (state.buildings.none { it?.owner == seat && it.type == BuildingType.CITY }) return null
|
||||
return WONDER_STAGES[stage]
|
||||
}
|
||||
|
||||
/** The extra moves a scenario offers during play: raids and wonder stages. */
|
||||
fun scenarioMoves(state: CatanState, seat: Int): List<CatanMove> {
|
||||
val moves = mutableListOf<CatanMove>()
|
||||
if (CatanRules.affordable(state, seat, RAID_COST)) {
|
||||
raidTargets(state, seat).forEach { moves += RaidFortress(state.topo.HEXES[it]) }
|
||||
}
|
||||
if (CatanRules.affordable(state, seat, DIG_COST)) {
|
||||
digTargets(state, seat).forEach { moves += DigCanal(state.topo.HEXES[it]) }
|
||||
}
|
||||
nextStage(state, seat)?.let { if (CatanRules.affordable(state, seat, it)) moves += BuildWonder }
|
||||
return moves
|
||||
}
|
||||
|
||||
fun applyWonder(state: CatanState, seat: Int): CatanState {
|
||||
val sea = state.sea!!
|
||||
val stage = sea.wonders[seat] + 1
|
||||
val built = sea.wonders.toMutableList().also { it[seat] = stage }
|
||||
val s = state.copy(
|
||||
resources = CatanRules.withCounts(state.resources, seat, WONDER_STAGES[stage - 1], -1),
|
||||
sea = sea.copy(wonders = built),
|
||||
log = state.log + "Seat $seat builds stage $stage of a wonder.",
|
||||
)
|
||||
if (stage < WONDER_STAGES.size) return CatanRules.checkWin(s)
|
||||
return s.copy(
|
||||
phase = CatanPhase.GAME_OVER,
|
||||
winner = seat,
|
||||
log = s.log + "Seat $seat completes a wonder of Catan and wins.",
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The Forgotten Tribe
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** After a settlement on [vertex]: take any tribe chip lying on a hex beside it. */
|
||||
fun takeChips(state: CatanState, seat: Int, vertex: Int): CatanState {
|
||||
var s = state
|
||||
if (s.sea?.chips.isNullOrEmpty()) return s
|
||||
var gold = 0
|
||||
for (hex in state.topo.VERTEX_HEXES[vertex]) {
|
||||
val sea = s.sea!!
|
||||
val chip = sea.chips.getOrNull(hex) ?: continue
|
||||
if (chip == TribeChip.NONE) continue
|
||||
val rest = sea.chips.toMutableList().also { it[hex] = TribeChip.NONE }
|
||||
var next = sea.copy(chips = rest)
|
||||
val said = when (chip) {
|
||||
TribeChip.POINT -> {
|
||||
val points = next.chipPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList()
|
||||
points[seat] += 1
|
||||
next = next.copy(chipPoints = points)
|
||||
"a victory point"
|
||||
}
|
||||
TribeChip.HARBOUR -> {
|
||||
next = next.copy(harbourSeats = (next.harbourSeats + seat).distinct())
|
||||
"a harbour that trades 3:1"
|
||||
}
|
||||
else -> {
|
||||
gold += 2
|
||||
"two resources of its choice"
|
||||
}
|
||||
}
|
||||
s = s.copy(sea = next, log = s.log + "Seat $seat takes the tribe's chip beside it: $said.")
|
||||
}
|
||||
return if (gold > 0) owe(s, seat, gold) else s
|
||||
}
|
||||
|
||||
/** What happens to a new settlement of [seat]'s on [vertex] beyond the base game. */
|
||||
fun settled(state: CatanState, seat: Int, vertex: Int): CatanState =
|
||||
takeChips(claimIsland(state, seat, vertex), seat, vertex)
|
||||
|
||||
/** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */
|
||||
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
|
||||
|
||||
@@ -33,7 +33,9 @@ import org.prolibertate.games.game.catan.CatanBoard
|
||||
import org.prolibertate.games.game.catan.CatanState
|
||||
import org.prolibertate.games.game.catan.NO_PLAYER
|
||||
import org.prolibertate.games.game.catan.Resource
|
||||
import org.prolibertate.games.game.catan.SeaRules
|
||||
import org.prolibertate.games.game.catan.Terrain
|
||||
import org.prolibertate.games.game.catan.TribeChip
|
||||
|
||||
/**
|
||||
* The island, drawn and tappable.
|
||||
@@ -166,6 +168,29 @@ private fun DrawScope.drawHexes(
|
||||
weight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
val sea = state.sea
|
||||
val note = when {
|
||||
state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth"
|
||||
sea != null && sea.canalNumbers.getOrElse(hex) { 0 } != 0 && state.terrains[hex] == Terrain.DESERT ->
|
||||
"dug ${sea.canalUnits.getOrElse(hex) { 0 }}/${SeaRules.CANAL_TILE_UNITS}"
|
||||
state.terrains[hex] == Terrain.FORTRESS && sea != null -> "fort ${sea.fortress.getOrElse(hex) { 0 }}"
|
||||
else -> when (sea?.chips?.getOrNull(hex)) {
|
||||
TribeChip.POINT -> "+1 VP"
|
||||
TribeChip.HARBOUR -> "3:1"
|
||||
TribeChip.GOLD -> "gold"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (note != null) {
|
||||
drawCentered(
|
||||
measurer = measurer,
|
||||
text = note,
|
||||
at = Pt(centre.x, centre.y + board.radius * 0.62f),
|
||||
fontPx = board.radius * 0.26f,
|
||||
color = style.tokenInk,
|
||||
weight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,9 @@ object CatanHudGlyphs {
|
||||
BuildKind.WALL -> "▦"
|
||||
BuildKind.KNIGHT -> "♞"
|
||||
BuildKind.SHIP -> "⛵"
|
||||
BuildKind.RAID -> "⚔"
|
||||
BuildKind.DIG -> "⛏"
|
||||
BuildKind.WONDER -> "✦"
|
||||
}
|
||||
|
||||
fun label(kind: BuildKind): String = when (kind) {
|
||||
@@ -182,6 +185,9 @@ object CatanHudGlyphs {
|
||||
BuildKind.WALL -> CatanText.WALL
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
BuildKind.RAID -> CatanText.RAID
|
||||
BuildKind.DIG -> CatanText.DIG
|
||||
BuildKind.WONDER -> CatanText.WONDER
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import org.prolibertate.games.game.catan.ActivateKnight
|
||||
import org.prolibertate.games.game.catan.BuildCity
|
||||
import org.prolibertate.games.game.catan.BuildRoad
|
||||
import org.prolibertate.games.game.catan.BuildShip
|
||||
import org.prolibertate.games.game.catan.BuildWonder
|
||||
import org.prolibertate.games.game.catan.DigCanal
|
||||
import org.prolibertate.games.game.catan.MovePirate
|
||||
import org.prolibertate.games.game.catan.RaidFortress
|
||||
import org.prolibertate.games.game.catan.MoveShip
|
||||
import org.prolibertate.games.game.catan.Topology
|
||||
import org.prolibertate.games.game.catan.BuildSettlement
|
||||
@@ -67,13 +70,38 @@ enum class BuildKind {
|
||||
ROAD, SETTLEMENT, CITY, WALL, KNIGHT,
|
||||
|
||||
/** Seafarers: a ship, laid on a sea edge. */
|
||||
SHIP;
|
||||
SHIP,
|
||||
|
||||
/** Pirate Islands: raid a fortress; the target is the fortress hex. */
|
||||
RAID,
|
||||
|
||||
/** Great Canal: dig a canal tile; the target is the tile. */
|
||||
DIG,
|
||||
|
||||
/** Wonders of Catan: the next stage of the wonder, which needs no tap on the board. */
|
||||
WONDER;
|
||||
|
||||
companion object {
|
||||
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
|
||||
|
||||
/** Seafarers adds the ship, listed after the road it sails beside. */
|
||||
val SEAFARERS: List<BuildKind> = listOf(ROAD, SHIP, SETTLEMENT, CITY)
|
||||
|
||||
/** The kinds a table's build menu lists: the scenario's own raid or wonder follows the usual ones. */
|
||||
fun of(state: CatanState): List<BuildKind> {
|
||||
val sea = state.sea
|
||||
val extras = buildList {
|
||||
if (sea != null && sea.fortress.isNotEmpty()) add(RAID)
|
||||
if (sea != null && sea.canalUnits.isNotEmpty()) add(DIG)
|
||||
if (sea != null && sea.wonders.isNotEmpty()) add(WONDER)
|
||||
}
|
||||
val usual = when {
|
||||
state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, DIG, WONDER)
|
||||
state.isSea -> SEAFARERS
|
||||
else -> BASE
|
||||
}
|
||||
return usual + extras
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +280,13 @@ object CatanInteraction {
|
||||
return when (building) {
|
||||
BuildKind.ROAD -> BoardTargets(edges = roads)
|
||||
BuildKind.SHIP -> BoardTargets(edges = ships)
|
||||
BuildKind.RAID -> BoardTargets(
|
||||
hexes = legal.filterIsInstance<RaidFortress>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
|
||||
)
|
||||
BuildKind.DIG -> BoardTargets(
|
||||
hexes = legal.filterIsInstance<DigCanal>().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(),
|
||||
)
|
||||
BuildKind.WONDER -> BoardTargets()
|
||||
BuildKind.SETTLEMENT ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
|
||||
|
||||
@@ -519,6 +554,7 @@ object CatanInteraction {
|
||||
val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
|
||||
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
|
||||
val pirates = legal.filterIsInstance<MovePirate>().filter { it.hex == coord }
|
||||
legal.firstOrNull { (it is RaidFortress && it.hex == coord) || (it is DigCanal && it.hex == coord) }?.let { return TapOutcome.Submit(it) }
|
||||
return when {
|
||||
moves.isNotEmpty() -> if (moves.size == 1) {
|
||||
TapOutcome.Submit(moves.first())
|
||||
@@ -586,6 +622,9 @@ object CatanInteraction {
|
||||
if (legal.any { it is BuildShip }) add(BuildKind.SHIP)
|
||||
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
|
||||
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
|
||||
if (legal.any { it is RaidFortress }) add(BuildKind.RAID)
|
||||
if (legal.any { it is DigCanal }) add(BuildKind.DIG)
|
||||
if (legal.any { it is BuildWonder }) add(BuildKind.WONDER)
|
||||
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
|
||||
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ fun CatanPlayerCard(
|
||||
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
|
||||
}
|
||||
if (ck.merchant?.seat == seat) add("Merchant")
|
||||
addAll(seaAwards(state, seat, compact))
|
||||
}
|
||||
CardLine(
|
||||
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
|
||||
@@ -280,6 +281,7 @@ fun CatanPlayerCard(
|
||||
val awards = buildList {
|
||||
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
|
||||
addAll(seaAwards(state, seat, compact))
|
||||
}
|
||||
// Always drawn, so the card does not grow when somebody takes an award.
|
||||
CardLine(
|
||||
@@ -598,6 +600,9 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.WALL -> CatanText.WALL
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
BuildKind.RAID -> CatanText.RAID
|
||||
BuildKind.DIG -> CatanText.DIG
|
||||
BuildKind.WONDER -> CatanText.WONDER
|
||||
}
|
||||
|
||||
private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||
@@ -607,6 +612,9 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.WALL -> CatanText.WALL_COST
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
|
||||
BuildKind.SHIP -> CatanText.SHIP_COST_TEXT
|
||||
BuildKind.RAID -> CatanText.RAID_COST
|
||||
BuildKind.DIG -> CatanText.DIG_COST
|
||||
BuildKind.WONDER -> CatanText.WONDER_COST
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -634,3 +642,14 @@ fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Seafarers scenario holdings worth a word on a player's card: cloth, and a tribe harbour. */
|
||||
private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List<String> {
|
||||
val sea = state.sea ?: return emptyList()
|
||||
return buildList {
|
||||
if (sea.cloth.isNotEmpty()) add(if (compact) "${sea.cloth[seat]}c" else "Cloth ${sea.cloth[seat]}")
|
||||
if (sea.canalDug.isNotEmpty()) add(if (compact) "${sea.canalDug[seat]}⛏" else "Dug ${sea.canalDug[seat]}")
|
||||
if (sea.wonders.isNotEmpty()) add(if (compact) "W${sea.wonders[seat]}" else "Wonder ${sea.wonders[seat]}/4")
|
||||
if (seat in sea.harbourSeats) add(if (compact) "3:1" else "Harbour 3:1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.catan.AcceptTrade
|
||||
import org.prolibertate.games.game.catan.AqueductChore
|
||||
import org.prolibertate.games.game.catan.BuildWonder
|
||||
import org.prolibertate.games.game.catan.BuyDevCard
|
||||
import org.prolibertate.games.game.catan.CatanCk
|
||||
import org.prolibertate.games.game.catan.CatanMove
|
||||
@@ -159,6 +160,14 @@ fun CatanScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// A wonder stage needs no tap on the board: choosing it from the menu builds it.
|
||||
LaunchedEffect(building) {
|
||||
if (building == BuildKind.WONDER) {
|
||||
legal.firstOrNull { it is BuildWonder }?.let { controller.submit(it) }
|
||||
building = null
|
||||
}
|
||||
}
|
||||
|
||||
val submit: (CatanMove) -> Unit = { move ->
|
||||
controller.submit(move)
|
||||
building = null
|
||||
@@ -197,11 +206,7 @@ fun CatanScreen(
|
||||
val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
|
||||
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
||||
val actions = CatanActions(
|
||||
buildKinds = when {
|
||||
current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP || current.isSea }
|
||||
current.isSea -> BuildKind.SEAFARERS
|
||||
else -> BuildKind.BASE
|
||||
},
|
||||
buildKinds = BuildKind.of(current),
|
||||
ck = current.isCk,
|
||||
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
|
||||
canPlayProgress = progressHand.any { it.playable },
|
||||
|
||||
@@ -126,6 +126,10 @@ private val LightStyle = CatanStyle(
|
||||
Terrain.GOLD to Color(0xFFF2B01E),
|
||||
// Land not yet found: a pale grey bank of fog with a question mark.
|
||||
Terrain.FOG to Color(0xFFB9C2C9),
|
||||
// Cloth villages: a dyed-cloth purple.
|
||||
Terrain.VILLAGE to Color(0xFF9B6BB5),
|
||||
// A pirate fortress: dark stone.
|
||||
Terrain.FORTRESS to Color(0xFF4A4F57),
|
||||
),
|
||||
hexEdge = Color(0x55000000),
|
||||
token = Color(0xFFF7EFD9),
|
||||
|
||||
@@ -75,6 +75,8 @@ object CatanText {
|
||||
Terrain.SEA -> "Sea"
|
||||
Terrain.GOLD -> "Gold field"
|
||||
Terrain.FOG -> "Fog"
|
||||
Terrain.VILLAGE -> "Village"
|
||||
Terrain.FORTRESS -> "Pirate fortress"
|
||||
}
|
||||
|
||||
fun devCard(type: DevCardType): String = when (type) {
|
||||
@@ -114,6 +116,12 @@ object CatanText {
|
||||
const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)."
|
||||
const val SHIP = "Ship"
|
||||
const val SHIP_COST_TEXT = "1 lumber + 1 wool"
|
||||
const val RAID = "Raid"
|
||||
const val RAID_COST = "1 ore + 1 wool + 1 grain"
|
||||
const val DIG = "Dig"
|
||||
const val DIG_COST = "1 brick + 1 ore"
|
||||
const val WONDER = "Wonder"
|
||||
const val WONDER_COST = "next stage"
|
||||
const val GOLD_TITLE = "Gold field"
|
||||
const val GOLD_TAKE = "Take"
|
||||
const val ROLL = "Roll dice"
|
||||
@@ -247,6 +255,8 @@ object CatanText {
|
||||
!mine -> "$turn is playing…"
|
||||
movingShip -> "Tap the sea edge to sail the ship to."
|
||||
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
|
||||
building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it."
|
||||
building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit."
|
||||
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||
@@ -451,6 +461,8 @@ object CatanText {
|
||||
movingKnight -> "Tap where the knight should go."
|
||||
movingShip -> "Tap the sea edge to sail the ship to."
|
||||
building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship."
|
||||
building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it."
|
||||
building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit."
|
||||
building == BuildKind.ROAD -> "Tap an edge to build a road."
|
||||
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
|
||||
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
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
|
||||
|
||||
/** The scenarios with rules of their own: cloth villages and tribe chips. */
|
||||
class CatanSeaExtrasTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun start(scenario: SeaScenario, seed: Long = 5L, players: Int = 3): CatanState {
|
||||
val options = CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 14,
|
||||
expansions = setOf(CatanExpansion.SEAFARERS),
|
||||
scenario = scenario,
|
||||
)
|
||||
val config = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
return CatanRules.initialState(config).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
|
||||
}
|
||||
|
||||
private fun dice(total: Int): List<Int> = if (total <= 7) listOf(1, total - 1) else listOf(total - 6, 6)
|
||||
|
||||
/** A cloth game with seat 0 and seat 1 both settled beside the first village, the robber elsewhere. */
|
||||
private fun clothGame(): Pair<CatanState, Int> {
|
||||
val s = start(SeaScenario.CLOTH_FOR_CATAN)
|
||||
val village = s.terrains.indexOf(Terrain.VILLAGE)
|
||||
val corners = s.topo.HEX_CORNERS[village]
|
||||
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
|
||||
val staged = s.copy(
|
||||
robber = elsewhere,
|
||||
buildings = s.buildings.toMutableList().also {
|
||||
it[corners[0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
|
||||
it[corners[3]] = CatanBuilding(BuildingType.CITY, 1)
|
||||
},
|
||||
)
|
||||
return staged to village
|
||||
}
|
||||
|
||||
private fun roll(s: CatanState, number: Int) = CatanRules.applyMove(s, s.turn, RollDice(dice(number)))
|
||||
|
||||
@Test
|
||||
fun `a village pays one cloth to every settlement beside it, a city no more`() {
|
||||
val (s, village) = clothGame()
|
||||
val number = s.numbers[village]
|
||||
assertTrue(number in 2..12 && number != 7)
|
||||
val after = roll(s, number)
|
||||
assertEquals(listOf(1, 1, 0), after.sea!!.cloth)
|
||||
assertEquals(SeaBoards.VILLAGE_CLOTH - 2, after.sea!!.villageCloth[village])
|
||||
assertTrue(after.log.any { "village" in it && "cloth" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty village pays nothing and the last token goes to the seat nearest the roller`() {
|
||||
val (s, village) = clothGame()
|
||||
val low = s.copy(sea = s.sea!!.copy(villageCloth = s.sea!!.villageCloth.toMutableList().also { it[village] = 1 }))
|
||||
val after = roll(low.copy(turn = 1), low.numbers[village])
|
||||
assertEquals(listOf(0, 1, 0), after.sea!!.cloth)
|
||||
assertEquals(0, after.sea!!.villageCloth[village])
|
||||
val again = roll(after.copy(phase = CatanPhase.AWAIT_ROLL), after.numbers[village])
|
||||
assertEquals(listOf(0, 1, 0), again.sea!!.cloth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the robber on a village stops it`() {
|
||||
val (s, village) = clothGame()
|
||||
val after = roll(s.copy(robber = village), s.numbers[village])
|
||||
assertEquals(listOf(0, 0, 0), after.sea!!.cloth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every two cloth are a victory point, and the whole thing survives the wire format`() {
|
||||
val (s, _) = clothGame()
|
||||
val rich = s.copy(sea = s.sea!!.copy(cloth = listOf(5, 1, 0)))
|
||||
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 2, CatanRules.publicVictoryPoints(rich)[0])
|
||||
assertEquals(CatanRules.publicVictoryPoints(s)[1], CatanRules.publicVictoryPoints(rich)[1])
|
||||
assertEquals(rich, CatanRules.decodeState(CatanRules.encodeState(rich)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `villages pay cloth with Cities and Knights too`() {
|
||||
val options = CatanOptions(
|
||||
playerCount = 3,
|
||||
pointsToWin = 14,
|
||||
expansions = setOf(CatanExpansion.SEAFARERS, CatanExpansion.CITIES_AND_KNIGHTS),
|
||||
scenario = SeaScenario.CLOTH_FOR_CATAN,
|
||||
)
|
||||
val config = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = 5L,
|
||||
)
|
||||
val s = CatanRules.initialState(config)
|
||||
.copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
|
||||
val village = s.terrains.indexOf(Terrain.VILLAGE)
|
||||
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
|
||||
val staged = s.copy(
|
||||
robber = elsewhere,
|
||||
buildings = s.buildings.toMutableList().also {
|
||||
it[s.topo.HEX_CORNERS[village][0]] = CatanBuilding(BuildingType.SETTLEMENT, 2)
|
||||
},
|
||||
)
|
||||
val after = roll(staged, staged.numbers[village])
|
||||
assertEquals(1, after.sea!!.cloth[2])
|
||||
}
|
||||
|
||||
// ---- The Forgotten Tribe ----
|
||||
|
||||
/** A tribe game, seat 0 with a road reaching a settlement site beside [chipHex], and plenty of cards. */
|
||||
private fun tribeGame(chip: TribeChip): Triple<CatanState, Int, Int> {
|
||||
val s = start(SeaScenario.FORGOTTEN_TRIBE)
|
||||
val hex = s.sea!!.chips.indexOfFirst { it != TribeChip.NONE }
|
||||
val chips = s.sea!!.chips.toMutableList().also { it[hex] = chip }
|
||||
val topo = s.topo
|
||||
val vertex = topo.HEX_CORNERS[hex].first { v -> topo.VERTEX_NEIGHBORS[v].all { s.buildings[it] == null } }
|
||||
val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeLand[it] }
|
||||
val staged = s.copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
roads = s.roads.toMutableList().also { it[edge] = 0 },
|
||||
resources = List(3) { Resource.entries.associateWith { 4 } },
|
||||
sea = s.sea!!.copy(chips = chips),
|
||||
)
|
||||
return Triple(staged, hex, vertex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first settler beside a point chip takes the point`() {
|
||||
val (s, hex, vertex) = tribeGame(TribeChip.POINT)
|
||||
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
|
||||
assertEquals(1, after.sea!!.chipPoints[0])
|
||||
assertEquals(TribeChip.NONE, after.sea!!.chips[hex])
|
||||
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 1 + 1 + 2, CatanRules.publicVictoryPoints(after)[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a harbour chip trades three to one anywhere`() {
|
||||
val (s, _, vertex) = tribeGame(TribeChip.HARBOUR)
|
||||
assertEquals(4, CatanRules.bestPortRatio(s, 0, Resource.WOOL))
|
||||
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
|
||||
assertEquals(listOf(0), after.sea!!.harbourSeats)
|
||||
assertEquals(3, CatanRules.bestPortRatio(after, 0, Resource.WOOL))
|
||||
assertEquals(4, CatanRules.bestPortRatio(after, 1, Resource.WOOL))
|
||||
assertTrue(CatanRules.legalMoves(after, 0).any { it is MaritimeTrade })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gold chip asks for two resources of the settler's choice`() {
|
||||
val (s, _, vertex) = tribeGame(TribeChip.GOLD)
|
||||
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
|
||||
assertEquals(CatanPhase.GOLD, after.phase)
|
||||
assertEquals(listOf(GoldOwed(0, 2)), after.sea!!.gold)
|
||||
val done = CatanRules.applyMove(after, 0, CatanRules.legalMoves(after, 0).first())
|
||||
assertEquals(CatanPhase.PLAYING, done.phase)
|
||||
}
|
||||
|
||||
// ---- Pirate Islands ----
|
||||
|
||||
private fun fortGame(): Triple<CatanState, Int, Int> {
|
||||
val s = start(SeaScenario.PIRATE_ISLANDS).copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
resources = List(3) { Resource.entries.associateWith { 6 } },
|
||||
)
|
||||
val fort = s.terrains.indexOf(Terrain.FORTRESS)
|
||||
val edge = (0 until s.topo.EDGE_COUNT).first { s.topo.edgeSea[it] && fort in s.topo.EDGE_HEXES[it] }
|
||||
return Triple(s, fort, edge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nobody settles a fortress island until the fortress falls`() {
|
||||
val (s, fort, _) = fortGame()
|
||||
val corner = s.topo.HEX_CORNERS[fort].first()
|
||||
assertTrue(SeaRules.fortressHolds(s, corner))
|
||||
assertTrue(!CatanRules.vertexOpenForSettlement(s, corner))
|
||||
val fort2 = s.terrains.indices.first { s.terrains[it] == Terrain.FORTRESS && it != fort }
|
||||
assertTrue(s.topo.islandOf[fort] != s.topo.islandOf[fort2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a raid needs a ship beside the fortress and three cards, and three raids take it`() {
|
||||
val (s, fort, edge) = fortGame()
|
||||
val coord = s.topo.HEXES[fort]
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is RaidFortress })
|
||||
var state = s.copy(sea = s.sea!!.copy(ships = s.sea!!.ships.toMutableList().also { it[edge] = 0 }))
|
||||
assertTrue(RaidFortress(coord) in CatanRules.legalMoves(state, 0))
|
||||
val before = state.resources[0].getValue(Resource.ORE)
|
||||
repeat(3) { state = CatanRules.applyMove(state, 0, RaidFortress(coord)) }
|
||||
assertEquals(before - 3, state.resources[0].getValue(Resource.ORE))
|
||||
assertEquals(Terrain.DESERT, state.terrains[fort])
|
||||
assertEquals(2, state.sea!!.raidPoints[0])
|
||||
assertTrue(!SeaRules.fortressHolds(state, s.topo.HEX_CORNERS[fort].first()))
|
||||
assertTrue(CatanRules.legalMoves(state, 0).none { it is RaidFortress })
|
||||
// Seat 1 has no ship there and cannot raid.
|
||||
assertTrue(CatanRules.legalMoves(state.copy(turn = 1), 1).none { it is RaidFortress })
|
||||
}
|
||||
|
||||
// ---- Wonders of Catan ----
|
||||
|
||||
private fun wonderGame(city: Boolean = true): CatanState {
|
||||
val s = start(SeaScenario.WONDERS_OF_CATAN).copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
resources = List(3) { Resource.entries.associateWith { 9 } },
|
||||
)
|
||||
return if (!city) s else s.copy(
|
||||
buildings = s.buildings.toMutableList().also { it[0] = CatanBuilding(BuildingType.CITY, 0) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wonder needs a city, each stage is a point, and the fourth stage wins`() {
|
||||
assertTrue(CatanRules.legalMoves(wonderGame(city = false), 0).none { it is BuildWonder })
|
||||
var s = wonderGame()
|
||||
val base = CatanRules.publicVictoryPoints(s)[0]
|
||||
for (stage in 1..3) {
|
||||
assertTrue(BuildWonder in CatanRules.legalMoves(s, 0))
|
||||
s = CatanRules.applyMove(s, 0, BuildWonder)
|
||||
assertEquals(stage, s.sea!!.wonders[0])
|
||||
assertEquals(base + stage, CatanRules.publicVictoryPoints(s)[0])
|
||||
assertEquals(CatanPhase.PLAYING, s.phase)
|
||||
}
|
||||
s = CatanRules.applyMove(s, 0, BuildWonder)
|
||||
assertEquals(CatanPhase.GAME_OVER, s.phase)
|
||||
assertEquals(0, s.winner)
|
||||
assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wonder stage is not offered without the cards`() {
|
||||
val s = wonderGame().copy(resources = List(3) { Resource.entries.associateWith { 0 } })
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildWonder })
|
||||
}
|
||||
|
||||
// ---- Treasure Islands, Oases ----
|
||||
|
||||
@Test
|
||||
fun `chests lie under the fog and are hidden from other views`() {
|
||||
for (scenario in listOf(SeaScenario.TREASURE_ISLANDS, SeaScenario.OASES)) {
|
||||
val s = start(scenario)
|
||||
val chests = s.sea!!.treasures.indices.filter { s.sea!!.treasures[it] != Treasure.NONE }
|
||||
assertEquals(minOf(5, s.terrains.count { it == Terrain.FOG }), chests.size)
|
||||
assertTrue(chests.all { s.terrains[it] == Terrain.FOG })
|
||||
assertTrue(CatanRules.viewFor(s, 1).sea!!.treasures.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an oasis map starts everyone on the desert island, never beside an oasis`() {
|
||||
val s = start(SeaScenario.OASES)
|
||||
assertEquals(37, s.topo.islandOf.count { it == s.sea!!.home })
|
||||
val moves = CatanRules.legalMoves(s.copy(phase = CatanPhase.SETUP_FIRST), s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.isNotEmpty())
|
||||
assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } })
|
||||
}
|
||||
|
||||
/** A chest of [kind] under the first fog hex, with a road of seat 0's laid up to the edge that will reveal it. */
|
||||
private fun chestGame(kind: Treasure): Triple<CatanState, Int, Int> {
|
||||
val s = start(SeaScenario.OASES).copy(phase = CatanPhase.PLAYING)
|
||||
val topo = s.topo
|
||||
val hex = s.terrains.indexOf(Terrain.FOG)
|
||||
val chests = List(topo.HEX_COUNT) { if (it == hex) kind else Treasure.NONE }
|
||||
// Seat 0 owns the town beside a road that reaches the oasis; building the road finds it.
|
||||
val edge = topo.EDGE_VERTICES.indices.first { e -> hex in topo.EDGE_HEXES[e] && topo.edgeLand[e] }
|
||||
val staged = s.copy(
|
||||
resources = List(3) { Resource.entries.associateWith { 5 } },
|
||||
sea = s.sea!!.copy(treasures = chests),
|
||||
)
|
||||
return Triple(staged, hex, edge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opening a chest pays its reward`() {
|
||||
val kinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.ROADS, Treasure.DEV_CARD, Treasure.CHOICE)
|
||||
for (kind in kinds) {
|
||||
val (s, _, _) = chestGame(kind)
|
||||
// Stage the find directly: reveal as if a road had just reached the hex.
|
||||
val topo = s.topo
|
||||
val hex = s.terrains.indexOf(Terrain.FOG)
|
||||
val edge = (0 until topo.EDGE_COUNT).first { hex in topo.EDGE_HEXES[it] && topo.edgeLand[it] }
|
||||
val after = SeaRules.reveal(s, 0, edge)
|
||||
assertTrue("$kind: the chest is announced", after.log.any { "treasure chest" in it })
|
||||
when (kind) {
|
||||
Treasure.DEV_CARD -> assertEquals(1, after.devHands[0].size)
|
||||
Treasure.CHOICE -> assertTrue(after.phase == CatanPhase.GOLD || after.log.any { "uncovers" in it })
|
||||
Treasure.ROADS -> assertTrue(after.resources[0].getValue(Resource.LUMBER) >= 7)
|
||||
else -> assertTrue(after.resources[0].getValue(Resource.WOOL) >= 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The Great Canal ----
|
||||
|
||||
private fun canalGame(): Triple<CatanState, Int, CatanState> {
|
||||
val s = start(SeaScenario.GREAT_CANAL).copy(
|
||||
phase = CatanPhase.PLAYING,
|
||||
resources = List(3) { Resource.entries.associateWith { 9 } },
|
||||
)
|
||||
val tile = s.sea!!.canalNumbers.indexOfFirst { it != 0 }
|
||||
val staged = s.copy(
|
||||
buildings = s.buildings.toMutableList().also {
|
||||
it[s.topo.HEX_CORNERS[tile][0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
|
||||
},
|
||||
)
|
||||
return Triple(staged, tile, s)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a canal tile hides its number, and three digs turn it into fields`() {
|
||||
var (s, tile, _) = canalGame()
|
||||
assertEquals(0, s.numbers[tile])
|
||||
assertEquals(Terrain.DESERT, s.terrains[tile])
|
||||
val number = s.sea!!.canalNumbers[tile]
|
||||
val dig = DigCanal(s.topo.HEXES[tile])
|
||||
assertTrue(dig in CatanRules.legalMoves(s, 0))
|
||||
assertTrue(CatanRules.legalMoves(s.copy(turn = 1), 1).none { it is DigCanal })
|
||||
val before = CatanRules.publicVictoryPoints(s)[0]
|
||||
repeat(2) { s = CatanRules.applyMove(s, 0, dig) }
|
||||
assertEquals(Terrain.DESERT, s.terrains[tile])
|
||||
assertEquals(before + 1, CatanRules.publicVictoryPoints(s)[0])
|
||||
s = CatanRules.applyMove(s, 0, dig)
|
||||
assertEquals(Terrain.FIELDS, s.terrains[tile])
|
||||
assertEquals(number, s.numbers[tile])
|
||||
assertTrue(CatanRules.legalMoves(s, 0).none { it is DigCanal && it.hex == dig.hex })
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,13 @@ class CatanSeaScenariosTest {
|
||||
assertEquals(listOf(1, 1, 6, 6, 6, 6), sizes(SeaScenario.FOUR_ISLANDS))
|
||||
assertEquals(listOf(1, 1, 1, 2, 25), sizes(SeaScenario.THROUGH_THE_DESERT))
|
||||
assertEquals(listOf(2, 13, 14), sizes(SeaScenario.FOG_ISLANDS))
|
||||
assertEquals(listOf(1, 3, 4, 15), sizes(SeaScenario.TREASURE_ISLANDS))
|
||||
assertEquals(listOf(37), sizes(SeaScenario.OASES))
|
||||
assertEquals(listOf(2, 25), sizes(SeaScenario.GREAT_CANAL))
|
||||
assertEquals(listOf(4, 5, 18), sizes(SeaScenario.PIRATE_ISLANDS))
|
||||
assertEquals(listOf(1, 3, 12, 13), sizes(SeaScenario.WONDERS_OF_CATAN))
|
||||
assertEquals(listOf(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN))
|
||||
assertEquals(listOf(2, 3, 5, 17), sizes(SeaScenario.FORGOTTEN_TRIBE))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,10 +62,14 @@ class CatanSeaScenariosTest {
|
||||
for (h in 0 until topo.HEX_COUNT) {
|
||||
val land = s.terrains[h] != Terrain.SEA
|
||||
assertEquals("$scenario hex $h", topo.land[h], land)
|
||||
val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG
|
||||
val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG ||
|
||||
s.terrains[h] == Terrain.FORTRESS
|
||||
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
|
||||
}
|
||||
assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS)
|
||||
val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN &&
|
||||
scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS &&
|
||||
scenario != SeaScenario.TREASURE_ISLANDS
|
||||
assertTrue("$scenario has no desert", deserts > 0 || !deserted)
|
||||
// Under fog every hex is face down with no number; the tiles are in the pile.
|
||||
val fog = s.terrains.indices.filter { s.terrains[it] == Terrain.FOG }
|
||||
assertEquals(fog.size, s.sea!!.fogDeck.size)
|
||||
@@ -208,6 +219,12 @@ class CatanSeaScenariosTest {
|
||||
@Test
|
||||
fun `engines finish a game on every map at three and four players`() {
|
||||
var fogFound = 0
|
||||
var clothPaid = 0
|
||||
var chipsTaken = 0
|
||||
var fortsTaken = 0
|
||||
var wonderStages = 0
|
||||
var chestsOpened = 0
|
||||
var canalDug = 0
|
||||
for (scenario in SeaScenario.entries) {
|
||||
for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) {
|
||||
val opts = options(scenario, players)
|
||||
@@ -225,9 +242,25 @@ class CatanSeaScenariosTest {
|
||||
}
|
||||
assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
if (scenario == SeaScenario.FOG_ISLANDS) fogFound += 14 - state.sea!!.fogDeck.size
|
||||
if (scenario == SeaScenario.CLOTH_FOR_CATAN) clothPaid += state.sea!!.cloth.sum()
|
||||
if (scenario == SeaScenario.PIRATE_ISLANDS) fortsTaken += state.sea!!.raidPoints.sum() / 2
|
||||
if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) {
|
||||
chestsOpened += state.log.count { "treasure chest" in it }
|
||||
}
|
||||
if (scenario == SeaScenario.GREAT_CANAL) canalDug += state.sea!!.canalDug.sum()
|
||||
if (scenario == SeaScenario.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum()
|
||||
if (scenario == SeaScenario.FORGOTTEN_TRIBE) {
|
||||
chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE }
|
||||
}
|
||||
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||
}
|
||||
}
|
||||
assertTrue("no engine ever uncovered a fog hex", fogFound > 0)
|
||||
assertTrue("no village ever paid cloth", clothPaid > 0)
|
||||
assertTrue("no tribe chip was ever taken", chipsTaken > 0)
|
||||
assertTrue("no fortress ever fell", fortsTaken > 0)
|
||||
assertTrue("no chest was ever opened", chestsOpened > 0)
|
||||
assertTrue("no canal unit was ever dug", canalDug > 0)
|
||||
assertTrue("no wonder stage was ever built", wonderStages > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,13 @@ A full-width panel that slides up over the bottom of the screen (`Animator`, col
|
||||
* **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a
|
||||
road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`.
|
||||
* **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are
|
||||
**not** part of the physical Seafarers rules; they are out of scope here.
|
||||
**not** part of the physical Seafarers rules. Names and texts only (rule values are compiled code):
|
||||
`TreasureRule` with chest outcomes (bricks, wool, grain; a resource; a development card; two roads),
|
||||
`SeafarerOasis` (no rule class of its own, base-game rules), and `GreatCanalRule` with `BuildCanal`,
|
||||
`CanalVictoryPoints`, "The canal was completely dug. Now grain can be grown." and a target of 21 / 18 points.
|
||||
The base-game scenario `baseharbormaster`: "2 victory points. This card goes to the first player to acquire at
|
||||
least 3 harbors. If another player acquires more, he will immediately receive this card."
|
||||
Implemented as inferred rules; see `docs/RULES-catan-seafarers.md`.
|
||||
* **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip`
|
||||
(number tokens placed per scenario), `Shuffle_Harbors`.
|
||||
* **Island bonus:** special victory points for the first settlement on a new island
|
||||
|
||||
@@ -27,6 +27,20 @@ Pick the map at setup; each scenario names where the starting settlements may go
|
||||
* **The Fog Islands.** A 13-hex home island (one desert), a two-hex islet and a 14-hex bank of fog.
|
||||
See *Fog* below.
|
||||
|
||||
* **Cloth for Catan.** A 17-hex main island, an eastern isle, two islets, and four **villages** on the coast.
|
||||
See *Cloth* below.
|
||||
* **The Forgotten Tribe.** A 17-hex home island and three isles carrying four tribe chips. See *The Forgotten
|
||||
Tribe* below.
|
||||
|
||||
* **Pirate Islands.** An 18-hex home island and two isles (five and four hexes) each held by a pirate
|
||||
fortress. See *Pirate Islands* below.
|
||||
* **Wonders of Catan.** A 13-hex island with a desert, a 12-hex island, and gold islets. See *Wonders* below.
|
||||
|
||||
* **The Treasure Islands** *(Catan Universe extra)*. A 15-hex home island and four fog isles, eight hexes of fog in
|
||||
all. See *Catan Universe extras* below.
|
||||
* **The Oases** *(Catan Universe extra)*. One 37-hex island that is mostly desert, with three oases hidden in it.
|
||||
* **The Great Canal** *(Catan Universe extra)*. A 25-hex island split by a row of five canal tiles, and gold islets.
|
||||
|
||||
A *new island* for the bonus is any island that is not a starting island for that map.
|
||||
|
||||
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
|
||||
@@ -85,6 +99,75 @@ pile is hidden from every seat but the host's, like the development deck.
|
||||
also on one bank rather than between several islands, and the number tokens are dealt with the tiles, so two
|
||||
red numbers can end up side by side once found.
|
||||
|
||||
## Cloth
|
||||
|
||||
The published scenario puts cloth villages between the islands and pays cloth when a village's number is rolled.
|
||||
This implementation is the author's reading of that, not a transcription of the printed rules:
|
||||
|
||||
* A **village** is a land hex with a number token and a stock of cloth (6). It produces no cards and nothing may
|
||||
be built on it, but its corners can hold settlements like any other hex's.
|
||||
* When its number rolls, the village pays **one cloth token** to each settlement or city beside it (a city is
|
||||
worth no more than a settlement here), until the stock is gone. If the stock is short, the seats nearest the
|
||||
roller, in turn order, take what is left. The robber on a village stops it; the pirate does not.
|
||||
* **Two cloth are a victory point** (an odd token is worth nothing alone). Tokens are scored, not spent, and not
|
||||
tradeable.
|
||||
* Works with Cities & Knights: the village pays after the dice, before the aqueduct and gold.
|
||||
|
||||
## The Forgotten Tribe
|
||||
|
||||
Also the author's reading. A chip lies face up on four hexes of the small isles (the hexes are marked on the
|
||||
board). The chips are dealt at the start of the game, one of each kind in turn:
|
||||
|
||||
* **The first settlement** built on a crossing beside a chip hex takes that chip; it is gone for everyone else.
|
||||
(The setup settlements are never beside one, since the home island is the only start.)
|
||||
* A **point** chip is one victory point. A **harbour** chip lets that seat trade 3:1 at the bank, wherever it is.
|
||||
A **gold** chip pays two resources of the settler's choice, taken at once like gold.
|
||||
* The chips come on top of the island bonus for being first to a new island.
|
||||
|
||||
## Pirate Islands
|
||||
|
||||
The author's reading of the scenario; the printed one uses knights and a roaming pirate fleet, which this does not.
|
||||
|
||||
* A **fortress** is a land hex with no number and a strength of 3. Nobody may build a settlement on any crossing of
|
||||
an island that still has a standing fortress. Roads and ships may go there.
|
||||
* A seat with a **ship beside** a fortress may **raid** it in its turn for 1 ore, 1 wool and 1 grain, as many times as
|
||||
it can pay. Each raid takes one point of strength.
|
||||
* The raid that takes the last point **takes the fortress**: the hex becomes desert (no number, robber may sit there),
|
||||
the island is open to settle for everyone, and the taker gets **2 victory points**. The robber never sits on a
|
||||
standing fortress.
|
||||
* The island still counts as a new island for the first-landing bonus.
|
||||
|
||||
## Wonders
|
||||
|
||||
Also the author's reading. Every seat may build one wonder in four stages, once it has a **city** anywhere on the
|
||||
board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain; 2: 1 brick, 2 ore, 1 wool; 3: 3 ore,
|
||||
1 grain, 1 lumber; 4: 3 ore, 2 grain, 1 brick). Each stage built is **one victory point**, and the seat that
|
||||
builds the fourth stage **wins at once**, whatever the point target. Wonders are not shared: each seat has its own.
|
||||
|
||||
## Catan Universe extras (inferred)
|
||||
|
||||
These three exist in the Catan Universe app but not in the physical set. Only their names, menu texts and log
|
||||
lines could be read (`docs/RE-catan-universe.md`); the rule values are compiled code that was not recovered. **Every
|
||||
rule below is inferred from those names and texts**, not copied.
|
||||
|
||||
* **Treasure Islands.** The app's text: "Treasures await the Catanians on neighboring, fog-covered islands.
|
||||
Discoveries are rewarded!" Its chest rewards (log texts and action names) are bricks, wool and grain; a resource of
|
||||
choice; a development card; two roads. Here: up to five chests are dealt face down to random fog hexes at the
|
||||
start. A seat that uncovers the hex (the fog rules) opens the chest at once and takes: *a brick, a wool and a grain*;
|
||||
*one resource of its choice* (a gold pick); *a development card* (a resource of choice if the deck is empty or it is
|
||||
Cities & Knights, which has no deck); or *two lumber and two brick* standing in for two free roads (the free-roads
|
||||
phase would have to nest inside a ship move). The app's Cities & Knights version gives a progress card; that is
|
||||
not modelled. The app's fixed treasure bonus (`SEAFARER_TREASURE_BONUS`) is not modelled either.
|
||||
* **The Oases.** The app's text: an enormous desert with rumoured oases and treasures, base-game rules. Here: the
|
||||
oases are fog hexes inside the desert, uncovered by a road reaching them, and hold the same chests as above.
|
||||
There is no ship rule on the map itself.
|
||||
* **The Great Canal.** The app's text, for Cities & Knights plus Seafarers: build a canal; first to 21 (3 players) or
|
||||
18 (4 players) wins; the canal's parts earn victory points; when a tile is dug out, grain grows there. Its knights dig
|
||||
in the app; here digging is cheaper to implement and play without Cities & Knights: a canal tile is a desert hex with
|
||||
its number token hidden. A seat with a settlement or city on a corner of the tile digs one **unit** for 1 brick and
|
||||
1 ore; **three units** finish the tile, which becomes **fields** with the hidden number. Every **two units** a seat
|
||||
has dug are a **victory point**. The app's gold river, harbour changes and target scores are not modelled.
|
||||
|
||||
## With Cities & Knights
|
||||
|
||||
Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map
|
||||
@@ -107,13 +190,17 @@ Where the two meet, this game decides as follows:
|
||||
## Not included
|
||||
|
||||
The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**:
|
||||
treasure islands, the oasis, the great canal, the Explorers & Pirates combination,
|
||||
the scenarios that need new rules (cloth, wonders, the forgotten tribe, pirate islands) and trade ships.
|
||||
the Explorers & Pirates combination,
|
||||
the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships.
|
||||
Drought and the app's other scenarios (Six Islands, Oceania, Great Catan, Enchanted Land, the Inka and Big Game sets)
|
||||
are not built.
|
||||
A map-only scenario is a new text map and a new `SeaScenario` entry.
|
||||
|
||||
## How the AI plays it
|
||||
|
||||
It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its
|
||||
open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that
|
||||
bonus is in play. It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
|
||||
bonus is in play. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is
|
||||
worth more (`CatanAi.scenarioLure`), a ship that heads for a standing fortress is worth building, and it raids and builds
|
||||
wonder stages whenever it can pay (`CatanAi.FORT_LURE`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
|
||||
not sail ships it has already launched. See `CatanAi.shipValue`.
|
||||
|
||||
Reference in New Issue
Block a user