Katan: no roads past an opponent's settlement, piece limits, stable layout #4

Merged
devadmin merged 1 commits from fix/catan-road-rule-and-ui-stability into dev 2026-10-15 08:03:26 -06:00
9 changed files with 277 additions and 82 deletions
Showing only changes of commit 3b0b307997 - Show all commits
@@ -12,6 +12,11 @@ const val LONGEST_ROAD_MIN = 5
/** Knights required to take Largest Army. */ /** Knights required to take Largest Army. */
const val LARGEST_ARMY_MIN = 3 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. */ /** Resource cards of each type the bank starts with. */
const val BANK_PER_RESOURCE = 19 const val BANK_PER_RESOURCE = 19
@@ -272,11 +272,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return takes 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<BuildRoad> = private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList()
} else {
(0 until CatanBoard.EDGE_COUNT) (0 until CatanBoard.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) } .filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
.map { BuildRoad(it) } .map { BuildRoad(it) }
}
/** /**
* Settlement placements: an open vertex at least two edges from every * Settlement placements: an open vertex at least two edges from every
@@ -285,15 +292,27 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* own move.) * own move.)
*/ */
private fun settlementVertices(state: CatanState, seat: Int): List<Int> = private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until CatanBoard.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) && vertexOpenForSettlement(state, v) &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
} }
}
private fun cityVertices(state: CatanState, seat: Int): List<Int> = private fun cityVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until CatanBoard.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) 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<CatanMove> { private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
val offer = state.pendingTrade ?: return emptyList() val offer = state.pendingTrade ?: return emptyList()
@@ -310,13 +329,25 @@ object CatanRules : GameRules<CatanState, CatanMove> {
state.buildings[v] == null && state.buildings[v] == null &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == 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 { private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
val (a, b) = CatanBoard.EDGE_VERTICES[edge] val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } || return networkReaches(state, seat, a) || networkReaches(state, seat, b)
CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } || }
state.buildings[a]?.owner == seat ||
state.buildings[b]?.owner == seat /** 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 { private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -28,7 +29,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.catan.CatanPhase 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 * 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 * [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; * 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 @Composable
fun CatanPlayerCard( fun CatanPlayerCard(
@@ -132,6 +142,7 @@ fun CatanPlayerCard(
points: Int, points: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current, style: CatanStyle = LocalCatanStyle.current,
compact: Boolean = false,
) { ) {
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
val shape = RoundedCornerShape(12.dp) val shape = RoundedCornerShape(12.dp)
@@ -155,39 +166,54 @@ fun CatanPlayerCard(
.background(style.seats[seat % style.seats.size]) .background(style.seats[seat % style.seats.size])
.border(1.dp, style.pieceOutline, CircleShape), .border(1.dp, style.pieceOutline, CircleShape),
) )
Text( CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
text = seats.label(seat), }
fontWeight = FontWeight.Bold, CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
maxLines = 1, if (compact) {
style = MaterialTheme.typography.labelLarge, 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 { val awards = buildList {
if (state.longestRoadOwner == seat) add("Longest road") if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
if (state.largestArmyOwner == seat) add("Largest army") if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
} }
if (awards.isNotEmpty()) { // Always drawn, so the card does not grow when somebody takes an award.
Text( CardLine(
text = awards.joinToString(" · "), text = awards.joinToString(" · ").ifEmpty { BLANK },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
weight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
) )
} }
} }
/** 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,
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -221,15 +247,14 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
) )
} }
} }
if (dice.size == 2) { // Always drawn ("= –" before the first roll) so the row never changes width.
Text( Text(
text = "= ${dice.sum()}", text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
} }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Actions // Actions
@@ -238,7 +263,6 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */ /** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
data class CatanActions( data class CatanActions(
val canRoll: Boolean = false, val canRoll: Boolean = false,
val showTurnActions: Boolean = false,
val canBuild: Set<BuildKind> = emptySet(), val canBuild: Set<BuildKind> = emptySet(),
val building: BuildKind? = null, val building: BuildKind? = null,
val canBuyCard: Boolean = false, val canBuyCard: Boolean = false,
@@ -250,7 +274,16 @@ data class CatanActions(
val canRepickSetup: Boolean = false, 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun CatanActionBar( fun CatanActionBar(
@@ -265,32 +298,41 @@ fun CatanActionBar(
onRepickSetup: () -> Unit, onRepickSetup: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
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) }
}
FlowRow( FlowRow(
modifier = modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = 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) }
}
if (actions.showTurnActions) {
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
} }
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { Text(CatanText.PLAY_CARD) } OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { Text(CatanText.TRADE) } 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) } /** Room for one button, held whether or not there is one to show. */
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } private val PRIMARY_ROW_HEIGHT = 48.dp
}
}
@Composable @Composable
private fun BuildButton( private fun BuildButton(
@@ -112,14 +112,13 @@ private fun PanelsPreview() {
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(0 until state.playerCount).forEach { seat -> (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) CatanHand(state, seat = 0)
CatanDice(state.lastDice) CatanDice(state.lastDice)
CatanActionBar( CatanActionBar(
actions = CatanActions( actions = CatanActions(
showTurnActions = true,
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY), canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
building = BuildKind.ROAD, building = BuildKind.ROAD,
canBuyCard = true, canBuyCard = true,
@@ -24,6 +24,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptTrade import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.BuyDevCard import org.prolibertate.games.game.catan.BuyDevCard
@@ -130,7 +132,6 @@ fun CatanScreen(
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions( val actions = CatanActions(
canRoll = CatanInteraction.canRoll(legal), canRoll = CatanInteraction.canRoll(legal),
showTurnActions = playing,
canBuild = CatanInteraction.canBuild(legal), canBuild = CatanInteraction.canBuild(legal),
building = building, building = building,
canBuyCard = CatanInteraction.canBuyDevCard(legal), canBuyCard = CatanInteraction.canBuyDevCard(legal),
@@ -275,6 +276,7 @@ private fun PlayerCards(
points = pointsOf(seat), points = pointsOf(seat),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = style, style = style,
compact = !twoPerRow,
) )
} }
// Keep an odd last row's card the same width as the others. // 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 @Composable
private fun PromptRow(prompt: String, notice: String?, state: CatanState) { private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
Row( Row(
@@ -291,16 +301,15 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Column(modifier = Modifier.weight(1f)) {
Text(prompt, style = MaterialTheme.typography.bodyMedium)
if (notice != null) {
Text( Text(
notice, text = notice ?: prompt,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error, color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
}
}
CatanDice(state.lastDice) CatanDice(state.lastDice)
} }
} }
@@ -103,6 +103,12 @@ object CatanText {
const val CONFIRM = "Confirm" const val CONFIRM = "Confirm"
const val STOP_ROADS = "Done with roads" const val STOP_ROADS = "Done with roads"
const val BUILD = "Build" 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 YOUR_HAND = "Your hand"
const val CARDS = "cards" const val CARDS = "cards"
const val ACCEPT = "Accept" const val ACCEPT = "Accept"
@@ -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<BuildRoad>().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<BuildRoad>().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<CatanMove>(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 @Test
fun `a settlement needs an open vertex on the network, a city one of yours`() { fun `a settlement needs an open vertex on the network, a city one of yours`() {
val base = craft() val base = craft()
+13 -1
View File
@@ -159,7 +159,19 @@ its edge.
A settlement goes on an **empty vertex** at least two edges from every standing 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 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 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, 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 2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
+8
View File
@@ -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`, size of the deck; it is not known to a client. Card *counts* (`resourceCount`,
`devHandCounts`) are public. `devHandCounts`) are public.
5. **The state is immutable.** Never mutate it; submit a move. 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) ## Interaction model (what the player actually does)