Seafarers: Cloth for Catan and The Forgotten Tribe (phase 5a) #37

Merged
devadmin merged 1 commits from feature/catan-cloth-tribe into dev 2026-11-11 17:05:22 -06:00
13 changed files with 465 additions and 14 deletions
@@ -592,6 +592,20 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
// -----------------------------------------------------------------------
/** What a seat would think of building on [vertex]: production and ports. */
/** What a spot beside a cloth village or an unclaimed tribe chip is worth beyond its cards. */
private fun scenarioLure(state: CatanState, vertex: Int): Double {
val sea = state.sea ?: return 0.0
var lure = 0.0
for (h in state.topo.VERTEX_HEXES[vertex]) {
// A cloth token is a quarter-point, rolled as often as its number comes up.
if (state.terrains[h] == Terrain.VILLAGE && sea.villageCloth.getOrElse(h) { 0 } > 0) {
lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH
}
if (sea.chips.getOrNull(h).let { it != null && it != TribeChip.NONE }) lure += CHIP_LURE
}
return lure
}
internal fun spotValue(state: CatanState, vertex: Int): Double {
if (state.buildings[vertex] != null) return -1.0
var value = 0.0
@@ -602,6 +616,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
kinds += resource
}
value += 0.12 * kinds.size // variety keeps a build plan alive
value += scenarioLure(state, vertex)
for (e in state.topo.VERTEX_EDGES[vertex]) {
val port = state.ports.firstOrNull { it.edge == e } ?: continue
value += if (port.resource == null) 0.1 else 0.2
@@ -678,5 +693,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 CLOTH_WORTH = 2.5
const val CHIP_LURE = 0.5
}
}
@@ -1568,6 +1568,7 @@ object CatanCk {
}
val said = if (notes.isEmpty()) "nothing for anyone" else notes.joinToString(", ")
s = s.copy(log = s.log + "The dice pay: $said.")
if (state.isSea) s = SeaRules.payCloth(s, number)
// The aqueduct: a seat that was paid nothing may take a resource of its choice.
val order = (0 until s.playerCount).map { (s.turn + it) % s.playerCount }
@@ -60,6 +60,9 @@ enum class Terrain(val produces: Resource?) {
* (`SeaState.fogDeck`). Until then nothing is built on it, nothing produces, and the robber keeps off.
*/
FOG(null),
/** Seafarers, Cloth for Catan: a village. It pays cloth, not cards, to the settlements around it when its number rolls. */
VILLAGE(null),
}
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
@@ -482,8 +485,33 @@ data class SeaState(
val fogDeck: List<FogTile> = emptyList(),
/** Where the game goes when the gold queue empties: [CatanPhase.PLAYING] unless a find interrupted something else. */
val afterGold: CatanPhase? = null,
/** Cloth tokens each seat holds (Cloth for Catan); empty when the scenario has none. Two make a victory point. */
val cloth: List<Int> = emptyList(),
/** Cloth still on each hex's village, by hex (Cloth for Catan); empty when the scenario has none. */
val villageCloth: List<Int> = emptyList(),
/** The tribe chip waiting on each hex (Forgotten Tribe), by hex; empty when the scenario has none. */
val chips: List<TribeChip> = emptyList(),
/** Seats that took a harbour chip and so trade 3:1 anywhere. */
val harbourSeats: List<Int> = emptyList(),
/** Victory points each seat holds from tribe chips; empty until one is taken. */
val chipPoints: List<Int> = emptyList(),
)
/** What the first settler beside a tribe hex takes (Forgotten Tribe). */
@Serializable
enum class TribeChip {
NONE,
/** One victory point. */
POINT,
/** A 3:1 harbour, good wherever the seat trades. */
HARBOUR,
/** Two resources of the seat's choice. */
GOLD,
}
/** One tile of the fog: what the hex turns out to be and the number token that comes with it. */
@Serializable
data class FogTile(val terrain: Terrain, val number: Int)
@@ -121,6 +121,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
home = it.home,
starts = it.starts,
fogDeck = it.fogDeck,
cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 },
villageCloth = it.villageCloth,
chips = it.chips,
)
},
)
@@ -596,6 +599,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
s = s.copy(
log = s.log + "Seat ${state.turn} rolls ${state.lastDice.joinToString(" + ")} — $said.",
)
if (state.isSea) s = SeaRules.payCloth(s, number)
if (goldOwed.isEmpty()) return s
return SeaRules.settleGold(s.copy(sea = s.sea!!.copy(gold = goldOwed)))
}
@@ -743,7 +747,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
},
)
s = payCost(s, seat, SETTLEMENT_COST)
s = SeaRules.claimIsland(s, seat, move.vertex)
s = SeaRules.settled(s, seat, move.vertex)
// A settlement can break another player's road, so the award is
// recomputed for everyone.
s = refreshLongestRoad(s)
@@ -1184,6 +1188,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val mine = state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat
if (mine) best = port.ratio
}
if (best > 3 && state.sea?.harbourSeats?.contains(seat) == true) best = 3
return best
}
@@ -1194,7 +1199,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
}
return 4
return if (state.sea?.harbourSeats?.contains(seat) == true) 3 else 4
}
// -----------------------------------------------------------------------
@@ -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.",
),
CLOTH_FOR_CATAN(
"Cloth for Catan",
"Four villages weave cloth. Settle beside one and it pays you a cloth token whenever its number rolls; " +
"every two tokens are a victory point.",
),
FORGOTTEN_TRIBE(
"The Forgotten Tribe",
"Four small isles hold the chips of a lost tribe. The first to settle beside one takes it: " +
"a victory point, a harbour, or gold.",
),
}
/** Where the starting settlements may go. */
@@ -92,11 +102,37 @@ object SeaBoards {
"............",
)
/** `V` is a village; `t` is a resource hex that carries a tribe chip. */
private val CLOTH_FOR_CATAN_MAP = listOf(
"............",
"..V###..V##.",
"..#####..##.",
"..####V..#..",
"...###......",
".......##V..",
"............",
"...##.......",
"............",
)
private val FORGOTTEN_TRIBE_MAP = listOf(
"............",
"..###..t#...",
"..#####..#t.",
"..#####..#..",
"..####......",
"........t##.",
".#t.........",
"............",
)
private 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.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP
SeaScenario.FORGOTTEN_TRIBE -> FORGOTTEN_TRIBE_MAP
}
private val topologies = HashMap<SeaScenario, Topology>()
@@ -138,11 +174,18 @@ object SeaBoards {
val starts: List<Int> = listOf(home),
/** The tiles under the fog, in the order they will be found. */
val fogDeck: List<FogTile> = emptyList(),
/** The tribe chip on each hex, or [TribeChip.NONE]; empty when the map has none. */
val chips: List<TribeChip> = emptyList(),
/** Cloth waiting on each village, by hex; empty when the map has none. */
val villageCloth: List<Int> = emptyList(),
)
/** An island of this many land hexes or more can be started on. */
private const val MAIN_ISLAND_HEXES = 4
/** Cloth each village starts with. */
const val VILLAGE_CLOTH = 6
private val RESOURCE_TILES = listOf(
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
)
@@ -157,14 +200,15 @@ object SeaBoards {
val random = Random(seed)
// Resources fill the '#' hexes as evenly as the count allows, in shuffled order.
val plain = kinds.indices.filter { kinds[it] == '#' }
val plain = kinds.indices.filter { kinds[it] == '#' || kinds[it] == 't' }
val pool = List(plain.size) { RESOURCE_TILES[it % RESOURCE_TILES.size] }.shuffled(random)
val terrains = MutableList(kinds.size) { Terrain.SEA }
kinds.forEachIndexed { h, ch ->
terrains[h] = when (ch) {
'$' -> Terrain.GOLD
'D' -> Terrain.DESERT
'#' -> pool[plain.indexOf(h)]
'#', 't' -> pool[plain.indexOf(h)]
'V' -> Terrain.VILLAGE
'F' -> Terrain.FOG
else -> Terrain.SEA
}
@@ -228,6 +272,21 @@ object SeaBoards {
}.ifEmpty { listOf(home) }
}
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck)
// Tribe chips: one of each kind in turn, dealt to the marked hexes in random order.
val chipHexes = kinds.indices.filter { kinds[it] == 't' }
val chips = if (chipHexes.isEmpty()) {
emptyList()
} else {
val kindsOfChip = listOf(TribeChip.POINT, TribeChip.HARBOUR, TribeChip.GOLD)
val dealt = List(chipHexes.size) { kindsOfChip[it % kindsOfChip.size] }.shuffled(random)
List(terrains.size) { h -> chipHexes.indexOf(h).let { if (it < 0) TribeChip.NONE else dealt[it] } }
}
val villages = if (kinds.none { it == 'V' }) {
emptyList()
} else {
List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 }
}
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages)
}
}
@@ -184,18 +184,113 @@ internal object SeaRules {
}
}
if (owed.isEmpty()) return s
val merged = GoldOwed(seat, owed.sumOf { it.count })
val sea = s.sea!!
return settleGold(s.copy(sea = sea.copy(gold = sea.gold + merged, afterGold = s.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold)))
return owe(s, seat, owed.sumOf { it.count })
}
/** Queues [count] cards of [seat]'s choice, to be taken before play resumes where it left off. */
private fun owe(state: CatanState, seat: Int, count: Int): CatanState {
val sea = state.sea!!
val queued = sea.copy(
gold = sea.gold + GoldOwed(seat, count),
afterGold = state.phase.takeIf { it != CatanPhase.GOLD } ?: sea.afterGold,
)
return settleGold(state.copy(sea = queued))
}
// -----------------------------------------------------------------------
// Islands and scoring
// -----------------------------------------------------------------------
/** The bonus points [seat] holds for first landings. */
fun bonusPoints(state: CatanState, seat: Int): Int =
(state.sea?.islandBonus?.count { it == seat } ?: 0) * ISLAND_BONUS
/** The bonus points [seat] holds: first landings, tribe chips, and one for every two cloth. */
fun bonusPoints(state: CatanState, seat: Int): Int {
val sea = state.sea ?: return 0
return sea.islandBonus.count { it == seat } * ISLAND_BONUS +
clothOf(state, seat) / CLOTH_PER_POINT +
(sea.chipPoints.getOrNull(seat) ?: 0)
}
// -----------------------------------------------------------------------
// Cloth for Catan
// -----------------------------------------------------------------------
const val CLOTH_PER_POINT = 2
fun clothOf(state: CatanState, seat: Int): Int = state.sea?.cloth?.getOrNull(seat) ?: 0
/**
* After the dice pay: every village showing [number] pays one cloth to each settlement or city beside it
* (a city is not worth more here), until its stock runs out. A robber on the village stops it.
*/
fun payCloth(state: CatanState, number: Int): CatanState {
val sea = state.sea ?: return state
if (sea.villageCloth.isEmpty()) return state
var s = state
val topo = state.topo
for (h in 0 until topo.HEX_COUNT) {
if (state.terrains[h] != Terrain.VILLAGE || state.numbers[h] != number || h == state.robber) continue
val takers = topo.HEX_CORNERS[h].mapNotNull { state.buildings[it]?.owner }
if (takers.isEmpty()) continue
// Seats in turn order from the roller get the last of the stock.
val order = takers.sortedBy { (it - state.turn + state.playerCount) % state.playerCount }
val current = s.sea!!
var stock = current.villageCloth[h]
val cloth = current.cloth.toMutableList()
val said = mutableListOf<String>()
for (seat in order) {
if (stock == 0) break
cloth[seat] += 1
stock--
said += "seat $seat"
}
if (said.isEmpty()) continue
val stocks = current.villageCloth.toMutableList().also { it[h] = stock }
s = s.copy(
sea = current.copy(cloth = cloth, villageCloth = stocks),
log = s.log + "The village at (${topo.HEXES[h].q},${topo.HEXES[h].r}) pays cloth to ${said.joinToString(", ")}.",
)
}
return s
}
// -----------------------------------------------------------------------
// The Forgotten Tribe
// -----------------------------------------------------------------------
/** After a settlement on [vertex]: take any tribe chip lying on a hex beside it. */
fun takeChips(state: CatanState, seat: Int, vertex: Int): CatanState {
var s = state
if (s.sea?.chips.isNullOrEmpty()) return s
var gold = 0
for (hex in state.topo.VERTEX_HEXES[vertex]) {
val sea = s.sea!!
val chip = sea.chips.getOrNull(hex) ?: continue
if (chip == TribeChip.NONE) continue
val rest = sea.chips.toMutableList().also { it[hex] = TribeChip.NONE }
var next = sea.copy(chips = rest)
val said = when (chip) {
TribeChip.POINT -> {
val points = next.chipPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList()
points[seat] += 1
next = next.copy(chipPoints = points)
"a victory point"
}
TribeChip.HARBOUR -> {
next = next.copy(harbourSeats = (next.harbourSeats + seat).distinct())
"a harbour that trades 3:1"
}
else -> {
gold += 2
"two resources of its choice"
}
}
s = s.copy(sea = next, log = s.log + "Seat $seat takes the tribe's chip beside it: $said.")
}
return if (gold > 0) owe(s, seat, gold) else s
}
/** What happens to a new settlement of [seat]'s on [vertex] beyond the base game. */
fun settled(state: CatanState, seat: Int, vertex: Int): CatanState =
takeChips(claimIsland(state, seat, vertex), seat, vertex)
/** After a settlement on [vertex]: award the island bonus if it is the first on a new island. */
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
@@ -34,6 +34,7 @@ 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.Terrain
import org.prolibertate.games.game.catan.TribeChip
/**
* The island, drawn and tappable.
@@ -166,6 +167,26 @@ private fun DrawScope.drawHexes(
weight = FontWeight.Bold,
)
}
val sea = state.sea
val note = when {
state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth"
else -> when (sea?.chips?.getOrNull(hex)) {
TribeChip.POINT -> "+1 VP"
TribeChip.HARBOUR -> "3:1"
TribeChip.GOLD -> "gold"
else -> null
}
}
if (note != null) {
drawCentered(
measurer = measurer,
text = note,
at = Pt(centre.x, centre.y + board.radius * 0.62f),
fontPx = board.radius * 0.26f,
color = style.tokenInk,
weight = FontWeight.Bold,
)
}
}
}
@@ -256,6 +256,7 @@ fun CatanPlayerCard(
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
}
if (ck.merchant?.seat == seat) add("Merchant")
addAll(seaAwards(state, seat, compact))
}
CardLine(
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
@@ -280,6 +281,7 @@ fun CatanPlayerCard(
val awards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
addAll(seaAwards(state, seat, compact))
}
// Always drawn, so the card does not grow when somebody takes an award.
CardLine(
@@ -634,3 +636,12 @@ fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
}
}
}
/** Seafarers scenario holdings worth a word on a player's card: cloth, and a tribe harbour. */
private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List<String> {
val sea = state.sea ?: return emptyList()
return buildList {
if (sea.cloth.isNotEmpty()) add(if (compact) "${sea.cloth[seat]}c" else "Cloth ${sea.cloth[seat]}")
if (seat in sea.harbourSeats) add(if (compact) "3:1" else "Harbour 3:1")
}
}
@@ -126,6 +126,8 @@ private val LightStyle = CatanStyle(
Terrain.GOLD to Color(0xFFF2B01E),
// Land not yet found: a pale grey bank of fog with a question mark.
Terrain.FOG to Color(0xFFB9C2C9),
// Cloth villages: a dyed-cloth purple.
Terrain.VILLAGE to Color(0xFF9B6BB5),
),
hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9),
@@ -75,6 +75,7 @@ object CatanText {
Terrain.SEA -> "Sea"
Terrain.GOLD -> "Gold field"
Terrain.FOG -> "Fog"
Terrain.VILLAGE -> "Village"
}
fun devCard(type: DevCardType): String = when (type) {
@@ -0,0 +1,168 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** The scenarios with rules of their own: cloth villages and tribe chips. */
class CatanSeaExtrasTest {
private val json = Json { encodeDefaults = true }
private fun start(scenario: SeaScenario, seed: Long = 5L, players: Int = 3): CatanState {
val options = CatanOptions(
playerCount = players,
pointsToWin = 14,
expansions = setOf(CatanExpansion.SEAFARERS),
scenario = scenario,
)
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(options),
seed = seed,
)
return CatanRules.initialState(config).copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
}
private fun dice(total: Int): List<Int> = if (total <= 7) listOf(1, total - 1) else listOf(total - 6, 6)
/** A cloth game with seat 0 and seat 1 both settled beside the first village, the robber elsewhere. */
private fun clothGame(): Pair<CatanState, Int> {
val s = start(SeaScenario.CLOTH_FOR_CATAN)
val village = s.terrains.indexOf(Terrain.VILLAGE)
val corners = s.topo.HEX_CORNERS[village]
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
val staged = s.copy(
robber = elsewhere,
buildings = s.buildings.toMutableList().also {
it[corners[0]] = CatanBuilding(BuildingType.SETTLEMENT, 0)
it[corners[3]] = CatanBuilding(BuildingType.CITY, 1)
},
)
return staged to village
}
private fun roll(s: CatanState, number: Int) = CatanRules.applyMove(s, s.turn, RollDice(dice(number)))
@Test
fun `a village pays one cloth to every settlement beside it, a city no more`() {
val (s, village) = clothGame()
val number = s.numbers[village]
assertTrue(number in 2..12 && number != 7)
val after = roll(s, number)
assertEquals(listOf(1, 1, 0), after.sea!!.cloth)
assertEquals(SeaBoards.VILLAGE_CLOTH - 2, after.sea!!.villageCloth[village])
assertTrue(after.log.any { "village" in it && "cloth" in it })
}
@Test
fun `an empty village pays nothing and the last token goes to the seat nearest the roller`() {
val (s, village) = clothGame()
val low = s.copy(sea = s.sea!!.copy(villageCloth = s.sea!!.villageCloth.toMutableList().also { it[village] = 1 }))
val after = roll(low.copy(turn = 1), low.numbers[village])
assertEquals(listOf(0, 1, 0), after.sea!!.cloth)
assertEquals(0, after.sea!!.villageCloth[village])
val again = roll(after.copy(phase = CatanPhase.AWAIT_ROLL), after.numbers[village])
assertEquals(listOf(0, 1, 0), again.sea!!.cloth)
}
@Test
fun `the robber on a village stops it`() {
val (s, village) = clothGame()
val after = roll(s.copy(robber = village), s.numbers[village])
assertEquals(listOf(0, 0, 0), after.sea!!.cloth)
}
@Test
fun `every two cloth are a victory point, and the whole thing survives the wire format`() {
val (s, _) = clothGame()
val rich = s.copy(sea = s.sea!!.copy(cloth = listOf(5, 1, 0)))
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 2, CatanRules.publicVictoryPoints(rich)[0])
assertEquals(CatanRules.publicVictoryPoints(s)[1], CatanRules.publicVictoryPoints(rich)[1])
assertEquals(rich, CatanRules.decodeState(CatanRules.encodeState(rich)))
}
@Test
fun `villages pay cloth with Cities and Knights too`() {
val options = CatanOptions(
playerCount = 3,
pointsToWin = 14,
expansions = setOf(CatanExpansion.SEAFARERS, CatanExpansion.CITIES_AND_KNIGHTS),
scenario = SeaScenario.CLOTH_FOR_CATAN,
)
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(options),
seed = 5L,
)
val s = CatanRules.initialState(config)
.copy(phase = CatanPhase.AWAIT_ROLL, turn = 0, turnNumber = 1, setupIndex = 6)
val village = s.terrains.indexOf(Terrain.VILLAGE)
val elsewhere = s.terrains.indices.first { s.topo.land[it] && it != village && s.terrains[it] != Terrain.VILLAGE }
val staged = s.copy(
robber = elsewhere,
buildings = s.buildings.toMutableList().also {
it[s.topo.HEX_CORNERS[village][0]] = CatanBuilding(BuildingType.SETTLEMENT, 2)
},
)
val after = roll(staged, staged.numbers[village])
assertEquals(1, after.sea!!.cloth[2])
}
// ---- The Forgotten Tribe ----
/** A tribe game, seat 0 with a road reaching a settlement site beside [chipHex], and plenty of cards. */
private fun tribeGame(chip: TribeChip): Triple<CatanState, Int, Int> {
val s = start(SeaScenario.FORGOTTEN_TRIBE)
val hex = s.sea!!.chips.indexOfFirst { it != TribeChip.NONE }
val chips = s.sea!!.chips.toMutableList().also { it[hex] = chip }
val topo = s.topo
val vertex = topo.HEX_CORNERS[hex].first { v -> topo.VERTEX_NEIGHBORS[v].all { s.buildings[it] == null } }
val edge = topo.VERTEX_EDGES[vertex].first { topo.edgeLand[it] }
val staged = s.copy(
phase = CatanPhase.PLAYING,
roads = s.roads.toMutableList().also { it[edge] = 0 },
resources = List(3) { Resource.entries.associateWith { 4 } },
sea = s.sea!!.copy(chips = chips),
)
return Triple(staged, hex, vertex)
}
@Test
fun `the first settler beside a point chip takes the point`() {
val (s, hex, vertex) = tribeGame(TribeChip.POINT)
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(1, after.sea!!.chipPoints[0])
assertEquals(TribeChip.NONE, after.sea!!.chips[hex])
assertEquals(CatanRules.publicVictoryPoints(s)[0] + 1 + 1 + 2, CatanRules.publicVictoryPoints(after)[0])
}
@Test
fun `a harbour chip trades three to one anywhere`() {
val (s, _, vertex) = tribeGame(TribeChip.HARBOUR)
assertEquals(4, CatanRules.bestPortRatio(s, 0, Resource.WOOL))
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(listOf(0), after.sea!!.harbourSeats)
assertEquals(3, CatanRules.bestPortRatio(after, 0, Resource.WOOL))
assertEquals(4, CatanRules.bestPortRatio(after, 1, Resource.WOOL))
assertTrue(CatanRules.legalMoves(after, 0).any { it is MaritimeTrade })
}
@Test
fun `a gold chip asks for two resources of the settler's choice`() {
val (s, _, vertex) = tribeGame(TribeChip.GOLD)
val after = CatanRules.applyMove(s, 0, BuildSettlement(vertex))
assertEquals(CatanPhase.GOLD, after.phase)
assertEquals(listOf(GoldOwed(0, 2)), after.sea!!.gold)
val done = CatanRules.applyMove(after, 0, CatanRules.legalMoves(after, 0).first())
assertEquals(CatanPhase.PLAYING, done.phase)
}
}
@@ -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(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN))
assertEquals(listOf(2, 3, 5, 17), sizes(SeaScenario.FORGOTTEN_TRIBE))
}
@Test
@@ -58,7 +60,9 @@ class CatanSeaScenariosTest {
val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG
assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0)
}
assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS)
val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN &&
scenario != SeaScenario.FORGOTTEN_TRIBE
assertTrue("$scenario has no desert", deserts > 0 || !deserted)
// Under fog every hex is face down with no number; the tiles are in the pile.
val fog = s.terrains.indices.filter { s.terrains[it] == Terrain.FOG }
assertEquals(fog.size, s.sea!!.fogDeck.size)
@@ -208,6 +212,8 @@ class CatanSeaScenariosTest {
@Test
fun `engines finish a game on every map at three and four players`() {
var fogFound = 0
var clothPaid = 0
var chipsTaken = 0
for (scenario in SeaScenario.entries) {
for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) {
val opts = options(scenario, players)
@@ -225,9 +231,15 @@ 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.FORGOTTEN_TRIBE) {
chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE }
}
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
}
}
assertTrue("no engine ever uncovered a fog hex", fogFound > 0)
assertTrue("no village ever paid cloth", clothPaid > 0)
assertTrue("no tribe chip was ever taken", chipsTaken > 0)
}
}
+33 -2
View File
@@ -27,6 +27,11 @@ Pick the map at setup; each scenario names where the starting settlements may go
* **The Fog Islands.** A 13-hex home island (one desert), a two-hex islet and a 14-hex bank of fog.
See *Fog* below.
* **Cloth for Catan.** A 17-hex main island, an eastern isle, two islets, and four **villages** on the coast.
See *Cloth* below.
* **The Forgotten Tribe.** A 17-hex home island and three isles carrying four tribe chips. See *The Forgotten
Tribe* below.
A *new island* for the bonus is any island that is not a starting island for that map.
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
@@ -85,6 +90,31 @@ pile is hidden from every seat but the host's, like the development deck.
also on one bank rather than between several islands, and the number tokens are dealt with the tiles, so two
red numbers can end up side by side once found.
## Cloth
The published scenario puts cloth villages between the islands and pays cloth when a village's number is rolled.
This implementation is the author's reading of that, not a transcription of the printed rules:
* A **village** is a land hex with a number token and a stock of cloth (6). It produces no cards and nothing may
be built on it, but its corners can hold settlements like any other hex's.
* When its number rolls, the village pays **one cloth token** to each settlement or city beside it (a city is
worth no more than a settlement here), until the stock is gone. If the stock is short, the seats nearest the
roller, in turn order, take what is left. The robber on a village stops it; the pirate does not.
* **Two cloth are a victory point** (an odd token is worth nothing alone). Tokens are scored, not spent, and not
tradeable.
* Works with Cities & Knights: the village pays after the dice, before the aqueduct and gold.
## The Forgotten Tribe
Also the author's reading. A chip lies face up on four hexes of the small isles (the hexes are marked on the
board). The chips are dealt at the start of the game, one of each kind in turn:
* **The first settlement** built on a crossing beside a chip hex takes that chip; it is gone for everyone else.
(The setup settlements are never beside one, since the home island is the only start.)
* A **point** chip is one victory point. A **harbour** chip lets that seat trade 3:1 at the bank, wherever it is.
A **gold** chip pays two resources of the settler's choice, taken at once like gold.
* The chips come on top of the island bonus for being first to a new island.
## With Cities & Knights
Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map
@@ -108,12 +138,13 @@ 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 (cloth, wonders, the forgotten tribe, pirate islands) and trade ships.
the scenarios that need new rules (wonders, pirate islands) and trade ships.
A map-only scenario is a new text map and a new `SeaScenario` entry.
## How the AI plays it
It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its
open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that
bonus is in play. It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
bonus is in play. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is
worth more (`CatanAi.scenarioLure`). 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`.