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 37041e8..30f1869 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 @@ -279,6 +279,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi().firstOrNull()?.let { return it } + legal.filterIsInstance().firstOrNull()?.let { return it } legal.filterIsInstance().firstOrNull()?.let { return it } // Seafarers: a ship toward an island nobody has landed on is worth more than a card. @@ -605,6 +606,8 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi 0) { lure += CatanBoard.numberOdds(state.numbers[h]) / 36.0 * CLOTH_WORTH } + // An unfinished canal tile is a place to dig: points now, grain land later. + if (sea.canalNumbers.getOrElse(h) { 0 } != 0 && state.terrains[h] == Terrain.DESERT) lure += CANAL_LURE if (sea.chips.getOrNull(h).let { it != null && it != TribeChip.NONE }) lure += CHIP_LURE } return lure @@ -705,5 +708,6 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi().firstOrNull()?.let { return it } + legal.filterIsInstance().firstOrNull()?.let { return it } legal.filterIsInstance().firstOrNull()?.let { return it } // Seafarers: a ship toward land nobody has settled is worth more than a card. diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt index 1ed0481..dc6f1a5 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt @@ -507,8 +507,34 @@ data class SeaState( val raidPoints: List = emptyList(), /** Wonder stages each seat has built (Wonders of Catan), by seat; empty when the scenario has none. */ val wonders: List = emptyList(), + /** The chest under each hex of fog, by hex (Treasure Islands, Oases); found when the hex is. Hidden from other views. */ + val treasures: List = emptyList(), + /** Units dug so far into each canal tile, by hex (Great Canal); empty when the map has none. */ + val canalUnits: List = emptyList(), + /** The number token waiting under each canal tile, by hex; it shows when the tile is finished. */ + val canalNumbers: List = emptyList(), + /** Canal units each seat has dug; every two are a victory point. */ + val canalDug: List = emptyList(), ) +/** What a treasure chest holds (Treasure Islands). */ +@Serializable +enum class Treasure { + NONE, + + /** One brick, one wool and one grain. */ + BRICK_WOOL_GRAIN, + + /** One resource of the finder's choice. */ + CHOICE, + + /** A development card (a resource of choice in Cities & Knights, which has none). */ + DEV_CARD, + + /** Two roads' worth of cards: two lumber and two brick. */ + ROADS, +} + /** What the first settler beside a tribe hex takes (Forgotten Tribe). */ @Serializable enum class TribeChip { @@ -563,3 +589,8 @@ data class RaidFortress(val hex: HexCoord) : CatanMove @Serializable @SerialName("wonder") data object BuildWonder : CatanMove + +/** Dig one unit of the canal tile on [hex]: 1 brick and 1 ore, from a seat with a settlement or city beside it. */ +@Serializable +@SerialName("dig") +data class DigCanal(val hex: HexCoord) : CatanMove 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 2f20df9..0e7ef6d 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 @@ -125,6 +125,10 @@ object CatanRules : GameRules { villageCloth = it.villageCloth, chips = it.chips, fortress = it.fortress, + treasures = it.treasures, + canalUnits = if (it.canalNumbers.isEmpty()) emptyList() else List(topo.HEX_COUNT) { 0 }, + canalNumbers = it.canalNumbers, + canalDug = if (it.canalNumbers.isEmpty()) emptyList() else List(options.playerCount) { 0 }, wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(), ) }, @@ -475,6 +479,7 @@ object CatanRules : GameRules { is MovePirate -> applyMovePirate(state, seat, move) is PickGold -> applyPickGold(state, seat, move) is RaidFortress -> SeaRules.applyRaid(state, seat, move) + is DigCanal -> SeaRules.applyDig(state, seat, move) BuildWonder -> SeaRules.applyWonder(state, seat) is BuildRoad -> applyBuildRoad(state, seat, move) is BuildSettlement -> applyBuildSettlement(state, seat, move) @@ -1226,7 +1231,7 @@ object CatanRules : GameRules { state.copy( // Hidden information stays with the holder and the host. devDeck = emptyList(), - sea = state.sea?.copy(fogDeck = emptyList()), + sea = state.sea?.copy(fogDeck = emptyList(), treasures = emptyList()), devHands = state.devHands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() }, 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 2a90a1e..3a4fad6 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,21 @@ enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart "A home island, an islet, and a bank of fog. Land in the fog is face down until a road or ship reaches it; " + "the finder takes a card of what lies there.", ), + TREASURE_ISLANDS( + "The Treasure Islands", + "Chests lie hidden on the fog-covered isles beside a home island. Whoever uncovers one opens it: " + + "cards, a resource of choice, a development card or a road's worth of materials.", + ), + OASES( + "The Oases", + "An enormous desert, with fertile oases hidden inside it. Roads that reach an oasis uncover it " + + "and whatever treasure is buried there.", + ), + GREAT_CANAL( + "The Great Canal", + "A desert strip splits the island. Dig the canal tile by tile (a brick and an ore a unit) and each finished " + + "tile becomes grain land; every two units dug are a victory point.", + ), PIRATE_ISLANDS( "Pirate Islands", "Two isles are held by pirate fortresses. Sail ships beside one and raid it with ore, wool and grain; " + @@ -136,6 +151,39 @@ object SeaBoards { "............", ) + private val TREASURE_ISLANDS_MAP = listOf( + "............", + "..###..FF...", + "..####..FF..", + "..#####.....", + "..###...F...", + ".......FF...", + "..F.........", + "............", + ) + + private val OASES_MAP = listOf( + "............", + "..###DD###..", + "..#DDFDD#...", + "..#DDDFD#...", + "..#DFDDD#...", + "..###DD###..", + "............", + ) + + /** `C` is a canal tile: desert with a number hidden under it until it is dug out. */ + private val GREAT_CANAL_MAP = listOf( + "............", + "..#####.....", + "..#####.....", + "..CCCCC.....", + "..#####.....", + "..#####.....", + "........$$..", + "............", + ) + /** `P` is a pirate fortress. */ private val PIRATE_ISLANDS_MAP = listOf( "............", @@ -164,6 +212,9 @@ object SeaBoards { SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP + SeaScenario.TREASURE_ISLANDS -> TREASURE_ISLANDS_MAP + SeaScenario.OASES -> OASES_MAP + SeaScenario.GREAT_CANAL -> GREAT_CANAL_MAP SeaScenario.PIRATE_ISLANDS -> PIRATE_ISLANDS_MAP SeaScenario.WONDERS_OF_CATAN -> WONDERS_OF_CATAN_MAP SeaScenario.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP @@ -215,6 +266,10 @@ object SeaBoards { val villageCloth: List = emptyList(), /** Strength of the fortress on each hex, by hex; empty when the map has none. */ val fortress: List = emptyList(), + /** The chest under each hex, by hex; empty when the map has none. */ + val treasures: List = emptyList(), + /** The number under each canal tile, by hex; empty when the map has none. */ + val canalNumbers: List = emptyList(), ) /** An island of this many land hexes or more can be started on. */ @@ -226,6 +281,9 @@ object SeaBoards { /** Ships' raids a pirate fortress takes to fall. */ const val FORTRESS_STRENGTH = 3 + /** Chests dealt to the fog, at most. */ + private const val CHESTS = 5 + private val RESOURCE_TILES = listOf( Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS, ) @@ -250,13 +308,14 @@ object SeaBoards { '#', 't' -> pool[plain.indexOf(h)] 'V' -> Terrain.VILLAGE 'P' -> Terrain.FORTRESS + 'C' -> Terrain.DESERT 'F' -> Terrain.FOG else -> Terrain.SEA } } // The fog hexes take part as slots, so the tokens they hide are dealt from the same pile. - val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS } + val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && (terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS || kinds[it] == 'C') } check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" } val tokens = TOKENS.take(numbered.size).shuffled(random) val numbers = IntArray(terrains.size) @@ -294,7 +353,9 @@ object SeaBoards { val desert = terrains.indexOf(Terrain.DESERT).takeIf { it >= 0 } ?: numbered.first() // Home is the largest island that is not under the fog: nobody starts on land they cannot see. - val fogged = fogHexes.map { topo.islandOf[it] }.toSet() + val fogged = (0 until topo.islandCount).filter { island -> + topo.islandOf.indices.filter { topo.islandOf[it] == island }.all { terrains[it] == Terrain.FOG } + }.toSet() val home = (0 until topo.islandCount).filter { it !in fogged } .maxByOrNull { island -> topo.islandOf.count { it == island } } ?: 0 // The pirate starts in the open sea: the sea hex farthest from every shore. @@ -334,6 +395,24 @@ object SeaBoards { List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 } } - return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress) + // Chests under the fog: the four rewards in turn, on hexes picked at random. + val chestKinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.CHOICE, Treasure.DEV_CARD, Treasure.ROADS) + val chestHexes = if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) { + fogHexes.shuffled(random).take(CHESTS) + } else { + emptyList() + } + val treasures = if (chestHexes.isEmpty()) { + emptyList() + } else { + List(terrains.size) { h -> chestHexes.indexOf(h).let { if (it < 0) Treasure.NONE else chestKinds[it % chestKinds.size] } } + } + + // Canal tiles keep their number under the tile until it is dug out. + val canalHexes = kinds.indices.filter { kinds[it] == 'C' } + val canalNumbers = if (canalHexes.isEmpty()) emptyList() else List(terrains.size) { h -> if (h in canalHexes) numbers[h] else 0 } + canalHexes.forEach { numbers[it] = 0 } + + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress, treasures, canalNumbers) } } 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 e1b6dd6..c9d1126 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 @@ -182,11 +182,55 @@ internal object SeaRules { } else if (tile.terrain == Terrain.GOLD) { owed += GoldOwed(seat, 1) } + val chest = s.sea!!.treasures.getOrNull(hex) ?: Treasure.NONE + if (chest != Treasure.NONE) { + val (opened, choose) = openChest(s, seat, chest) + s = opened + if (choose > 0) owed += GoldOwed(seat, choose) + } } if (owed.isEmpty()) return s return owe(s, seat, owed.sumOf { it.count }) } + /** + * [seat] opens [chest]: the state after what it pays directly, and the number of cards of the seat's own + * choice still owed. Cards the bank cannot cover are simply not paid. + */ + private fun openChest(state: CatanState, seat: Int, chest: Treasure): Pair { + var s = state + fun pay(cards: Map) { + val real = cards.filter { (r, n) -> CatanRules.bankSupply(s, r) >= n } + if (real.isNotEmpty()) s = s.copy(resources = CatanRules.withCounts(s.resources, seat, real, 1)) + } + var choose = 0 + val said = when (chest) { + Treasure.BRICK_WOOL_GRAIN -> { + pay(mapOf(Resource.BRICK to 1, Resource.WOOL to 1, Resource.GRAIN to 1)) + "a brick, a wool and a grain" + } + Treasure.ROADS -> { + pay(mapOf(Resource.LUMBER to 2, Resource.BRICK to 2)) + "materials for two roads" + } + Treasure.DEV_CARD -> if (!s.isCk && s.devDeck.isNotEmpty()) { + val card = s.devDeck.first().copy(boughtTurn = s.turnNumber) + val hands = s.devHands.toMutableList().also { it[seat] = it[seat] + card } + s = s.copy(devDeck = s.devDeck.drop(1), devHands = hands, devHandCounts = hands.map { it.size }) + "a development card" + } else { + choose = 1 + "a resource of its choice" + } + else -> { + choose = 1 + "a resource of its choice" + } + } + s = s.copy(log = s.log + "Seat $seat opens a treasure chest: $said.") + return s to choose + } + /** Queues [count] cards of [seat]'s choice, to be taken before play resumes where it left off. */ private fun owe(state: CatanState, seat: Int, count: Int): CatanState { val sea = state.sea!! @@ -208,7 +252,51 @@ internal object SeaRules { (sea.raidPoints.getOrNull(seat) ?: 0) + (sea.wonders.getOrNull(seat) ?: 0) + clothOf(state, seat) / CLOTH_PER_POINT + - (sea.chipPoints.getOrNull(seat) ?: 0) + (sea.chipPoints.getOrNull(seat) ?: 0) + + (sea.canalDug.getOrNull(seat) ?: 0) / CANAL_UNITS_PER_POINT + } + + // ----------------------------------------------------------------------- + // The Great Canal + // ----------------------------------------------------------------------- + + val DIG_COST: Map = mapOf(Resource.BRICK to 1, Resource.ORE to 1) + + /** Units that finish a canal tile. */ + const val CANAL_TILE_UNITS = 3 + + /** Two units dug are a victory point. */ + const val CANAL_UNITS_PER_POINT = 2 + + /** Tiles [seat] may dig a unit of: unfinished, with a settlement or city of the seat's on a corner. */ + private fun digTargets(state: CatanState, seat: Int): List { + val sea = state.sea!! + if (sea.canalUnits.isEmpty()) return emptyList() + return sea.canalUnits.indices.filter { h -> + sea.canalNumbers[h] != 0 && sea.canalUnits[h] in 0 until CANAL_TILE_UNITS && + state.terrains[h] == Terrain.DESERT && + state.topo.HEX_CORNERS[h].any { state.buildings[it]?.owner == seat } + } + } + + fun applyDig(state: CatanState, seat: Int, move: DigCanal): CatanState { + val sea = state.sea!! + val hex = state.topo.hexIndex.getValue(move.hex) + val units = sea.canalUnits[hex] + 1 + val dug = sea.canalDug.toMutableList().also { it[seat] += 1 } + var s = state.copy( + resources = CatanRules.withCounts(state.resources, seat, DIG_COST, -1), + sea = sea.copy(canalUnits = sea.canalUnits.toMutableList().also { it[hex] = units }, canalDug = dug), + log = state.log + "Seat $seat digs the canal at (${move.hex.q},${move.hex.r}): $units of $CANAL_TILE_UNITS.", + ) + if (units == CANAL_TILE_UNITS) { + s = s.copy( + terrains = s.terrains.toMutableList().also { it[hex] = Terrain.FIELDS }, + numbers = s.numbers.toMutableList().also { it[hex] = sea.canalNumbers[hex] }, + log = s.log + "The tile is dug out: it is fields now, with a ${sea.canalNumbers[hex]}.", + ) + } + return CatanRules.checkWin(s) } // ----------------------------------------------------------------------- @@ -328,6 +416,9 @@ internal object SeaRules { if (CatanRules.affordable(state, seat, RAID_COST)) { raidTargets(state, seat).forEach { moves += RaidFortress(state.topo.HEXES[it]) } } + if (CatanRules.affordable(state, seat, DIG_COST)) { + digTargets(state, seat).forEach { moves += DigCanal(state.topo.HEXES[it]) } + } nextStage(state, seat)?.let { if (CatanRules.affordable(state, seat, it)) moves += BuildWonder } return moves } 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 6d9532a..33ae55c 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 @@ -33,6 +33,7 @@ import org.prolibertate.games.game.catan.CatanBoard import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.NO_PLAYER import org.prolibertate.games.game.catan.Resource +import org.prolibertate.games.game.catan.SeaRules import org.prolibertate.games.game.catan.Terrain import org.prolibertate.games.game.catan.TribeChip @@ -170,6 +171,8 @@ private fun DrawScope.drawHexes( val sea = state.sea val note = when { state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth" + sea != null && sea.canalNumbers.getOrElse(hex) { 0 } != 0 && state.terrains[hex] == Terrain.DESERT -> + "dug ${sea.canalUnits.getOrElse(hex) { 0 }}/${SeaRules.CANAL_TILE_UNITS}" state.terrains[hex] == Terrain.FORTRESS && sea != null -> "fort ${sea.fortress.getOrElse(hex) { 0 }}" else -> when (sea?.chips?.getOrNull(hex)) { TribeChip.POINT -> "+1 VP" diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt index 31fc508..77aa31f 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanHud.kt @@ -174,6 +174,7 @@ object CatanHudGlyphs { BuildKind.KNIGHT -> "♞" BuildKind.SHIP -> "⛵" BuildKind.RAID -> "⚔" + BuildKind.DIG -> "⛏" BuildKind.WONDER -> "✦" } @@ -185,6 +186,7 @@ object CatanHudGlyphs { BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP BuildKind.RAID -> CatanText.RAID + BuildKind.DIG -> CatanText.DIG BuildKind.WONDER -> CatanText.WONDER } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt index f83f73d..8b676fc 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -7,6 +7,7 @@ import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildRoad import org.prolibertate.games.game.catan.BuildShip import org.prolibertate.games.game.catan.BuildWonder +import org.prolibertate.games.game.catan.DigCanal import org.prolibertate.games.game.catan.MovePirate import org.prolibertate.games.game.catan.RaidFortress import org.prolibertate.games.game.catan.MoveShip @@ -74,6 +75,9 @@ enum class BuildKind { /** Pirate Islands: raid a fortress; the target is the fortress hex. */ RAID, + /** Great Canal: dig a canal tile; the target is the tile. */ + DIG, + /** Wonders of Catan: the next stage of the wonder, which needs no tap on the board. */ WONDER; @@ -88,10 +92,11 @@ enum class BuildKind { val sea = state.sea val extras = buildList { if (sea != null && sea.fortress.isNotEmpty()) add(RAID) + if (sea != null && sea.canalUnits.isNotEmpty()) add(DIG) if (sea != null && sea.wonders.isNotEmpty()) add(WONDER) } val usual = when { - state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, WONDER) + state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, DIG, WONDER) state.isSea -> SEAFARERS else -> BASE } @@ -278,6 +283,9 @@ object CatanInteraction { BuildKind.RAID -> BoardTargets( hexes = legal.filterIsInstance().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(), ) + BuildKind.DIG -> BoardTargets( + hexes = legal.filterIsInstance().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(), + ) BuildKind.WONDER -> BoardTargets() BuildKind.SETTLEMENT -> BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) @@ -546,7 +554,7 @@ object CatanInteraction { val coord = topo.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored val moves = legal.filterIsInstance().filter { it.hex == coord } val pirates = legal.filterIsInstance().filter { it.hex == coord } - legal.firstOrNull { it is RaidFortress && it.hex == coord }?.let { return TapOutcome.Submit(it) } + legal.firstOrNull { (it is RaidFortress && it.hex == coord) || (it is DigCanal && it.hex == coord) }?.let { return TapOutcome.Submit(it) } return when { moves.isNotEmpty() -> if (moves.size == 1) { TapOutcome.Submit(moves.first()) @@ -615,6 +623,7 @@ object CatanInteraction { if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT) if (legal.any { it is BuildCity }) add(BuildKind.CITY) if (legal.any { it is RaidFortress }) add(BuildKind.RAID) + if (legal.any { it is DigCanal }) add(BuildKind.DIG) if (legal.any { it is BuildWonder }) add(BuildKind.WONDER) if (legal.any { it is BuildWall }) add(BuildKind.WALL) if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT) 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 00a4812..9d01735 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 @@ -601,6 +601,7 @@ private fun buildLabel(kind: BuildKind): String = when (kind) { BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP BuildKind.RAID -> CatanText.RAID + BuildKind.DIG -> CatanText.DIG BuildKind.WONDER -> CatanText.WONDER } @@ -612,6 +613,7 @@ private fun buildCost(kind: BuildKind): String = when (kind) { BuildKind.KNIGHT -> CatanText.KNIGHT_COST BuildKind.SHIP -> CatanText.SHIP_COST_TEXT BuildKind.RAID -> CatanText.RAID_COST + BuildKind.DIG -> CatanText.DIG_COST BuildKind.WONDER -> CatanText.WONDER_COST } @@ -646,6 +648,7 @@ private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List "Tap the sea edge to sail the ship to." building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship." building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it." + building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit." building == BuildKind.ROAD -> "Tap an edge to build a road." building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." @@ -459,6 +462,7 @@ object CatanText { movingShip -> "Tap the sea edge to sail the ship to." building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship." building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it." + building == BuildKind.DIG -> "Tap a canal tile beside one of your towns to dig a unit." building == BuildKind.ROAD -> "Tap an edge to build a road." building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." 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 index f54e52f..23862d2 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeaExtrasTest.kt @@ -241,4 +241,96 @@ class CatanSeaExtrasTest { val s = wonderGame().copy(resources = List(3) { Resource.entries.associateWith { 0 } }) assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildWonder }) } + + // ---- Treasure Islands, Oases ---- + + @Test + fun `chests lie under the fog and are hidden from other views`() { + for (scenario in listOf(SeaScenario.TREASURE_ISLANDS, SeaScenario.OASES)) { + val s = start(scenario) + val chests = s.sea!!.treasures.indices.filter { s.sea!!.treasures[it] != Treasure.NONE } + assertEquals(minOf(5, s.terrains.count { it == Terrain.FOG }), chests.size) + assertTrue(chests.all { s.terrains[it] == Terrain.FOG }) + assertTrue(CatanRules.viewFor(s, 1).sea!!.treasures.isEmpty()) + } + } + + @Test + fun `an oasis map starts everyone on the desert island, never beside an oasis`() { + val s = start(SeaScenario.OASES) + assertEquals(37, s.topo.islandOf.count { it == s.sea!!.home }) + val moves = CatanRules.legalMoves(s.copy(phase = CatanPhase.SETUP_FIRST), s.firstSeat).filterIsInstance() + assertTrue(moves.isNotEmpty()) + assertTrue(moves.all { m -> s.topo.VERTEX_HEXES[m.vertex].none { s.terrains[it] == Terrain.FOG } }) + } + + /** A chest of [kind] under the first fog hex, with a road of seat 0's laid up to the edge that will reveal it. */ + private fun chestGame(kind: Treasure): Triple { + val s = start(SeaScenario.OASES).copy(phase = CatanPhase.PLAYING) + val topo = s.topo + val hex = s.terrains.indexOf(Terrain.FOG) + val chests = List(topo.HEX_COUNT) { if (it == hex) kind else Treasure.NONE } + // Seat 0 owns the town beside a road that reaches the oasis; building the road finds it. + val edge = topo.EDGE_VERTICES.indices.first { e -> hex in topo.EDGE_HEXES[e] && topo.edgeLand[e] } + val staged = s.copy( + resources = List(3) { Resource.entries.associateWith { 5 } }, + sea = s.sea!!.copy(treasures = chests), + ) + return Triple(staged, hex, edge) + } + + @Test + fun `opening a chest pays its reward`() { + val kinds = listOf(Treasure.BRICK_WOOL_GRAIN, Treasure.ROADS, Treasure.DEV_CARD, Treasure.CHOICE) + for (kind in kinds) { + val (s, _, _) = chestGame(kind) + // Stage the find directly: reveal as if a road had just reached the hex. + val topo = s.topo + val hex = s.terrains.indexOf(Terrain.FOG) + val edge = (0 until topo.EDGE_COUNT).first { hex in topo.EDGE_HEXES[it] && topo.edgeLand[it] } + val after = SeaRules.reveal(s, 0, edge) + assertTrue("$kind: the chest is announced", after.log.any { "treasure chest" in it }) + when (kind) { + Treasure.DEV_CARD -> assertEquals(1, after.devHands[0].size) + Treasure.CHOICE -> assertTrue(after.phase == CatanPhase.GOLD || after.log.any { "uncovers" in it }) + Treasure.ROADS -> assertTrue(after.resources[0].getValue(Resource.LUMBER) >= 7) + else -> assertTrue(after.resources[0].getValue(Resource.WOOL) >= 6) + } + } + } + + // ---- The Great Canal ---- + + private fun canalGame(): Triple { + val s = start(SeaScenario.GREAT_CANAL).copy( + phase = CatanPhase.PLAYING, + resources = List(3) { Resource.entries.associateWith { 9 } }, + ) + val tile = s.sea!!.canalNumbers.indexOfFirst { it != 0 } + val staged = s.copy( + buildings = s.buildings.toMutableList().also { + it[s.topo.HEX_CORNERS[tile][0]] = CatanBuilding(BuildingType.SETTLEMENT, 0) + }, + ) + return Triple(staged, tile, s) + } + + @Test + fun `a canal tile hides its number, and three digs turn it into fields`() { + var (s, tile, _) = canalGame() + assertEquals(0, s.numbers[tile]) + assertEquals(Terrain.DESERT, s.terrains[tile]) + val number = s.sea!!.canalNumbers[tile] + val dig = DigCanal(s.topo.HEXES[tile]) + assertTrue(dig in CatanRules.legalMoves(s, 0)) + assertTrue(CatanRules.legalMoves(s.copy(turn = 1), 1).none { it is DigCanal }) + val before = CatanRules.publicVictoryPoints(s)[0] + repeat(2) { s = CatanRules.applyMove(s, 0, dig) } + assertEquals(Terrain.DESERT, s.terrains[tile]) + assertEquals(before + 1, CatanRules.publicVictoryPoints(s)[0]) + s = CatanRules.applyMove(s, 0, dig) + assertEquals(Terrain.FIELDS, s.terrains[tile]) + assertEquals(number, s.numbers[tile]) + assertTrue(CatanRules.legalMoves(s, 0).none { it is DigCanal && it.hex == dig.hex }) + } } 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 e2be5b1..04a1cc5 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,9 @@ class CatanSeaScenariosTest { assertEquals(listOf(1, 1, 6, 6, 6, 6), sizes(SeaScenario.FOUR_ISLANDS)) assertEquals(listOf(1, 1, 1, 2, 25), sizes(SeaScenario.THROUGH_THE_DESERT)) assertEquals(listOf(2, 13, 14), sizes(SeaScenario.FOG_ISLANDS)) + assertEquals(listOf(1, 3, 4, 15), sizes(SeaScenario.TREASURE_ISLANDS)) + assertEquals(listOf(37), sizes(SeaScenario.OASES)) + assertEquals(listOf(2, 25), sizes(SeaScenario.GREAT_CANAL)) assertEquals(listOf(4, 5, 18), sizes(SeaScenario.PIRATE_ISLANDS)) assertEquals(listOf(1, 3, 12, 13), sizes(SeaScenario.WONDERS_OF_CATAN)) assertEquals(listOf(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN)) @@ -64,7 +67,8 @@ class CatanSeaScenariosTest { assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0) } val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN && - scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS + scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS && + scenario != SeaScenario.TREASURE_ISLANDS assertTrue("$scenario has no desert", deserts > 0 || !deserted) // Under fog every hex is face down with no number; the tiles are in the pile. val fog = s.terrains.indices.filter { s.terrains[it] == Terrain.FOG } @@ -219,6 +223,8 @@ class CatanSeaScenariosTest { var chipsTaken = 0 var fortsTaken = 0 var wonderStages = 0 + var chestsOpened = 0 + var canalDug = 0 for (scenario in SeaScenario.entries) { for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) { val opts = options(scenario, players) @@ -238,6 +244,10 @@ class CatanSeaScenariosTest { if (scenario == SeaScenario.FOG_ISLANDS) fogFound += 14 - state.sea!!.fogDeck.size if (scenario == SeaScenario.CLOTH_FOR_CATAN) clothPaid += state.sea!!.cloth.sum() if (scenario == SeaScenario.PIRATE_ISLANDS) fortsTaken += state.sea!!.raidPoints.sum() / 2 + if (scenario == SeaScenario.TREASURE_ISLANDS || scenario == SeaScenario.OASES) { + chestsOpened += state.log.count { "treasure chest" in it } + } + if (scenario == SeaScenario.GREAT_CANAL) canalDug += state.sea!!.canalDug.sum() if (scenario == SeaScenario.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum() if (scenario == SeaScenario.FORGOTTEN_TRIBE) { chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE } @@ -249,6 +259,8 @@ class CatanSeaScenariosTest { assertTrue("no village ever paid cloth", clothPaid > 0) assertTrue("no tribe chip was ever taken", chipsTaken > 0) assertTrue("no fortress ever fell", fortsTaken > 0) + assertTrue("no chest was ever opened", chestsOpened > 0) + assertTrue("no canal unit was ever dug", canalDug > 0) assertTrue("no wonder stage was ever built", wonderStages > 0) } } diff --git a/docs/RE-catan-universe.md b/docs/RE-catan-universe.md index 01d09d7..0bb0591 100644 --- a/docs/RE-catan-universe.md +++ b/docs/RE-catan-universe.md @@ -66,7 +66,13 @@ A full-width panel that slides up over the bottom of the screen (`Animator`, col * **Fog islands:** `Check_FogField`, `DistributeFogField` (hidden hex revealed when a road/ship reaches it, the player takes the resource), `Shuffle_Islands`, `Shuffle_TileValueList`. * **Treasure islands / Oasis / Great Canal** are Catan Universe scenario extras and are - **not** part of the physical Seafarers rules; they are out of scope here. + **not** part of the physical Seafarers rules. Names and texts only (rule values are compiled code): + `TreasureRule` with chest outcomes (bricks, wool, grain; a resource; a development card; two roads), + `SeafarerOasis` (no rule class of its own, base-game rules), and `GreatCanalRule` with `BuildCanal`, + `CanalVictoryPoints`, "The canal was completely dug. Now grain can be grown." and a target of 21 / 18 points. + The base-game scenario `baseharbormaster`: "2 victory points. This card goes to the first player to acquire at + least 3 harbors. If another player acquires more, he will immediately receive this card." + Implemented as inferred rules; see `docs/RULES-catan-seafarers.md`. * **Setup:** `StartPhase_BuildSettlement`, `StartPhase_BuildRoadShip`, `PlaceValueChip` (number tokens placed per scenario), `Shuffle_Harbors`. * **Island bonus:** special victory points for the first settlement on a new island diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index 1c5ef98..2682130 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -36,6 +36,11 @@ Pick the map at setup; each scenario names where the starting settlements may go fortress. See *Pirate Islands* below. * **Wonders of Catan.** A 13-hex island with a desert, a 12-hex island, and gold islets. See *Wonders* below. +* **The Treasure Islands** *(Catan Universe extra)*. A 15-hex home island and four fog isles, eight hexes of fog in + all. See *Catan Universe extras* below. +* **The Oases** *(Catan Universe extra)*. One 37-hex island that is mostly desert, with three oases hidden in it. +* **The Great Canal** *(Catan Universe extra)*. A 25-hex island split by a row of five canal tiles, and gold islets. + A *new island* for the bonus is any island that is not a starting island for that map. The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea, @@ -139,6 +144,30 @@ board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain 1 grain, 1 lumber; 4: 3 ore, 2 grain, 1 brick). Each stage built is **one victory point**, and the seat that builds the fourth stage **wins at once**, whatever the point target. Wonders are not shared: each seat has its own. +## Catan Universe extras (inferred) + +These three exist in the Catan Universe app but not in the physical set. Only their names, menu texts and log +lines could be read (`docs/RE-catan-universe.md`); the rule values are compiled code that was not recovered. **Every +rule below is inferred from those names and texts**, not copied. + +* **Treasure Islands.** The app's text: "Treasures await the Catanians on neighboring, fog-covered islands. + Discoveries are rewarded!" Its chest rewards (log texts and action names) are bricks, wool and grain; a resource of + choice; a development card; two roads. Here: up to five chests are dealt face down to random fog hexes at the + start. A seat that uncovers the hex (the fog rules) opens the chest at once and takes: *a brick, a wool and a grain*; + *one resource of its choice* (a gold pick); *a development card* (a resource of choice if the deck is empty or it is + Cities & Knights, which has no deck); or *two lumber and two brick* standing in for two free roads (the free-roads + phase would have to nest inside a ship move). The app's Cities & Knights version gives a progress card; that is + not modelled. The app's fixed treasure bonus (`SEAFARER_TREASURE_BONUS`) is not modelled either. +* **The Oases.** The app's text: an enormous desert with rumoured oases and treasures, base-game rules. Here: the + oases are fog hexes inside the desert, uncovered by a road reaching them, and hold the same chests as above. + There is no ship rule on the map itself. +* **The Great Canal.** The app's text, for Cities & Knights plus Seafarers: build a canal; first to 21 (3 players) or + 18 (4 players) wins; the canal's parts earn victory points; when a tile is dug out, grain grows there. Its knights dig + in the app; here digging is cheaper to implement and play without Cities & Knights: a canal tile is a desert hex with + its number token hidden. A seat with a settlement or city on a corner of the tile digs one **unit** for 1 brick and + 1 ore; **three units** finish the tile, which becomes **fields** with the hidden number. Every **two units** a seat + has dug are a **victory point**. The app's gold river, harbour changes and target scores are not modelled. + ## With Cities & Knights Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map @@ -161,8 +190,10 @@ Where the two meet, this game decides as follows: ## Not included The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**: -treasure islands, the oasis, the great canal, the Explorers & Pirates combination, +the Explorers & Pirates combination, the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships. +Drought and the app's other scenarios (Six Islands, Oceania, Great Catan, Enchanted Land, the Inka and Big Game sets) +are not built. A map-only scenario is a new text map and a new `SeaScenario` entry. ## How the AI plays it