Katan: no roads past an opponent's settlement, piece limits, stable layout
CI / Build and check (pull_request) Successful in 7m22s
CI / Deploy to Cloudflare Pages (pull_request) Skipped

Rules
- A road may only start from a crossing where the player has a building, or
  where one of their roads ends AND no opponent's settlement or city stands.
  roadConnects ignored opponents' buildings, so a road (the computer's included)
  could be laid straight past a settlement. Also applies to Road Building.
- Enforce the piece supply: 15 roads, 5 settlements, 4 cities. It was
  unlimited.
- Tests: building past a settlement and a city, free roads, own settlement not
  blocking, and running out of each piece.

UI
- The layout rescaled the board whenever it became the player's turn, because
  the action buttons only existed then. Everything outside the board now has a
  fixed height: the action bar always shows every button (disabled, not absent),
  the prompt is pinned to two lines, player cards always draw every line, and
  the dice total is always present. Rule recorded in docs/UI-catan.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
This commit is contained in:
Claude
2026-10-15 13:55:03 +00:00
parent 87b00ad84f
commit 3b0b307997
9 changed files with 277 additions and 82 deletions
@@ -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
fun `a settlement needs an open vertex on the network, a city one of yours`() {
val base = craft()