diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index 5de0e0a..5d1bc63 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt @@ -592,6 +592,20 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi 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 = 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 = emptyList(), + /** Cloth still on each hex's village, by hex (Cloth for Catan); empty when the scenario has none. */ + val villageCloth: List = emptyList(), + /** The tribe chip waiting on each hex (Forgotten Tribe), by hex; empty when the scenario has none. */ + val chips: List = emptyList(), + /** Seats that took a harbour chip and so trade 3:1 anywhere. */ + val harbourSeats: List = emptyList(), + /** Victory points each seat holds from tribe chips; empty until one is taken. */ + val chipPoints: List = 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) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt index 5e0138d..95d8705 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt @@ -121,6 +121,9 @@ object CatanRules : GameRules { 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 { 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 { }, ) 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 { 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 { 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 } // ----------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt index 5291e3f..24748ce 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaBoard.kt @@ -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 = 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() @@ -138,11 +174,18 @@ object SeaBoards { val starts: List = listOf(home), /** The tiles under the fog, in the order they will be found. */ val fogDeck: List = emptyList(), + /** The tribe chip on each hex, or [TribeChip.NONE]; empty when the map has none. */ + val chips: List = emptyList(), + /** Cloth waiting on each village, by hex; empty when the map has none. */ + val villageCloth: List = 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) } } diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt index 0fda06f..594cdc8 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanSeaRules.kt @@ -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() + 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 { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt index 672c54c..49d3f1d 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -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, + ) + } } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index 00f79e2..779b0df 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -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 { + 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") + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt index 44bc064..1d6c65b 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt @@ -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), diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt index a90a657..188a792 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -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) { diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt new file mode 100644 index 0000000..700c3e9 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt @@ -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 = 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 { + 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 { + 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) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt index 8cf1687..ee83e03 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaScenariosTest.kt @@ -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) } } diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index d500da7..1b1cbbd 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -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`.