Seafarers: Pirate Islands and Wonders of Catan (phase 5b) #38
@@ -277,6 +277,10 @@ 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<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 }
|
||||
@@ -661,6 +665,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
|
||||
@@ -693,6 +702,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -214,6 +214,9 @@ internal class CatanCkAi(
|
||||
settlements.maxByOrNull { ai.spotValue(state, it.vertex) }?.let { return it }
|
||||
}
|
||||
|
||||
legal.filterIsInstance<RaidFortress>().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 }
|
||||
|
||||
@@ -63,6 +63,12 @@ enum class Terrain(val produces: Resource?) {
|
||||
|
||||
/** 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]. */
|
||||
@@ -495,6 +501,12 @@ data class SeaState(
|
||||
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(),
|
||||
)
|
||||
|
||||
/** What the first settler beside a tribe hex takes (Forgotten Tribe). */
|
||||
@@ -541,3 +553,13 @@ 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
|
||||
|
||||
@@ -124,6 +124,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 },
|
||||
villageCloth = it.villageCloth,
|
||||
chips = it.chips,
|
||||
fortress = it.fortress,
|
||||
wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -251,7 +253,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 }
|
||||
@@ -283,6 +285,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) {
|
||||
@@ -417,6 +420,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 }
|
||||
|
||||
/**
|
||||
@@ -470,6 +474,8 @@ 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)
|
||||
BuildWonder -> SeaRules.applyWonder(state, seat)
|
||||
is BuildRoad -> applyBuildRoad(state, seat, move)
|
||||
is BuildSettlement -> applyBuildSettlement(state, seat, move)
|
||||
is BuildCity -> applyBuildCity(state, seat, move)
|
||||
|
||||
@@ -24,6 +24,16 @@ 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.",
|
||||
),
|
||||
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; " +
|
||||
@@ -126,11 +136,36 @@ object SeaBoards {
|
||||
"............",
|
||||
)
|
||||
|
||||
/** `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.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
|
||||
}
|
||||
@@ -178,6 +213,8 @@ object SeaBoards {
|
||||
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(),
|
||||
)
|
||||
|
||||
/** An island of this many land hexes or more can be started on. */
|
||||
@@ -186,6 +223,9 @@ object SeaBoards {
|
||||
/** Cloth each village starts with. */
|
||||
const val VILLAGE_CLOTH = 6
|
||||
|
||||
/** Ships' raids a pirate fortress takes to fall. */
|
||||
const val FORTRESS_STRENGTH = 3
|
||||
|
||||
private val RESOURCE_TILES = listOf(
|
||||
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
|
||||
)
|
||||
@@ -209,13 +249,14 @@ object SeaBoards {
|
||||
'D' -> Terrain.DESERT
|
||||
'#', 't' -> pool[plain.indexOf(h)]
|
||||
'V' -> Terrain.VILLAGE
|
||||
'P' -> Terrain.FORTRESS
|
||||
'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 }
|
||||
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)
|
||||
@@ -287,6 +328,12 @@ object SeaBoards {
|
||||
List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 }
|
||||
}
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages)
|
||||
val fortress = if (kinds.none { it == 'P' }) {
|
||||
emptyList()
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,8 @@ internal object SeaRules {
|
||||
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)
|
||||
}
|
||||
@@ -252,6 +254,101 @@ internal object SeaRules {
|
||||
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]) }
|
||||
}
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -170,6 +170,7 @@ private fun DrawScope.drawHexes(
|
||||
val sea = state.sea
|
||||
val note = when {
|
||||
state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth"
|
||||
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"
|
||||
|
||||
@@ -173,6 +173,8 @@ object CatanHudGlyphs {
|
||||
BuildKind.WALL -> "▦"
|
||||
BuildKind.KNIGHT -> "♞"
|
||||
BuildKind.SHIP -> "⛵"
|
||||
BuildKind.RAID -> "⚔"
|
||||
BuildKind.WONDER -> "✦"
|
||||
}
|
||||
|
||||
fun label(kind: BuildKind): String = when (kind) {
|
||||
@@ -182,6 +184,8 @@ object CatanHudGlyphs {
|
||||
BuildKind.WALL -> CatanText.WALL
|
||||
BuildKind.KNIGHT -> CatanText.KNIGHT
|
||||
BuildKind.SHIP -> CatanText.SHIP
|
||||
BuildKind.RAID -> CatanText.RAID
|
||||
BuildKind.WONDER -> CatanText.WONDER
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ 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.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 +69,34 @@ 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,
|
||||
|
||||
/** 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.wonders.isNotEmpty()) add(WONDER)
|
||||
}
|
||||
val usual = when {
|
||||
state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, WONDER)
|
||||
state.isSea -> SEAFARERS
|
||||
else -> BASE
|
||||
}
|
||||
return usual + extras
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +275,10 @@ 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.WONDER -> BoardTargets()
|
||||
BuildKind.SETTLEMENT ->
|
||||
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
|
||||
|
||||
@@ -519,6 +546,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) }
|
||||
return when {
|
||||
moves.isNotEmpty() -> if (moves.size == 1) {
|
||||
TapOutcome.Submit(moves.first())
|
||||
@@ -586,6 +614,8 @@ 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 BuildWonder }) add(BuildKind.WONDER)
|
||||
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
|
||||
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
|
||||
}
|
||||
|
||||
@@ -600,6 +600,8 @@ 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.WONDER -> CatanText.WONDER
|
||||
}
|
||||
|
||||
private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||
@@ -609,6 +611,8 @@ 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.WONDER -> CatanText.WONDER_COST
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -642,6 +646,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.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 },
|
||||
|
||||
@@ -128,6 +128,8 @@ private val LightStyle = CatanStyle(
|
||||
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),
|
||||
|
||||
@@ -76,6 +76,7 @@ object CatanText {
|
||||
Terrain.GOLD -> "Gold field"
|
||||
Terrain.FOG -> "Fog"
|
||||
Terrain.VILLAGE -> "Village"
|
||||
Terrain.FORTRESS -> "Pirate fortress"
|
||||
}
|
||||
|
||||
fun devCard(type: DevCardType): String = when (type) {
|
||||
@@ -115,6 +116,10 @@ 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 WONDER = "Wonder"
|
||||
const val WONDER_COST = "next stage"
|
||||
const val GOLD_TITLE = "Gold field"
|
||||
const val GOLD_TAKE = "Take"
|
||||
const val ROLL = "Roll dice"
|
||||
@@ -248,6 +253,7 @@ 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.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."
|
||||
@@ -452,6 +458,7 @@ 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.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."
|
||||
|
||||
@@ -165,4 +165,80 @@ class CatanSeaExtrasTest {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ 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(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))
|
||||
}
|
||||
@@ -57,11 +59,12 @@ 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)
|
||||
}
|
||||
val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN &&
|
||||
scenario != SeaScenario.FORGOTTEN_TRIBE
|
||||
scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_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 }
|
||||
@@ -214,6 +217,8 @@ class CatanSeaScenariosTest {
|
||||
var fogFound = 0
|
||||
var clothPaid = 0
|
||||
var chipsTaken = 0
|
||||
var fortsTaken = 0
|
||||
var wonderStages = 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)
|
||||
@@ -232,6 +237,8 @@ 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.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum()
|
||||
if (scenario == SeaScenario.FORGOTTEN_TRIBE) {
|
||||
chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE }
|
||||
}
|
||||
@@ -241,5 +248,7 @@ class CatanSeaScenariosTest {
|
||||
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 wonder stage was ever built", wonderStages > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ Pick the map at setup; each scenario names where the starting settlements may go
|
||||
* **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.
|
||||
|
||||
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,
|
||||
@@ -115,6 +119,26 @@ board). The chips are dealt at the start of the game, one of each kind in turn:
|
||||
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.
|
||||
|
||||
## With Cities & Knights
|
||||
|
||||
Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map
|
||||
@@ -138,7 +162,7 @@ Where the two meet, this game decides as follows:
|
||||
|
||||
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 (wonders, pirate islands) and trade ships.
|
||||
the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships.
|
||||
A map-only scenario is a new text map and a new `SeaScenario` entry.
|
||||
|
||||
## How the AI plays it
|
||||
@@ -146,5 +170,6 @@ A map-only scenario is a new text map and a new `SeaScenario` entry.
|
||||
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. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is
|
||||
worth more (`CatanAi.scenarioLure`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
|
||||
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