Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f2efa313 | ||
|
|
385262515e |
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user