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 7bd715c..721fe9c 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 @@ -12,6 +12,11 @@ const val LONGEST_ROAD_MIN = 5 /** Knights required to take Largest Army. */ const val LARGEST_ARMY_MIN = 3 +/** Pieces each player has: a player who has laid them all cannot build another. */ +const val MAX_ROADS = 15 +const val MAX_SETTLEMENTS = 5 +const val MAX_CITIES = 4 + /** Resource cards of each type the bank starts with. */ const val BANK_PER_RESOURCE = 19 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 a47156f..82fdb1d 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 @@ -272,11 +272,18 @@ object CatanRules : GameRules { return takes } - /** Road placements that connect to [seat]'s network, cost aside. */ + /** + * Road placements that connect to [seat]'s network, cost aside. A player + * with all fifteen roads laid has none. + */ private fun roadMoves(state: CatanState, seat: Int): List = - (0 until CatanBoard.EDGE_COUNT) - .filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) } - .map { BuildRoad(it) } + if (state.roads.count { it == seat } >= MAX_ROADS) { + emptyList() + } else { + (0 until CatanBoard.EDGE_COUNT) + .filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) } + .map { BuildRoad(it) } + } /** * Settlement placements: an open vertex at least two edges from every @@ -285,16 +292,28 @@ object CatanRules : GameRules { * own move.) */ private fun settlementVertices(state: CatanState, seat: Int): List = - (0 until CatanBoard.VERTEX_COUNT).filter { v -> - vertexOpenForSettlement(state, v) && - CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } + if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) { + emptyList() + } else { + (0 until CatanBoard.VERTEX_COUNT).filter { v -> + vertexOpenForSettlement(state, v) && + CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } + } } private fun cityVertices(state: CatanState, seat: Int): List = - (0 until CatanBoard.VERTEX_COUNT).filter { v -> - state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) + if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) { + emptyList() + } else { + (0 until CatanBoard.VERTEX_COUNT).filter { v -> + state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) + } } + /** How many settlements or cities [seat] currently has standing. */ + private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int = + state.buildings.count { it?.owner == seat && it.type == type } + private fun tradeResponses(state: CatanState, seat: Int): List { val offer = state.pendingTrade ?: return emptyList() val responses = mutableListOf() @@ -310,13 +329,25 @@ object CatanRules : GameRules { state.buildings[v] == null && CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } - /** A road on [edge] would join [seat]'s network of roads and buildings. */ + /** + * A road on [edge] would join [seat]'s network of roads and buildings. + * + * It must start from a crossing the network actually reaches. An opponent's + * settlement or city on a crossing breaks the network there: roads on either + * side of it do not join, so a road cannot be laid *past* it, even with one + * of your own roads ending at the same crossing. + */ private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean { val (a, b) = CatanBoard.EDGE_VERTICES[edge] - return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } || - CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } || - state.buildings[a]?.owner == seat || - state.buildings[b]?.owner == seat + return networkReaches(state, seat, a) || networkReaches(state, seat, b) + } + + /** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */ + private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean { + val building = state.buildings[vertex] + // Your own building is always a way in; anyone else's is a wall. + if (building != null) return building.owner == seat + return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat } } private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean { 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 65fd78d..eaf93f9 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 @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -28,7 +29,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prolibertate.games.game.catan.CatanPhase @@ -118,11 +121,18 @@ fun CatanHand( /** * One player's public standing: colour, name, points, how many cards they hold - * (not which), development cards, knights and their longest road. + * (not which), and — in the roomier layout — development cards, knights and + * their longest road. + * + * **Fixed height.** Every line is always present (blank when there is nothing to + * say) and never wraps, so the card is the same height whatever the game is + * doing. The board is given whatever room is left, so anything that made this + * card taller or shorter would rescale the island under the player's thumb. * * [points] is passed in rather than computed here because the local player's * own count includes hidden victory-point cards and everyone else's does not; - * the screen decides which. + * the screen decides which. [compact] is the narrow portrait strip (four cards + * across a phone); the default has room for the detail lines. */ @Composable fun CatanPlayerCard( @@ -132,6 +142,7 @@ fun CatanPlayerCard( points: Int, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current, + compact: Boolean = false, ) { val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER val shape = RoundedCornerShape(12.dp) @@ -155,41 +166,56 @@ fun CatanPlayerCard( .background(style.seats[seat % style.seats.size]) .border(1.dp, style.pieceOutline, CircleShape), ) - Text( - text = seats.label(seat), - fontWeight = FontWeight.Bold, - maxLines = 1, - style = MaterialTheme.typography.labelLarge, + CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold) + } + CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold) + if (compact) { + CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall) + } else { + CardLine( + "${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev", + MaterialTheme.typography.bodySmall, + ) + CardLine( + "Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}", + MaterialTheme.typography.bodySmall, ) } - Text( - text = "$points VP", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = "${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev", - style = MaterialTheme.typography.bodySmall, - ) - Text( - text = "Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}", - style = MaterialTheme.typography.bodySmall, - ) val awards = buildList { - if (state.longestRoadOwner == seat) add("Longest road") - if (state.largestArmyOwner == seat) add("Largest army") - } - if (awards.isNotEmpty()) { - Text( - text = awards.joinToString(" · "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) + if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road") + if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army") } + // Always drawn, so the card does not grow when somebody takes an award. + CardLine( + text = awards.joinToString(" · ").ifEmpty { BLANK }, + style = MaterialTheme.typography.labelSmall, + weight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) } } +/** A non-breaking space: a line of text with nothing in it, still one line tall. */ +private const val BLANK = "\u00A0" + +/** One line of a player card: never wraps, so the card's height cannot change. */ +@Composable +private fun CardLine( + text: String, + style: TextStyle, + weight: FontWeight? = null, + color: Color = Color.Unspecified, +) { + Text( + text = text, + style = style, + fontWeight = weight, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + // --------------------------------------------------------------------------- // Dice // --------------------------------------------------------------------------- @@ -221,13 +247,12 @@ fun CatanDice(dice: List, modifier: Modifier = Modifier) { ) } } - if (dice.size == 2) { - Text( - text = "= ${dice.sum()}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } + // Always drawn ("= –" before the first roll) so the row never changes width. + Text( + text = if (dice.size == 2) "= ${dice.sum()}" else "= –", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) } } @@ -238,7 +263,6 @@ fun CatanDice(dice: List, modifier: Modifier = Modifier) { /** What the action bar offers right now. Built by the screen from [CatanInteraction]. */ data class CatanActions( val canRoll: Boolean = false, - val showTurnActions: Boolean = false, val canBuild: Set = emptySet(), val building: BuildKind? = null, val canBuyCard: Boolean = false, @@ -250,7 +274,16 @@ data class CatanActions( val canRepickSetup: Boolean = false, ) -/** The buttons for whatever the player can do now. Pure display: every press is a lambda. */ +/** + * The buttons for whatever the player can do now. Pure display: every press is a lambda. + * + * **The bar never changes size.** The top row holds the one "primary" action + * (roll, end turn, done with roads, cancel) and keeps its height when it is + * empty; the second row is always all six buttons, greyed out when they do not + * apply. Buttons appearing and disappearing used to resize this area and, with + * it, the board above — so nothing here may be conditionally *present*, only + * conditionally *enabled*. + */ @OptIn(ExperimentalLayoutApi::class) @Composable fun CatanActionBar( @@ -265,33 +298,42 @@ fun CatanActionBar( onRepickSetup: () -> Unit, modifier: Modifier = Modifier, ) { - FlowRow( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) } - - if (actions.canRepickSetup) { - OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) } + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) } + if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) } + if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) } + if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } } - if (actions.showTurnActions) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) } - OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { Text(CatanText.PLAY_CARD) } - OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { Text(CatanText.TRADE) } + OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { + LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST) + } + OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { + LabelWithCost(CatanText.TRADE, CatanText.NO_COST) + } } - - if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) } - if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } } } +/** Room for one button, held whether or not there is one to show. */ +private val PRIMARY_ROW_HEIGHT = 48.dp + @Composable private fun BuildButton( kind: BuildKind, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt index 898a03f..5df15d1 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt @@ -112,14 +112,13 @@ private fun PanelsPreview() { Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { (0 until state.playerCount).forEach { seat -> - CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f)) + CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f), compact = true) } } CatanHand(state, seat = 0) CatanDice(state.lastDice) CatanActionBar( actions = CatanActions( - showTurnActions = true, canBuild = setOf(BuildKind.ROAD, BuildKind.CITY), building = BuildKind.ROAD, canBuyCard = true, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 7c58230..3e97876 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.prolibertate.games.game.catan.AcceptTrade import org.prolibertate.games.game.catan.BuyDevCard @@ -130,7 +132,6 @@ fun CatanScreen( val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val actions = CatanActions( canRoll = CatanInteraction.canRoll(legal), - showTurnActions = playing, canBuild = CatanInteraction.canBuild(legal), building = building, canBuyCard = CatanInteraction.canBuyDevCard(legal), @@ -275,6 +276,7 @@ private fun PlayerCards( points = pointsOf(seat), modifier = Modifier.weight(1f), style = style, + compact = !twoPerRow, ) } // Keep an odd last row's card the same width as the others. @@ -284,6 +286,14 @@ private fun PlayerCards( } } +/** + * The one line telling the player what is going on, and the dice. + * + * **Fixed height.** The text always occupies exactly two lines (padded when it is + * shorter, clipped when it is longer) and a refused move replaces the prompt + * rather than adding to it, so this row is the same height whatever is said and + * the board below it never moves. + */ @Composable private fun PromptRow(prompt: String, notice: String?, state: CatanState) { Row( @@ -291,16 +301,15 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text(prompt, style = MaterialTheme.typography.bodyMedium) - if (notice != null) { - Text( - notice, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } + Text( + text = notice ?: prompt, + style = MaterialTheme.typography.bodyMedium, + color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) CatanDice(state.lastDice) } } 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 bd40f76..2b83a41 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 @@ -103,6 +103,12 @@ object CatanText { const val CONFIRM = "Confirm" const val STOP_ROADS = "Done with roads" const val BUILD = "Build" + + /** + * The empty second line of a button that has no cost, so every button in the + * action bar is the same height. A non-breaking space: blank, but a line. + */ + const val NO_COST = "\u00A0" const val YOUR_HAND = "Your hand" const val CARDS = "cards" const val ACCEPT = "Accept" diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt index 71c2eb4..5cf8bc6 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt @@ -340,6 +340,89 @@ class CatanRulesTest { ) } + /** Seat 0 owns a settlement at vertex 0 and a two-road chain v0 - w - x; `beyond` continues past x. */ + private data class Chain(val state: CatanState, val x: Int, val beyond: Int, val spur: Int?) + + private fun chain(): Chain { + val e1 = CatanBoard.VERTEX_EDGES[0][0] + val w = CatanBoard.otherEnd(e1, 0) + val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 } + val x = CatanBoard.otherEnd(e2, w) + val beyond = CatanBoard.VERTEX_EDGES[x].first { it != e2 } + val spur = CatanBoard.VERTEX_EDGES[w].firstOrNull { it != e1 && it != e2 } + val state = craft() + .copy(resources = hands(hand(Resource.LUMBER to 5, Resource.BRICK to 5))) + .withBuilding(0, BuildingType.SETTLEMENT, 0) + .withRoad(e1, 0) + .withRoad(e2, 0) + return Chain(state, x, beyond, spur) + } + + @Test + fun `a road cannot be built past an opponent's settlement`() { + val (open, x, beyond, spur) = chain() + assertTrue("open crossing: the road continues", BuildRoad(beyond) in CatanRules.legalMoves(open, 0)) + + val blocked = open.withBuilding(x, BuildingType.SETTLEMENT, 1) + assertFalse( + "an opponent's settlement on the crossing breaks the network", + BuildRoad(beyond) in CatanRules.legalMoves(blocked, 0), + ) + // The same through a city, and while laying free roads from Road Building. + val city = open.withBuilding(x, BuildingType.CITY, 1) + assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(city, 0)) + val free = blocked.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2) + assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(free, 0)) + // The rest of the network is untouched: the spur at the other crossing still works. + if (spur != null) assertTrue(BuildRoad(spur) in CatanRules.legalMoves(blocked, 0)) + } + + @Test + fun `your own settlement on the crossing does not block your road`() { + val (open, x, beyond, _) = chain() + val own = open.withBuilding(x, BuildingType.SETTLEMENT, 0) + assertTrue(BuildRoad(beyond) in CatanRules.legalMoves(own, 0)) + } + + @Test + fun `a player runs out of pieces`() { + val rich = hands( + hand( + Resource.LUMBER to 9, Resource.BRICK to 9, Resource.WOOL to 9, + Resource.GRAIN to 9, Resource.ORE to 9, + ), + ) + + // Roads: fifteen, and no sixteenth - in the main phase or from Road Building. + var roads = craft().copy(resources = rich).withBuilding(0, BuildingType.SETTLEMENT, 0) + repeat(14) { + roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance().first().edge, 0) + } + assertTrue("fourteen laid: one more is allowed", CatanRules.legalMoves(roads, 0).any { it is BuildRoad }) + roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance().first().edge, 0) + assertEquals(15, roads.roads.count { it == 0 }) + assertTrue("fifteen laid: none left", CatanRules.legalMoves(roads, 0).none { it is BuildRoad }) + val free = roads.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2) + assertEquals(listOf(EndFreeRoads), CatanRules.legalMoves(free, 0)) + + // Settlements: five. + val homes = scattered(7) + var settled = craft().copy(resources = rich) + for (i in 0 until 4) settled = settled.withBuilding(homes[i], BuildingType.SETTLEMENT, 0) + settled = settled.withRoad(CatanBoard.VERTEX_EDGES[homes[5]][0], 0) + assertTrue("four standing: a fifth is allowed", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0)) + settled = settled.withBuilding(homes[4], BuildingType.SETTLEMENT, 0) + assertFalse("five standing: no sixth", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0)) + + // Cities: four. A raised settlement returns to the supply, so five settlements stays possible. + var raised = craft().copy(resources = rich) + for (i in 0 until 3) raised = raised.withBuilding(homes[i], BuildingType.CITY, 0) + raised = raised.withBuilding(homes[3], BuildingType.SETTLEMENT, 0) + assertTrue("three cities: a fourth is allowed", BuildCity(homes[3]) in CatanRules.legalMoves(raised, 0)) + raised = raised.withBuilding(homes[3], BuildingType.CITY, 0).withBuilding(homes[4], BuildingType.SETTLEMENT, 0) + assertFalse("four cities: no fifth", BuildCity(homes[4]) in CatanRules.legalMoves(raised, 0)) + } + @Test fun `a settlement needs an open vertex on the network, a city one of yours`() { val base = craft() diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index 429c117..c90c0e5 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -159,7 +159,19 @@ its edge. A settlement goes on an **empty vertex** at least two edges from every standing building and connected to the seat's own road network. A city upgrades one of the seat's own settlements. A road goes on an empty edge that connects to the -seat's network (a road or a building at one of its ends). +seat's network: it must start from a crossing where the seat has a building, or +where one of its roads ends. + +**An opponent's settlement or city on a crossing breaks the network there.** A +road cannot be laid past it, even from a crossing where one of your own roads +ends — only a crossing you own a building on is a way through. The same rule +applies to the free roads of a Road Building card, and it is the same wall that +cuts Longest Road (below). + +**Pieces are limited**: each player has **15 roads, 5 settlements and 4 cities**. +A player who has laid them all cannot build another of that kind. Raising a +settlement into a city returns the settlement to the supply, so the five +settlements remain available to be laid again. The development deck is the published 25 cards: **14 Knights, 2 Road Building, 2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the diff --git a/docs/UI-catan.md b/docs/UI-catan.md index f9ee225..6582b20 100644 --- a/docs/UI-catan.md +++ b/docs/UI-catan.md @@ -71,6 +71,14 @@ screen's UI state. The legal-move list stays the gate. size of the deck; it is not known to a client. Card *counts* (`resourceCount`, `devHandCounts`) are public. 5. **The state is immutable.** Never mutate it; submit a move. +6. **The layout must not move.** Everything outside the board has a *fixed + height*, and the board takes whatever is left, so the island's scale is the + same all game. Concretely: a control is *enabled or disabled*, never + *present or absent* (the action bar always shows every button); text that can + vary in length is pinned to a fixed number of lines (`minLines = maxLines`); + a line that is sometimes empty is still drawn (a non-breaking space), as the + award line on a player card is. A change that makes any non-board element + taller or shorter at runtime will rescale the board under the player's thumb. ## Interaction model (what the player actually does)