Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3deb764905 | ||
|
|
da772bd326 | ||
|
|
dc73a71993 | ||
|
|
42334c3bf8 | ||
|
|
421cbd4c70 | ||
|
|
cd220396f9 | ||
|
|
af279d3592 |
@@ -279,6 +279,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
|
||||
// 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.
|
||||
@@ -598,6 +599,10 @@ 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 {
|
||||
// The Harbormaster: a town on a harbour is a point toward a title worth two.
|
||||
if (CatanRules.harbormasterGame(state) &&
|
||||
state.topo.VERTEX_EDGES[vertex].any { e -> state.ports.any { it.edge == e } }
|
||||
) return HARBOR_LURE
|
||||
val sea = state.sea ?: return 0.0
|
||||
var lure = 0.0
|
||||
for (h in state.topo.VERTEX_HEXES[vertex]) {
|
||||
@@ -605,6 +610,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
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
|
||||
@@ -705,5 +712,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
const val FORT_LURE = 1.2
|
||||
const val CLOTH_WORTH = 2.5
|
||||
const val CHIP_LURE = 0.5
|
||||
const val CANAL_LURE = 0.6
|
||||
const val HARBOR_LURE = 0.5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,9 @@ object CatanBoard {
|
||||
* tokens go to four of the six board-corner hexes, which are pairwise
|
||||
* non-adjacent, and at most one corner can be desert.
|
||||
*/
|
||||
/** The seed of the First Island scenario's fixed layout. */
|
||||
const val FIRST_ISLAND_SEED = 1L
|
||||
|
||||
fun generate(seed: Long): Generated {
|
||||
val random = Random(seed)
|
||||
val terrains = TERRAIN_TILES.shuffled(random)
|
||||
|
||||
@@ -255,6 +255,7 @@ object CatanCk {
|
||||
points[seat] += ck.defenderVp[seat] + ck.progressVp[seat]
|
||||
}
|
||||
ck.merchant?.let { points[it.seat] += 1 }
|
||||
state.harbormaster?.let { points[it] += CatanRules.HARBORMASTER_POINTS }
|
||||
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
|
||||
return points.toList()
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ internal class CatanCkAi(
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -71,6 +71,21 @@ enum class Terrain(val produces: Resource?) {
|
||||
FORTRESS(null),
|
||||
}
|
||||
|
||||
/** Base-island scenarios: the board and goals of a game without Seafarers. Seafarers games choose a [SeaScenario] instead. */
|
||||
@Serializable
|
||||
enum class BaseScenario(val title: String, val blurb: String) {
|
||||
RANDOM_MAP("Random map", "The usual island, its tiles and numbers shuffled for every game."),
|
||||
FIRST_ISLAND(
|
||||
"First Island",
|
||||
"The same fixed island every game, so the set-up is not a surprise: good for a first game or a rematch.",
|
||||
),
|
||||
HARBORMASTER(
|
||||
"The Harbormaster",
|
||||
"Harbours matter. A settlement on a harbour counts a point and a city two; the first to three has the " +
|
||||
"Harbormaster title, worth 2 victory points, and whoever gets more takes it.",
|
||||
),
|
||||
}
|
||||
|
||||
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||
@Serializable
|
||||
data class HexCoord(val q: Int, val r: Int)
|
||||
@@ -197,6 +212,7 @@ data class CatanOptions(
|
||||
val pointsToWin: Int = 10,
|
||||
val expansions: Set<CatanExpansion> = emptySet(),
|
||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
|
||||
) {
|
||||
init {
|
||||
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||
@@ -214,6 +230,7 @@ private class CatanOptionsWire(
|
||||
val pointsToWin: Int = 10,
|
||||
val expansions: Set<CatanExpansion> = emptySet(),
|
||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
|
||||
)
|
||||
|
||||
/** Reads the legacy single `ruleSet` field as an expansion set. */
|
||||
@@ -222,7 +239,7 @@ object CatanOptionsSerializer : KSerializer<CatanOptions> {
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
|
||||
CatanOptionsWire.serializer(),
|
||||
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario),
|
||||
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario, value.baseScenario),
|
||||
)
|
||||
|
||||
override fun deserialize(decoder: Decoder): CatanOptions {
|
||||
@@ -234,7 +251,7 @@ object CatanOptionsSerializer : KSerializer<CatanOptions> {
|
||||
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
|
||||
}
|
||||
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
|
||||
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario)
|
||||
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario, wire.baseScenario)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +323,8 @@ data class CatanState(
|
||||
val log: List<String>,
|
||||
/** Everything Cities & Knights adds; null in the base game. */
|
||||
val ck: CkState? = null,
|
||||
/** The Harbormaster scenario: the seat holding the title, or null. */
|
||||
val harbormaster: Int? = null,
|
||||
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
|
||||
val sea: SeaState? = null,
|
||||
) {
|
||||
@@ -507,8 +526,34 @@ data class SeaState(
|
||||
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 {
|
||||
@@ -563,3 +608,8 @@ data class RaidFortress(val hex: HexCoord) : CatanMove
|
||||
@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
|
||||
|
||||
@@ -59,7 +59,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
|
||||
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
|
||||
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
|
||||
?: CatanBoard.generate(config.seed)
|
||||
?: CatanBoard.generate(
|
||||
if (options.baseScenario == BaseScenario.FIRST_ISLAND) CatanBoard.FIRST_ISLAND_SEED else config.seed,
|
||||
)
|
||||
// A different random stream than the island, so the same seed still
|
||||
// produces both a fixed island and a fixed deck.
|
||||
val ck = options.citiesAndKnights
|
||||
@@ -125,6 +127,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
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(),
|
||||
)
|
||||
},
|
||||
@@ -475,6 +481,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
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)
|
||||
@@ -527,7 +534,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
}
|
||||
|
||||
val newIndex = state.setupIndex + 1
|
||||
s = s.copy(setupIndex = newIndex)
|
||||
s = refreshHarbormaster(s.copy(setupIndex = newIndex))
|
||||
return when {
|
||||
newIndex >= 2 * n -> s.copy(
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
@@ -1018,16 +1025,64 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
}
|
||||
state.longestRoadOwner?.let { points[it] += 5 }
|
||||
state.largestArmyOwner?.let { points[it] += 5 }
|
||||
state.harbormaster?.let { points[it] += HARBORMASTER_POINTS }
|
||||
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
|
||||
return points.toList()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The Harbormaster
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The scenario's title: points for a harbour town and for the Harbormaster. */
|
||||
const val HARBORMASTER_MINIMUM = 3
|
||||
const val HARBORMASTER_POINTS = 2
|
||||
|
||||
/** Whether this table plays the Harbormaster (never in a Seafarers game, which has its own maps). */
|
||||
internal fun harbormasterGame(state: CatanState): Boolean =
|
||||
state.options.baseScenario == BaseScenario.HARBORMASTER && !state.isSea
|
||||
|
||||
/** Harbour points: a settlement on a harbour counts 1, a city 2, for each harbour it touches. */
|
||||
fun harbourPoints(state: CatanState, seat: Int): Int {
|
||||
var total = 0
|
||||
for (port in state.ports) {
|
||||
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
|
||||
val own = listOfNotNull(state.buildings[a], state.buildings[b]).firstOrNull { it.owner == seat }
|
||||
if (own != null) total += if (own.type == BuildingType.CITY) 2 else 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Gives the title to the first seat with three harbour points, and to a seat with strictly more than the holder. */
|
||||
internal fun refreshHarbormaster(state: CatanState): CatanState {
|
||||
if (!harbormasterGame(state)) return state
|
||||
val points = List(state.playerCount) { harbourPoints(state, it) }
|
||||
val holder = state.harbormaster
|
||||
val best = points.max()
|
||||
val leaders = points.indices.filter { points[it] == best }
|
||||
val next = when {
|
||||
holder == null -> leaders.singleOrNull()?.takeIf { best >= HARBORMASTER_MINIMUM }
|
||||
best > points[holder] -> leaders.singleOrNull() ?: holder
|
||||
else -> holder
|
||||
}
|
||||
if (next == holder) return state
|
||||
return state.copy(
|
||||
harbormaster = next,
|
||||
log = state.log + if (holder == null) {
|
||||
"Seat $next is now harbormaster."
|
||||
} else {
|
||||
"Seat $next has taken the title harbormaster from seat $holder."
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun victoryPoints(state: CatanState, seat: Int): Int =
|
||||
publicVictoryPoints(state)[seat] +
|
||||
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
||||
|
||||
internal fun checkWin(state: CatanState): CatanState {
|
||||
if (state.phase == CatanPhase.GAME_OVER) return state
|
||||
internal fun checkWin(settled: CatanState): CatanState {
|
||||
if (settled.phase == CatanPhase.GAME_OVER) return settled
|
||||
val state = refreshHarbormaster(settled)
|
||||
val seat = state.turn
|
||||
val points = victoryPoints(state, seat)
|
||||
if (points < state.options.pointsToWin) return state
|
||||
@@ -1226,7 +1281,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,21 @@ 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; " +
|
||||
@@ -136,6 +151,39 @@ object SeaBoards {
|
||||
"............",
|
||||
)
|
||||
|
||||
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(
|
||||
"............",
|
||||
@@ -164,6 +212,9 @@ object SeaBoards {
|
||||
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
|
||||
@@ -215,6 +266,10 @@ object SeaBoards {
|
||||
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. */
|
||||
@@ -226,6 +281,9 @@ object SeaBoards {
|
||||
/** 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,
|
||||
)
|
||||
@@ -250,13 +308,14 @@ object SeaBoards {
|
||||
'#', '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 && terrains[it] != Terrain.FORTRESS }
|
||||
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)
|
||||
@@ -294,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.
|
||||
@@ -334,6 +395,24 @@ object SeaBoards {
|
||||
List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 }
|
||||
}
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress)
|
||||
// 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,11 +182,55 @@ 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
|
||||
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!!
|
||||
@@ -208,7 +252,51 @@ internal object SeaRules {
|
||||
(sea.raidPoints.getOrNull(seat) ?: 0) +
|
||||
(sea.wonders.getOrNull(seat) ?: 0) +
|
||||
clothOf(state, seat) / CLOTH_PER_POINT +
|
||||
(sea.chipPoints.getOrNull(seat) ?: 0)
|
||||
(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)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -328,6 +416,9 @@ internal object SeaRules {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
import org.prolibertate.games.game.catan.CatanExpansion
|
||||
import org.prolibertate.games.game.catan.SeaScenario
|
||||
import org.prolibertate.games.game.catan.BaseScenario
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
import org.prolibertate.games.game.tayu.TayuOptions
|
||||
@@ -442,6 +443,15 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
|
||||
onSelect = { onChange(options.copy(scenario = it)) },
|
||||
)
|
||||
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
ChipRow(
|
||||
label = "Island",
|
||||
values = BaseScenario.entries,
|
||||
selected = options.baseScenario,
|
||||
display = { it.title },
|
||||
onSelect = { onChange(options.copy(baseScenario = it)) },
|
||||
)
|
||||
Text(options.baseScenario.blurb, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
|
||||
@@ -33,6 +33,7 @@ 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
|
||||
|
||||
@@ -170,6 +171,8 @@ private fun DrawScope.drawHexes(
|
||||
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"
|
||||
|
||||
@@ -174,6 +174,7 @@ object CatanHudGlyphs {
|
||||
BuildKind.KNIGHT -> "♞"
|
||||
BuildKind.SHIP -> "⛵"
|
||||
BuildKind.RAID -> "⚔"
|
||||
BuildKind.DIG -> "⛏"
|
||||
BuildKind.WONDER -> "✦"
|
||||
}
|
||||
|
||||
@@ -185,6 +186,7 @@ object CatanHudGlyphs {
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
BuildKind.RAID -> CatanText.RAID
|
||||
BuildKind.DIG -> CatanText.DIG
|
||||
BuildKind.WONDER -> CatanText.WONDER
|
||||
}
|
||||
}
|
||||
@@ -212,7 +214,7 @@ fun HudConfirmButtons(
|
||||
fun ZoomPanBox(
|
||||
modifier: Modifier = Modifier,
|
||||
minScale: Float = 1f,
|
||||
maxScale: Float = 3f,
|
||||
maxScale: Float = 6f,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -74,6 +75,9 @@ enum class BuildKind {
|
||||
/** 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;
|
||||
|
||||
@@ -88,10 +92,11 @@ enum class 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, WONDER)
|
||||
state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, DIG, WONDER)
|
||||
state.isSea -> SEAFARERS
|
||||
else -> BASE
|
||||
}
|
||||
@@ -278,6 +283,9 @@ object CatanInteraction {
|
||||
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())
|
||||
@@ -546,7 +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 }?.let { return TapOutcome.Submit(it) }
|
||||
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())
|
||||
@@ -615,6 +623,7 @@ object CatanInteraction {
|
||||
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")
|
||||
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
|
||||
addAll(seaAwards(state, seat, compact))
|
||||
}
|
||||
CardLine(
|
||||
@@ -281,6 +282,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")
|
||||
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
|
||||
addAll(seaAwards(state, seat, compact))
|
||||
}
|
||||
// Always drawn, so the card does not grow when somebody takes an award.
|
||||
@@ -601,6 +603,7 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
BuildKind.RAID -> CatanText.RAID
|
||||
BuildKind.DIG -> CatanText.DIG
|
||||
BuildKind.WONDER -> CatanText.WONDER
|
||||
}
|
||||
|
||||
@@ -612,6 +615,7 @@ private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -646,6 +650,7 @@ private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List<Stri
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -379,7 +379,8 @@ fun CatanScreen(
|
||||
players(false)
|
||||
if (current.isCk) CatanBarbarians(current, style = style)
|
||||
promptRow()
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
// Pinch to zoom and drag to pan, as in landscape: a wide Seafarers map is small at fit.
|
||||
ZoomPanBox(Modifier.weight(1f).fillMaxWidth()) {
|
||||
board(Modifier)
|
||||
}
|
||||
handAndActions()
|
||||
|
||||
@@ -118,6 +118,8 @@ object CatanText {
|
||||
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"
|
||||
@@ -254,6 +256,7 @@ object CatanText {
|
||||
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."
|
||||
@@ -459,6 +462,7 @@ object CatanText {
|
||||
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,115 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
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 base-island scenarios: First Island's fixed board and the Harbormaster. */
|
||||
class CatanBaseScenarioTest {
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
private fun game(
|
||||
scenario: BaseScenario,
|
||||
seed: Long = 5L,
|
||||
expansions: Set<CatanExpansion> = emptySet(),
|
||||
players: Int = 3,
|
||||
) = CatanRules.initialState(config(CatanOptions(players, 10, expansions, baseScenario = scenario), seed))
|
||||
|
||||
@Test
|
||||
fun `first island is the same board for every seed and the random map is not`() {
|
||||
val a = game(BaseScenario.FIRST_ISLAND, 1L)
|
||||
val b = game(BaseScenario.FIRST_ISLAND, 99L)
|
||||
assertEquals(a.terrains, b.terrains)
|
||||
assertEquals(a.numbers, b.numbers)
|
||||
assertEquals(a.ports, b.ports)
|
||||
assertNotEquals(game(BaseScenario.RANDOM_MAP, 1L).terrains, game(BaseScenario.RANDOM_MAP, 99L).terrains)
|
||||
// The deck and the seating still follow the table's own seed.
|
||||
assertNotEquals(a.devDeck, b.devDeck)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save from before base scenarios reads as a random map`() {
|
||||
val old = """{"playerCount":3,"pointsToWin":10,"expansions":[],"scenario":"NEW_SHORES"}"""
|
||||
assertEquals(BaseScenario.RANDOM_MAP, json.decodeFromString<CatanOptions>(old).baseScenario)
|
||||
val options = CatanOptions(3, 10, baseScenario = BaseScenario.HARBORMASTER)
|
||||
assertEquals(options, json.decodeFromString<CatanOptions>(json.encodeToString(options)))
|
||||
}
|
||||
|
||||
/** Vertices of the harbours, one per harbour, in board order. */
|
||||
private fun CatanState.harbourVertices(): List<Int> = ports.map { topo.EDGE_VERTICES[it.edge].first }
|
||||
|
||||
private fun CatanState.with(seat: Int, vertex: Int, type: BuildingType) =
|
||||
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) })
|
||||
|
||||
@Test
|
||||
fun `three harbour points make a harbormaster and more takes the title`() {
|
||||
var s = game(BaseScenario.HARBORMASTER).copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
|
||||
val v = s.harbourVertices()
|
||||
s = s.with(0, v[0], BuildingType.SETTLEMENT).with(0, v[1], BuildingType.SETTLEMENT)
|
||||
assertEquals(2, CatanRules.harbourPoints(s, 0))
|
||||
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
|
||||
|
||||
// A city counts two: 1 + 2 = 3.
|
||||
s = CatanRules.refreshHarbormaster(s.with(0, v[1], BuildingType.CITY))
|
||||
assertEquals(3, CatanRules.harbourPoints(s, 0))
|
||||
assertEquals(0, s.harbormaster)
|
||||
assertEquals(CatanRules.publicVictoryPoints(s.copy(harbormaster = null))[0] + 2, CatanRules.publicVictoryPoints(s)[0])
|
||||
|
||||
// Equal points do not take it; strictly more does.
|
||||
s = CatanRules.refreshHarbormaster(
|
||||
s.with(1, v[2], BuildingType.SETTLEMENT).with(1, v[3], BuildingType.SETTLEMENT).with(1, v[4], BuildingType.SETTLEMENT),
|
||||
)
|
||||
assertEquals(0, s.harbormaster)
|
||||
s = CatanRules.refreshHarbormaster(s.with(1, v[5], BuildingType.SETTLEMENT))
|
||||
assertEquals(1, s.harbormaster)
|
||||
assertTrue(s.log.any { "taken the title harbormaster from seat 0" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title does not exist in other scenarios or in a Seafarers game`() {
|
||||
var s = game(BaseScenario.RANDOM_MAP).copy(phase = CatanPhase.PLAYING)
|
||||
val v = s.harbourVertices()
|
||||
for (i in 0..3) s = s.with(0, v[i], BuildingType.SETTLEMENT)
|
||||
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engines finish games on the base scenarios, with and without Cities and Knights`() {
|
||||
var titles = 0
|
||||
for (scenario in listOf(BaseScenario.FIRST_ISLAND, BaseScenario.HARBORMASTER)) {
|
||||
for (expansions in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
|
||||
for ((players, seed) in listOf(3 to 31L, 4 to 32L)) {
|
||||
val opts = CatanOptions(players, if (expansions.isEmpty()) 10 else 13, expansions, baseScenario = scenario)
|
||||
var state = CatanRules.initialState(config(opts, seed))
|
||||
val ai = CatanAi()
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("$scenario $expansions seed $seed ran long", moves++ < 20_000)
|
||||
val seat = CatanRules.currentSeat(state)!!
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||
}
|
||||
if (state.harbormaster != null) titles++
|
||||
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue("nobody ever became harbormaster", titles > 0)
|
||||
}
|
||||
}
|
||||
@@ -241,4 +241,96 @@ class CatanSeaExtrasTest {
|
||||
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,9 @@ 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))
|
||||
@@ -64,7 +67,8 @@ class CatanSeaScenariosTest {
|
||||
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
|
||||
}
|
||||
val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN &&
|
||||
scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS
|
||||
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 }
|
||||
@@ -219,6 +223,8 @@ class CatanSeaScenariosTest {
|
||||
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)
|
||||
@@ -238,6 +244,10 @@ class CatanSeaScenariosTest {
|
||||
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 }
|
||||
@@ -249,6 +259,8 @@ class CatanSeaScenariosTest {
|
||||
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
|
||||
|
||||
@@ -36,6 +36,11 @@ Pick the map at setup; each scenario names where the starting settlements may go
|
||||
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,
|
||||
@@ -139,6 +144,30 @@ board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain
|
||||
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
|
||||
@@ -161,8 +190,10 @@ 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 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
|
||||
|
||||
@@ -23,6 +23,26 @@ A table plays one of two rule sets, chosen on the setup screen
|
||||
building, robber and trade rules and changes or replaces the rest; its state lives
|
||||
behind `CatanState.ck`, which is `null` in the base game.
|
||||
|
||||
## Base-island scenarios
|
||||
|
||||
Without Seafarers the setup screen offers an **Island** choice (`CatanOptions.baseScenario`; it works with Cities &
|
||||
Knights too). Both extras are the author's reading, not transcriptions of the printed scenarios:
|
||||
|
||||
- **Random map** (default) — the usual shuffled island.
|
||||
- **First Island** — the same island every game: one fixed shuffle (`CatanBoard.FIRST_ISLAND_SEED`) of the standard
|
||||
tiles, numbers and harbours, so a first game or a rematch has no surprise set-up. It is not the printed beginner
|
||||
layout. The development deck and the seating still follow the table's own seed.
|
||||
- **The Harbormaster** — the Catan Universe menu text is "Because trade becomes more and more important, harbours
|
||||
have an ever increasing significance." Its card: 2 victory points to the first player with at least three harbour
|
||||
points; another player with more takes it at once. What a harbour point is was not in the app's texts, so it is
|
||||
inferred: a settlement on a harbour counts **1**, a city **2**, for each harbour it touches. The title is checked after
|
||||
every settlement, city and set-up placement; **equal points do not take it**, only strictly more (like Longest
|
||||
Road); it is **+2 victory points** to the holder (`CatanState.harbormaster`). The AI values a harbour spot a
|
||||
little more here (`CatanAi.HARBOR_LURE`).
|
||||
|
||||
Seafarers games choose a map instead (see [RULES-catan-seafarers.md](RULES-catan-seafarers.md)); the Harbormaster does not apply there.
|
||||
The app's other base scenario, Ore for Wool, is not built: its rules are not in the texts that could be read.
|
||||
|
||||
## Table
|
||||
|
||||
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).
|
||||
|
||||
Reference in New Issue
Block a user