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 5d1bc63..37041e8 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 @@ -277,6 +277,10 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi().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. legal.filterIsInstance() .filter { shipValue(state, seat, it.edge) > 0.5 } @@ -661,6 +665,11 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi + topo.edgeSea[e] && topo.EDGE_HEXES[e].any { sea.fortress.getOrElse(it) { 0 } > 0 } + } + ) best = maxOf(best, FORT_LURE * Math.pow(0.85, depth.toDouble())) if (CatanRules.vertexOpenForSettlement(state, v)) { val island = topo.vertexIsland[v] val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER @@ -693,6 +702,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi().firstOrNull()?.let { return it } + legal.filterIsInstance().firstOrNull()?.let { return it } + // Seafarers: a ship toward land nobody has settled is worth more than a card. legal.filterIsInstance() .filter { ai.shipValue(state, seat, it.edge) > 0.5 } 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 2cd5ac6..1ed0481 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 @@ -63,6 +63,12 @@ enum class Terrain(val produces: Resource?) { /** Seafarers, Cloth for Catan: a village. It pays cloth, not cards, to the settlements around it when its number rolls. */ VILLAGE(null), + + /** + * Seafarers, Pirate Islands: a pirate fortress. It stands until ships raid it (`SeaState.fortress`), and + * nobody settles its island meanwhile. Once taken it becomes desert. + */ + FORTRESS(null), } /** Axial coordinate of one hex on the board. See [CatanBoard]. */ @@ -495,6 +501,12 @@ data class SeaState( val harbourSeats: List = emptyList(), /** Victory points each seat holds from tribe chips; empty until one is taken. */ val chipPoints: List = emptyList(), + /** Strength still left in each hex's pirate fortress, by hex (Pirate Islands); empty when the map has none. */ + val fortress: List = emptyList(), + /** Victory points each seat holds for taking fortresses; empty until one falls. */ + 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(), ) /** What the first settler beside a tribe hex takes (Forgotten Tribe). */ @@ -541,3 +553,13 @@ data class MovePirate(val hex: HexCoord, val victim: Int? = null) : CatanMove @Serializable @SerialName("gold") data class PickGold(val cards: List) : CatanMove + +/** Raid the pirate fortress on [hex] from a ship beside it: 1 ore, 1 wool and 1 grain take one point of its strength. */ +@Serializable +@SerialName("raid") +data class RaidFortress(val hex: HexCoord) : CatanMove + +/** Build the next stage of your wonder, which needs a city of yours; the stage's cost is in `SeaRules.WONDER_STAGES`. */ +@Serializable +@SerialName("wonder") +data object BuildWonder : CatanMove 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 95d8705..2f20df9 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 @@ -124,6 +124,8 @@ object CatanRules : GameRules { cloth = if (it.villageCloth.isEmpty()) emptyList() else List(options.playerCount) { 0 }, villageCloth = it.villageCloth, chips = it.chips, + fortress = it.fortress, + wonders = if (scenario == SeaScenario.WONDERS_OF_CATAN) List(options.playerCount) { 0 } else emptyList(), ) }, ) @@ -251,7 +253,7 @@ object CatanRules : GameRules { private fun robberMoves(state: CatanState, seat: Int): List { val moves = mutableListOf() for (h in 0 until state.topo.HEX_COUNT) { - if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG) continue + if (h == state.robber || !state.topo.land[h] || state.terrains[h] == Terrain.FOG || state.terrains[h] == Terrain.FORTRESS) continue val victims = state.topo.HEX_CORNERS[h] .mapNotNull { state.buildings[it] } .map { it.owner } @@ -283,6 +285,7 @@ object CatanRules : GameRules { if (state.isSea) { if (affordable(state, seat, SeaRules.SHIP_COST)) moves += SeaRules.buildShipMoves(state, seat) moves += SeaRules.moveShipMoves(state, seat) + moves += SeaRules.scenarioMoves(state, seat) } if (!state.devCardPlayedThisTurn) { @@ -417,6 +420,7 @@ object CatanRules : GameRules { /** An empty vertex with no building on it or one edge away. */ internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = state.buildings[v] == null && !hasKnight(state, v) && state.topo.vertexLand[v] && + !SeaRules.fortressHolds(state, v) && state.topo.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } /** @@ -470,6 +474,8 @@ object CatanRules : GameRules { is MoveShip -> applyMoveShip(state, seat, move) is MovePirate -> applyMovePirate(state, seat, move) is PickGold -> applyPickGold(state, seat, move) + is RaidFortress -> SeaRules.applyRaid(state, seat, move) + BuildWonder -> SeaRules.applyWonder(state, seat) is BuildRoad -> applyBuildRoad(state, seat, move) is BuildSettlement -> applyBuildSettlement(state, seat, move) is BuildCity -> applyBuildCity(state, seat, move) 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 24748ce..2a90a1e 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.", ), + PIRATE_ISLANDS( + "Pirate Islands", + "Two isles are held by pirate fortresses. Sail ships beside one and raid it with ore, wool and grain; " + + "nobody may settle the isle until the fortress falls, and the one who takes it earns two points.", + ), + WONDERS_OF_CATAN( + "Wonders of Catan", + "Two big islands and room to grow. Raise a city, then build a wonder in four stages; each stage is a " + + "victory point and the first to finish all four wins.", + ), CLOTH_FOR_CATAN( "Cloth for Catan", "Four villages weave cloth. Settle beside one and it pays you a cloth token whenever its number rolls; " + @@ -126,11 +136,36 @@ object SeaBoards { "............", ) + /** `P` is a pirate fortress. */ + private val PIRATE_ISLANDS_MAP = listOf( + "............", + "..####.P##..", + "..####..##..", + "..####......", + "..####..P#..", + "...##...##..", + "............", + "............", + ) + + private val WONDERS_OF_CATAN_MAP = listOf( + "............", + "..####.###..", + ".#####.####.", + "..###D.###..", + ".......##...", + "..$......$..", + "...##.......", + "............", + ) + private fun rows(scenario: SeaScenario): List = when (scenario) { SeaScenario.NEW_SHORES -> NEW_SHORES_MAP SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP SeaScenario.FOG_ISLANDS -> FOG_ISLANDS_MAP + SeaScenario.PIRATE_ISLANDS -> PIRATE_ISLANDS_MAP + SeaScenario.WONDERS_OF_CATAN -> WONDERS_OF_CATAN_MAP SeaScenario.CLOTH_FOR_CATAN -> CLOTH_FOR_CATAN_MAP SeaScenario.FORGOTTEN_TRIBE -> FORGOTTEN_TRIBE_MAP } @@ -178,6 +213,8 @@ object SeaBoards { val chips: List = emptyList(), /** Cloth waiting on each village, by hex; empty when the map has none. */ val villageCloth: List = emptyList(), + /** Strength of the fortress on each hex, by hex; empty when the map has none. */ + val fortress: List = emptyList(), ) /** An island of this many land hexes or more can be started on. */ @@ -186,6 +223,9 @@ object SeaBoards { /** Cloth each village starts with. */ const val VILLAGE_CLOTH = 6 + /** Ships' raids a pirate fortress takes to fall. */ + const val FORTRESS_STRENGTH = 3 + private val RESOURCE_TILES = listOf( Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS, ) @@ -209,13 +249,14 @@ object SeaBoards { 'D' -> Terrain.DESERT '#', 't' -> pool[plain.indexOf(h)] 'V' -> Terrain.VILLAGE + 'P' -> Terrain.FORTRESS 'F' -> Terrain.FOG else -> Terrain.SEA } } // The fog hexes take part as slots, so the tokens they hide are dealt from the same pile. - val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT } + val numbered = terrains.indices.filter { terrains[it] != Terrain.SEA && terrains[it] != Terrain.DESERT && terrains[it] != Terrain.FORTRESS } check(numbered.size <= TOKENS.size) { "Not enough number tokens for ${numbered.size} hexes" } val tokens = TOKENS.take(numbered.size).shuffled(random) val numbers = IntArray(terrains.size) @@ -287,6 +328,12 @@ object SeaBoards { List(terrains.size) { h -> if (terrains[h] == Terrain.VILLAGE) VILLAGE_CLOTH else 0 } } - return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages) + val fortress = if (kinds.none { it == 'P' }) { + emptyList() + } else { + List(terrains.size) { h -> if (terrains[h] == Terrain.FORTRESS) FORTRESS_STRENGTH else 0 } + } + + return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts, fogDeck, chips, villages, fortress) } } 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 594cdc8..e1b6dd6 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 @@ -205,6 +205,8 @@ internal object SeaRules { fun bonusPoints(state: CatanState, seat: Int): Int { val sea = state.sea ?: return 0 return sea.islandBonus.count { it == seat } * ISLAND_BONUS + + (sea.raidPoints.getOrNull(seat) ?: 0) + + (sea.wonders.getOrNull(seat) ?: 0) + clothOf(state, seat) / CLOTH_PER_POINT + (sea.chipPoints.getOrNull(seat) ?: 0) } @@ -252,6 +254,101 @@ internal object SeaRules { return s } + // ----------------------------------------------------------------------- + // Pirate Islands + // ----------------------------------------------------------------------- + + val RAID_COST: Map = mapOf(Resource.ORE to 1, Resource.WOOL to 1, Resource.GRAIN to 1) + + const val FORTRESS_POINTS = 2 + + /** Whether a pirate fortress still stands on the island [vertex] is part of, so nobody may settle there. */ + fun fortressHolds(state: CatanState, vertex: Int): Boolean { + val sea = state.sea ?: return false + if (sea.fortress.isEmpty()) return false + val island = state.topo.vertexIsland[vertex] + return island >= 0 && sea.fortress.indices.any { sea.fortress[it] > 0 && state.topo.islandOf[it] == island } + } + + /** Fortress hexes [seat] has a ship beside. */ + private fun raidTargets(state: CatanState, seat: Int): List { + val sea = state.sea!! + if (sea.fortress.isEmpty()) return emptyList() + val topo = state.topo + return sea.fortress.indices.filter { h -> + sea.fortress[h] > 0 && (0 until topo.EDGE_COUNT).any { h in topo.EDGE_HEXES[it] && sea.ships[it] == seat } + } + } + + fun applyRaid(state: CatanState, seat: Int, move: RaidFortress): CatanState { + val sea = state.sea!! + val hex = state.topo.hexIndex.getValue(move.hex) + val left = sea.fortress[hex] - 1 + var s = state.copy(resources = CatanRules.withCounts(state.resources, seat, RAID_COST, -1)) + val strength = sea.fortress.toMutableList().also { it[hex] = left } + if (left > 0) { + return s.copy( + sea = sea.copy(fortress = strength), + log = s.log + "Seat $seat raids the fortress at (${move.hex.q},${move.hex.r}); $left strength left.", + ) + } + val points = sea.raidPoints.ifEmpty { List(state.playerCount) { 0 } }.toMutableList().also { it[seat] += FORTRESS_POINTS } + s = s.copy( + terrains = s.terrains.toMutableList().also { it[hex] = Terrain.DESERT }, + sea = sea.copy(fortress = strength, raidPoints = points), + log = s.log + "Seat $seat takes the fortress at (${move.hex.q},${move.hex.r}): +$FORTRESS_POINTS victory points, " + + "and the island is open.", + ) + return CatanRules.checkWin(s) + } + + // ----------------------------------------------------------------------- + // Wonders of Catan + // ----------------------------------------------------------------------- + + /** What each of the four stages of a wonder costs. */ + val WONDER_STAGES: List> = listOf( + mapOf(Resource.BRICK to 2, Resource.LUMBER to 1, Resource.GRAIN to 1), + mapOf(Resource.BRICK to 1, Resource.ORE to 2, Resource.WOOL to 1), + mapOf(Resource.ORE to 3, Resource.GRAIN to 1, Resource.LUMBER to 1), + mapOf(Resource.ORE to 3, Resource.GRAIN to 2, Resource.BRICK to 1), + ) + + /** The next stage [seat] could build, if the scenario has wonders and the seat has a city to raise it in. */ + private fun nextStage(state: CatanState, seat: Int): Map? { + val stage = state.sea?.wonders?.getOrNull(seat) ?: return null + if (stage >= WONDER_STAGES.size) return null + if (state.buildings.none { it?.owner == seat && it.type == BuildingType.CITY }) return null + return WONDER_STAGES[stage] + } + + /** The extra moves a scenario offers during play: raids and wonder stages. */ + fun scenarioMoves(state: CatanState, seat: Int): List { + val moves = mutableListOf() + if (CatanRules.affordable(state, seat, RAID_COST)) { + raidTargets(state, seat).forEach { moves += RaidFortress(state.topo.HEXES[it]) } + } + nextStage(state, seat)?.let { if (CatanRules.affordable(state, seat, it)) moves += BuildWonder } + return moves + } + + fun applyWonder(state: CatanState, seat: Int): CatanState { + val sea = state.sea!! + val stage = sea.wonders[seat] + 1 + val built = sea.wonders.toMutableList().also { it[seat] = stage } + val s = state.copy( + resources = CatanRules.withCounts(state.resources, seat, WONDER_STAGES[stage - 1], -1), + sea = sea.copy(wonders = built), + log = state.log + "Seat $seat builds stage $stage of a wonder.", + ) + if (stage < WONDER_STAGES.size) return CatanRules.checkWin(s) + return s.copy( + phase = CatanPhase.GAME_OVER, + winner = seat, + log = s.log + "Seat $seat completes a wonder of Catan and wins.", + ) + } + // ----------------------------------------------------------------------- // The Forgotten Tribe // ----------------------------------------------------------------------- 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 49d3f1d..6d9532a 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 @@ -170,6 +170,7 @@ private fun DrawScope.drawHexes( val sea = state.sea val note = when { state.terrains[hex] == Terrain.VILLAGE && sea != null -> "${sea.villageCloth.getOrElse(hex) { 0 }} cloth" + state.terrains[hex] == Terrain.FORTRESS && sea != null -> "fort ${sea.fortress.getOrElse(hex) { 0 }}" else -> when (sea?.chips?.getOrNull(hex)) { TribeChip.POINT -> "+1 VP" TribeChip.HARBOUR -> "3:1" 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 933980c..31fc508 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 @@ -173,6 +173,8 @@ object CatanHudGlyphs { BuildKind.WALL -> "▦" BuildKind.KNIGHT -> "♞" BuildKind.SHIP -> "⛵" + BuildKind.RAID -> "⚔" + BuildKind.WONDER -> "✦" } fun label(kind: BuildKind): String = when (kind) { @@ -182,6 +184,8 @@ object CatanHudGlyphs { BuildKind.WALL -> CatanText.WALL BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP + BuildKind.RAID -> CatanText.RAID + BuildKind.WONDER -> CatanText.WONDER } } 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 80b486e..f83f73d 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 @@ -6,7 +6,9 @@ import org.prolibertate.games.game.catan.ActivateKnight import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildRoad import org.prolibertate.games.game.catan.BuildShip +import org.prolibertate.games.game.catan.BuildWonder import org.prolibertate.games.game.catan.MovePirate +import org.prolibertate.games.game.catan.RaidFortress import org.prolibertate.games.game.catan.MoveShip import org.prolibertate.games.game.catan.Topology import org.prolibertate.games.game.catan.BuildSettlement @@ -67,13 +69,34 @@ enum class BuildKind { ROAD, SETTLEMENT, CITY, WALL, KNIGHT, /** Seafarers: a ship, laid on a sea edge. */ - SHIP; + SHIP, + + /** Pirate Islands: raid a fortress; the target is the fortress hex. */ + RAID, + + /** Wonders of Catan: the next stage of the wonder, which needs no tap on the board. */ + WONDER; companion object { val BASE: List = listOf(ROAD, SETTLEMENT, CITY) /** Seafarers adds the ship, listed after the road it sails beside. */ val SEAFARERS: List = listOf(ROAD, SHIP, SETTLEMENT, CITY) + + /** The kinds a table's build menu lists: the scenario's own raid or wonder follows the usual ones. */ + fun of(state: CatanState): List { + val sea = state.sea + val extras = buildList { + if (sea != null && sea.fortress.isNotEmpty()) add(RAID) + if (sea != null && sea.wonders.isNotEmpty()) add(WONDER) + } + val usual = when { + state.isCk -> entries.filter { it != SHIP || state.isSea } - listOf(RAID, WONDER) + state.isSea -> SEAFARERS + else -> BASE + } + return usual + extras + } } } @@ -252,6 +275,10 @@ object CatanInteraction { return when (building) { BuildKind.ROAD -> BoardTargets(edges = roads) BuildKind.SHIP -> BoardTargets(edges = ships) + BuildKind.RAID -> BoardTargets( + hexes = legal.filterIsInstance().mapNotNull { state.topo.hexIndex[it.hex] }.toSet(), + ) + BuildKind.WONDER -> BoardTargets() BuildKind.SETTLEMENT -> BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) @@ -519,6 +546,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) } return when { moves.isNotEmpty() -> if (moves.size == 1) { TapOutcome.Submit(moves.first()) @@ -586,6 +614,8 @@ object CatanInteraction { if (legal.any { it is BuildShip }) add(BuildKind.SHIP) if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT) if (legal.any { it is BuildCity }) add(BuildKind.CITY) + if (legal.any { it is RaidFortress }) add(BuildKind.RAID) + if (legal.any { it is BuildWonder }) add(BuildKind.WONDER) if (legal.any { it is BuildWall }) add(BuildKind.WALL) if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT) } 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 779b0df..00a4812 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 @@ -600,6 +600,8 @@ private fun buildLabel(kind: BuildKind): String = when (kind) { BuildKind.WALL -> CatanText.WALL BuildKind.KNIGHT -> CatanText.KNIGHT BuildKind.SHIP -> CatanText.SHIP + BuildKind.RAID -> CatanText.RAID + BuildKind.WONDER -> CatanText.WONDER } private fun buildCost(kind: BuildKind): String = when (kind) { @@ -609,6 +611,8 @@ private fun buildCost(kind: BuildKind): String = when (kind) { BuildKind.WALL -> CatanText.WALL_COST BuildKind.KNIGHT -> CatanText.KNIGHT_COST BuildKind.SHIP -> CatanText.SHIP_COST_TEXT + BuildKind.RAID -> CatanText.RAID_COST + BuildKind.WONDER -> CatanText.WONDER_COST } @Composable @@ -642,6 +646,7 @@ private fun seaAwards(state: CatanState, seat: Int, compact: Boolean): List Unit = { move -> controller.submit(move) building = null @@ -197,11 +206,7 @@ fun CatanScreen( val progressHand = CatanInteraction.progressHand(current, localSeat, legal) val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val actions = CatanActions( - buildKinds = when { - current.isCk -> BuildKind.entries.filter { it != BuildKind.SHIP || current.isSea } - current.isSea -> BuildKind.SEAFARERS - else -> BuildKind.BASE - }, + buildKinds = BuildKind.of(current), ck = current.isCk, canImprove = CatanInteraction.improvable(legal).isNotEmpty(), canPlayProgress = progressHand.any { it.playable }, 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 1d6c65b..273df93 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 @@ -128,6 +128,8 @@ private val LightStyle = CatanStyle( Terrain.FOG to Color(0xFFB9C2C9), // Cloth villages: a dyed-cloth purple. Terrain.VILLAGE to Color(0xFF9B6BB5), + // A pirate fortress: dark stone. + Terrain.FORTRESS to Color(0xFF4A4F57), ), hexEdge = Color(0x55000000), token = Color(0xFFF7EFD9), 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 188a792..038e6a3 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 @@ -76,6 +76,7 @@ object CatanText { Terrain.GOLD -> "Gold field" Terrain.FOG -> "Fog" Terrain.VILLAGE -> "Village" + Terrain.FORTRESS -> "Pirate fortress" } fun devCard(type: DevCardType): String = when (type) { @@ -115,6 +116,10 @@ object CatanText { const val SEA_ROBBER = "Move the robber (tap a land hex) or the pirate (tap a sea hex)." const val SHIP = "Ship" const val SHIP_COST_TEXT = "1 lumber + 1 wool" + const val RAID = "Raid" + const val RAID_COST = "1 ore + 1 wool + 1 grain" + const val WONDER = "Wonder" + const val WONDER_COST = "next stage" const val GOLD_TITLE = "Gold field" const val GOLD_TAKE = "Take" const val ROLL = "Roll dice" @@ -248,6 +253,7 @@ object CatanText { !mine -> "$turn is playing…" movingShip -> "Tap the sea edge to sail the ship to." building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship." + building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it." building == BuildKind.ROAD -> "Tap an edge to build a road." building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." @@ -452,6 +458,7 @@ object CatanText { movingKnight -> "Tap where the knight should go." movingShip -> "Tap the sea edge to sail the ship to." building == BuildKind.SHIP -> "Tap a sea edge beside your fleet or a harbour town to launch a ship." + building == BuildKind.RAID -> "Tap a pirate fortress your ship lies beside to raid it." building == BuildKind.ROAD -> "Tap an edge to build a road." building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement." building == BuildKind.CITY -> "Tap one of your settlements to upgrade it." 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 700c3e9..f54e52f 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 @@ -165,4 +165,80 @@ class CatanSeaExtrasTest { val done = CatanRules.applyMove(after, 0, CatanRules.legalMoves(after, 0).first()) assertEquals(CatanPhase.PLAYING, done.phase) } + + // ---- Pirate Islands ---- + + private fun fortGame(): Triple { + val s = start(SeaScenario.PIRATE_ISLANDS).copy( + phase = CatanPhase.PLAYING, + resources = List(3) { Resource.entries.associateWith { 6 } }, + ) + val fort = s.terrains.indexOf(Terrain.FORTRESS) + val edge = (0 until s.topo.EDGE_COUNT).first { s.topo.edgeSea[it] && fort in s.topo.EDGE_HEXES[it] } + return Triple(s, fort, edge) + } + + @Test + fun `nobody settles a fortress island until the fortress falls`() { + val (s, fort, _) = fortGame() + val corner = s.topo.HEX_CORNERS[fort].first() + assertTrue(SeaRules.fortressHolds(s, corner)) + assertTrue(!CatanRules.vertexOpenForSettlement(s, corner)) + val fort2 = s.terrains.indices.first { s.terrains[it] == Terrain.FORTRESS && it != fort } + assertTrue(s.topo.islandOf[fort] != s.topo.islandOf[fort2]) + } + + @Test + fun `a raid needs a ship beside the fortress and three cards, and three raids take it`() { + val (s, fort, edge) = fortGame() + val coord = s.topo.HEXES[fort] + assertTrue(CatanRules.legalMoves(s, 0).none { it is RaidFortress }) + var state = s.copy(sea = s.sea!!.copy(ships = s.sea!!.ships.toMutableList().also { it[edge] = 0 })) + assertTrue(RaidFortress(coord) in CatanRules.legalMoves(state, 0)) + val before = state.resources[0].getValue(Resource.ORE) + repeat(3) { state = CatanRules.applyMove(state, 0, RaidFortress(coord)) } + assertEquals(before - 3, state.resources[0].getValue(Resource.ORE)) + assertEquals(Terrain.DESERT, state.terrains[fort]) + assertEquals(2, state.sea!!.raidPoints[0]) + assertTrue(!SeaRules.fortressHolds(state, s.topo.HEX_CORNERS[fort].first())) + assertTrue(CatanRules.legalMoves(state, 0).none { it is RaidFortress }) + // Seat 1 has no ship there and cannot raid. + assertTrue(CatanRules.legalMoves(state.copy(turn = 1), 1).none { it is RaidFortress }) + } + + // ---- Wonders of Catan ---- + + private fun wonderGame(city: Boolean = true): CatanState { + val s = start(SeaScenario.WONDERS_OF_CATAN).copy( + phase = CatanPhase.PLAYING, + resources = List(3) { Resource.entries.associateWith { 9 } }, + ) + return if (!city) s else s.copy( + buildings = s.buildings.toMutableList().also { it[0] = CatanBuilding(BuildingType.CITY, 0) }, + ) + } + + @Test + fun `a wonder needs a city, each stage is a point, and the fourth stage wins`() { + assertTrue(CatanRules.legalMoves(wonderGame(city = false), 0).none { it is BuildWonder }) + var s = wonderGame() + val base = CatanRules.publicVictoryPoints(s)[0] + for (stage in 1..3) { + assertTrue(BuildWonder in CatanRules.legalMoves(s, 0)) + s = CatanRules.applyMove(s, 0, BuildWonder) + assertEquals(stage, s.sea!!.wonders[0]) + assertEquals(base + stage, CatanRules.publicVictoryPoints(s)[0]) + assertEquals(CatanPhase.PLAYING, s.phase) + } + s = CatanRules.applyMove(s, 0, BuildWonder) + assertEquals(CatanPhase.GAME_OVER, s.phase) + assertEquals(0, s.winner) + assertEquals(s, CatanRules.decodeState(CatanRules.encodeState(s))) + } + + @Test + fun `a wonder stage is not offered without the cards`() { + val s = wonderGame().copy(resources = List(3) { Resource.entries.associateWith { 0 } }) + assertTrue(CatanRules.legalMoves(s, 0).none { it is BuildWonder }) + } } 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 ee83e03..e2be5b1 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(4, 5, 18), sizes(SeaScenario.PIRATE_ISLANDS)) + assertEquals(listOf(1, 3, 12, 13), sizes(SeaScenario.WONDERS_OF_CATAN)) assertEquals(listOf(2, 3, 6, 17), sizes(SeaScenario.CLOTH_FOR_CATAN)) assertEquals(listOf(2, 3, 5, 17), sizes(SeaScenario.FORGOTTEN_TRIBE)) } @@ -57,11 +59,12 @@ class CatanSeaScenariosTest { for (h in 0 until topo.HEX_COUNT) { val land = s.terrains[h] != Terrain.SEA assertEquals("$scenario hex $h", topo.land[h], land) - val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG + val unnumbered = s.terrains[h] == Terrain.DESERT || s.terrains[h] == Terrain.FOG || + s.terrains[h] == Terrain.FORTRESS assertEquals("$scenario hex $h", land && !unnumbered, s.numbers[h] != 0) } val deserted = scenario != SeaScenario.FOUR_ISLANDS && scenario != SeaScenario.CLOTH_FOR_CATAN && - scenario != SeaScenario.FORGOTTEN_TRIBE + scenario != SeaScenario.FORGOTTEN_TRIBE && scenario != SeaScenario.PIRATE_ISLANDS assertTrue("$scenario has no desert", deserts > 0 || !deserted) // Under fog every hex is face down with no number; the tiles are in the pile. val fog = s.terrains.indices.filter { s.terrains[it] == Terrain.FOG } @@ -214,6 +217,8 @@ class CatanSeaScenariosTest { var fogFound = 0 var clothPaid = 0 var chipsTaken = 0 + var fortsTaken = 0 + var wonderStages = 0 for (scenario in SeaScenario.entries) { for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) { val opts = options(scenario, players) @@ -232,6 +237,8 @@ class CatanSeaScenariosTest { assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase) if (scenario == SeaScenario.FOG_ISLANDS) fogFound += 14 - state.sea!!.fogDeck.size if (scenario == SeaScenario.CLOTH_FOR_CATAN) clothPaid += state.sea!!.cloth.sum() + if (scenario == SeaScenario.PIRATE_ISLANDS) fortsTaken += state.sea!!.raidPoints.sum() / 2 + if (scenario == SeaScenario.WONDERS_OF_CATAN) wonderStages += state.sea!!.wonders.sum() if (scenario == SeaScenario.FORGOTTEN_TRIBE) { chipsTaken += 4 - state.sea!!.chips.count { it != TribeChip.NONE } } @@ -241,5 +248,7 @@ class CatanSeaScenariosTest { assertTrue("no engine ever uncovered a fog hex", fogFound > 0) assertTrue("no village ever paid cloth", clothPaid > 0) assertTrue("no tribe chip was ever taken", chipsTaken > 0) + assertTrue("no fortress ever fell", fortsTaken > 0) + assertTrue("no wonder stage was ever built", wonderStages > 0) } } diff --git a/docs/RULES-catan-seafarers.md b/docs/RULES-catan-seafarers.md index 1b1cbbd..1c5ef98 100644 --- a/docs/RULES-catan-seafarers.md +++ b/docs/RULES-catan-seafarers.md @@ -32,6 +32,10 @@ Pick the map at setup; each scenario names where the starting settlements may go * **The Forgotten Tribe.** A 17-hex home island and three isles carrying four tribe chips. See *The Forgotten Tribe* below. +* **Pirate Islands.** An 18-hex home island and two isles (five and four hexes) each held by a pirate + fortress. See *Pirate Islands* below. +* **Wonders of Catan.** A 13-hex island with a desert, a 12-hex island, and gold islets. See *Wonders* below. + A *new island* for the bonus is any island that is not a starting island for that map. The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea, @@ -115,6 +119,26 @@ board). The chips are dealt at the start of the game, one of each kind in turn: A **gold** chip pays two resources of the settler's choice, taken at once like gold. * The chips come on top of the island bonus for being first to a new island. +## Pirate Islands + +The author's reading of the scenario; the printed one uses knights and a roaming pirate fleet, which this does not. + +* A **fortress** is a land hex with no number and a strength of 3. Nobody may build a settlement on any crossing of + an island that still has a standing fortress. Roads and ships may go there. +* A seat with a **ship beside** a fortress may **raid** it in its turn for 1 ore, 1 wool and 1 grain, as many times as + it can pay. Each raid takes one point of strength. +* The raid that takes the last point **takes the fortress**: the hex becomes desert (no number, robber may sit there), + the island is open to settle for everyone, and the taker gets **2 victory points**. The robber never sits on a + standing fortress. +* The island still counts as a new island for the first-landing bonus. + +## Wonders + +Also the author's reading. Every seat may build one wonder in four stages, once it has a **city** anywhere on the +board. Each stage is one move costing cards (stage 1: 2 brick, 1 lumber, 1 grain; 2: 1 brick, 2 ore, 1 wool; 3: 3 ore, +1 grain, 1 lumber; 4: 3 ore, 2 grain, 1 brick). Each stage built is **one victory point**, and the seat that +builds the fourth stage **wins at once**, whatever the point target. Wonders are not shared: each seat has its own. + ## With Cities & Knights Switching both expansions on plays [Cities & Knights](RULES-catan-ck.md) on any Seafarers map @@ -138,7 +162,7 @@ Where the two meet, this game decides as follows: The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**: treasure islands, the oasis, the great canal, the Explorers & Pirates combination, -the scenarios that need new rules (wonders, pirate islands) and trade ships. +the printed rules of the wonders and pirate-islands scenarios (the versions here are simplified) and trade ships. A map-only scenario is a new text map and a new `SeaScenario` entry. ## How the AI plays it @@ -146,5 +170,6 @@ A map-only scenario is a new text map and a new `SeaScenario` entry. It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that bonus is in play. A spot beside a village that still has cloth, or beside a tribe chip nobody has taken, is -worth more (`CatanAi.scenarioLure`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does +worth more (`CatanAi.scenarioLure`), a ship that heads for a standing fortress is worth building, and it raids and builds +wonder stages whenever it can pay (`CatanAi.FORT_LURE`). It chooses the resource it values most from gold, always moves the robber (never the pirate) and does not sail ships it has already launched. See `CatanAi.shipValue`.