Katan: Cities & Knights docs, knight menu for any own knight, tidier player card

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
This commit is contained in:
Claude
2026-10-17 14:40:49 +00:00
parent e344d1d817
commit c8368416c4
11 changed files with 328 additions and 15 deletions
+4 -2
View File
@@ -373,7 +373,9 @@ game:
the game is out of print and the rulebook could not be obtained, so the the game is out of print and the rulebook could not be obtained, so the
document says which parts are attested and which were derived document says which parts are attested and which were derived
- [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the - [Katan](docs/RULES-catan.md) — the island game (catalogue id `catan`); the
rules engine and AI are in place, the screen lands with its own task rules engine, AI and screen are in place
- [Katan: Cities & Knights](docs/RULES-catan-ck.md) — the expansion rule set, chosen on
the setup screen: commodities, knights, barbarians, city improvements and progress cards
## Layout ## Layout
@@ -396,7 +398,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
mastermind/ Mastermind codes, scoring, and a code breaker mastermind/ Mastermind codes, scoring, and a code breaker
pirates/ Pirates and Bulgars — the cross board, the hunt, search pirates/ Pirates and Bulgars — the cross board, the hunt, search
tayu/ Ta Yü tile geometry, river rules, scoring, AI tayu/ Ta Yü tile geometry, river rules, scoring, AI
catan/ Katan island geometry, production, trade, dev cards, heuristic AI catan/ Katan island geometry, production, trade, dev cards, Cities & Knights, heuristic AI
net/ wire protocol, LAN transport, lobby, match driver net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
@@ -360,7 +360,7 @@ object GameCatalog {
minPlayers = 3, minPlayers = 3,
maxPlayers = 4, maxPlayers = 4,
teamBased = false, teamBased = false,
blurb = "Trade, build, and settle the island.", blurb = "Trade, build, and settle the island, in the base game or Cities & Knights.",
// Engine and screen exist but have not been verified on two real // Engine and screen exist but have not been verified on two real
// devices yet. Dev builds can open it as a *Preview*; a production // devices yet. Dev builds can open it as a *Preview*; a production
// build does not list it. Flip `available` on, and `devPreview` // build does not list it. Flip `available` on, and `devPreview`
@@ -71,11 +71,14 @@ data class BoardTargets(
val edges: Set<Int> = emptySet(), val edges: Set<Int> = emptySet(),
val hexes: Set<Int> = emptySet(), val hexes: Set<Int> = emptySet(),
val knights: Set<Int> = emptySet(), val knights: Set<Int> = emptySet(),
/** The player's other knights: tappable for their menu, but with nothing to do, so not ringed. */
val idleKnights: Set<Int> = emptySet(),
) { ) {
val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() val isEmpty: Boolean
get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty() && idleKnights.isEmpty()
/** Every crossing a tap can land on. */ /** Every crossing a tap can land on. */
val tappableVertices: Set<Int> get() = vertices + knights val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
} }
/** /**
@@ -143,6 +146,8 @@ object CatanInteraction {
setupVertex: Int?, setupVertex: Int?,
movingKnight: Int? = null, movingKnight: Int? = null,
armed: ArmedCard? = null, armed: ArmedCard? = null,
/** The local seat, so its sleeping knights can be tapped for their menu too. */
localSeat: Int? = null,
): BoardTargets { ): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>() val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) { if (setup.isNotEmpty()) {
@@ -187,7 +192,15 @@ object CatanInteraction {
BuildKind.KNIGHT -> BuildKind.KNIGHT ->
BoardTargets(vertices = legal.filterIsInstance<HireKnight>().map { it.vertex }.toSet()) BoardTargets(vertices = legal.filterIsInstance<HireKnight>().map { it.vertex }.toSet())
null -> BoardTargets(knights = knightsWithActions(legal)) null -> {
val ready = knightsWithActions(legal)
val mine = if (localSeat != null && legal.isNotEmpty()) {
state.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
} else {
emptySet()
}
BoardTargets(knights = ready, idleKnights = mine - ready)
}
} }
} }
@@ -323,6 +336,8 @@ object CatanInteraction {
setupVertex: Int?, setupVertex: Int?,
movingKnight: Int? = null, movingKnight: Int? = null,
armed: ArmedCard? = null, armed: ArmedCard? = null,
/** The local player's knights, which can be tapped for their menu even with nothing to do. */
ownKnights: Set<Int> = emptySet(),
): TapOutcome { ): TapOutcome {
val setup = legal.filterIsInstance<SetupPlace>() val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) { if (setup.isNotEmpty()) {
@@ -350,7 +365,7 @@ object CatanInteraction {
BuildKind.CITY -> BuildCity(vertex) BuildKind.CITY -> BuildCity(vertex)
BuildKind.WALL -> BuildWall(vertex) BuildKind.WALL -> BuildWall(vertex)
BuildKind.KNIGHT -> HireKnight(vertex) BuildKind.KNIGHT -> HireKnight(vertex)
else -> return if (building == null && vertex in knightsWithActions(legal)) { else -> return if (building == null && (vertex in knightsWithActions(legal) || vertex in ownKnights)) {
TapOutcome.PickKnight(vertex) TapOutcome.PickKnight(vertex)
} else { } else {
TapOutcome.Ignored TapOutcome.Ignored
@@ -239,7 +239,7 @@ fun CatanPlayerCard(
} else { } else {
CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall) CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall)
CardLine( CardLine(
"Knights ${CatanCk.knightsOf(state, seat).size} (strength $strength) · " + "⚔ ${CatanCk.knightsOf(state, seat).size} · str $strength · " +
"Road ${CatanRules.longestRoadFor(state, seat)}", "Road ${CatanRules.longestRoadFor(state, seat)}",
MaterialTheme.typography.bodySmall, MaterialTheme.typography.bodySmall,
) )
@@ -117,7 +117,6 @@ fun CatanScreen(
setupVertex = null setupVertex = null
} }
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
if (knightMenu != null && CatanInteraction.knightActions(legal, knightMenu!!).isEmpty()) knightMenu = null
armed?.let { card -> if (card.plays.none { it in legal }) armed = null } armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
if (legal.isEmpty()) { if (legal.isEmpty()) {
showCards = false showCards = false
@@ -159,7 +158,8 @@ fun CatanScreen(
return return
} }
val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed) val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed, localSeat)
val ownKnights = current.ck?.knights?.filter { it.owner == localSeat }?.map { it.vertex }?.toSet().orEmpty()
val devHand = CatanInteraction.devHand(current, localSeat, legal) val devHand = CatanInteraction.devHand(current, localSeat, legal)
val progressHand = CatanInteraction.progressHand(current, localSeat, legal) val progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
@@ -219,7 +219,12 @@ fun CatanScreen(
targets = targets, targets = targets,
selectedVertex = setupVertex, selectedVertex = setupVertex,
onVertexTap = { onVertexTap = {
handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex, movingKnight, armed)) handle(
CatanInteraction.onVertexTap(
it, legal, building, setupVertex, movingKnight, armed,
ownKnights = if (legal.isNotEmpty()) ownKnights else emptySet(),
),
)
}, },
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) }, onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) }, onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
@@ -58,8 +58,8 @@ data class CatanStyle(
/** Wall round a city, and the barbarian strip. */ /** Wall round a city, and the barbarian strip. */
val wall: Color = Color(0xFF5D4037), val wall: Color = Color(0xFF5D4037),
val barbarian: Color = Color(0xFFB71C1C), val barbarian: Color = Color(0xFFB71C1C),
/** A knight that is awake and ready to act. */ /** The halo round a knight that is awake. White, so it is not mistaken for the yellow tap ring. */
val knightReady: Color = Color(0xFFFFEB3B), val knightReady: Color = Color(0xFFFFFFFF),
// --- Players ------------------------------------------------------------ // --- Players ------------------------------------------------------------
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */ /** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
@@ -2,6 +2,7 @@ package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCatalog
@@ -261,4 +262,26 @@ class CatanCkInteractionTest {
listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn") listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn")
.forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) } .forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) }
} }
@Test
fun `a sleeping knight with nothing to do can still be tapped for its menu`() {
val base = CatanRules.initialState(config(3, 5L))
.copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
val state = base.copy(
ck = base.ck!!.copy(knights = listOf(org.prolibertate.games.game.catan.Knight(10, 0, 1))),
)
val legal = CatanRules.legalMoves(state, 0)
assertTrue("no grain, so nothing to do", CatanInteraction.knightActions(legal, 10).isEmpty())
val targets = CatanInteraction.targets(state, legal, null, null, localSeat = 0)
assertTrue(10 in targets.idleKnights)
assertTrue("no tap ring for a knight with nothing to do", 10 !in targets.knights)
assertTrue(10 in targets.tappableVertices)
assertEquals(
TapOutcome.PickKnight(10),
CatanInteraction.onVertexTap(10, legal, null, null, ownKnights = setOf(10)),
)
// On somebody else's turn the legal list is empty and nothing is offered.
assertTrue(CatanInteraction.targets(state, emptyList(), null, null, localSeat = 0).idleKnights.isEmpty())
}
} }
+1 -1
View File
@@ -24,7 +24,7 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
| Golf | [RULES-golf.md](RULES-golf.md) | | Golf | [RULES-golf.md](RULES-golf.md) |
| Hearts | — (TODO) | | Hearts | — (TODO) |
| Kaiser | [RULES-kaiser.md](RULES-kaiser.md) | | Kaiser | [RULES-kaiser.md](RULES-kaiser.md) |
| Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) | | Katan (catalogue id `catan`) | [RULES-catan.md](RULES-catan.md) — and its expansion rule set, [RULES-catan-ck.md](RULES-catan-ck.md) |
| Klondike | — (TODO) | | Klondike | — (TODO) |
| Mastermind | [RULES-mastermind.md](RULES-mastermind.md) | | Mastermind | [RULES-mastermind.md](RULES-mastermind.md) |
| Morris | [RULES-morris.md](RULES-morris.md) | | Morris | [RULES-morris.md](RULES-morris.md) |
+220
View File
@@ -0,0 +1,220 @@
# Katan: Cities & Knights — implemented rules
This is the second rule set of Katan (see [RULES-catan.md](RULES-catan.md) for the
base game). It is chosen on the setup screen — **Rules: Base game / Cities &
Knights** — and stored as `CatanOptions.ruleSet` (`CatanRuleSet.CITIES_AND_KNIGHTS`).
A table plays one or the other; the choice reaches guests with the rest of the
options.
The expansion is a whole rule set rather than a toggle, because it replaces rules
(no development cards, no Largest Army, a different seven) as well as adding them.
Everything it needs lives behind `CatanState.ck`, which is `null` in the base game,
so a base game's state and rules are untouched. The code is:
| File | Holds |
| --- | --- |
| `CatanCkModel.kt` | the data: commodities, improvement tracks, progress cards, knights, the chore queue, every new move |
| `CatanCk.kt` | the rules: legal moves, applying them, the roll, the barbarians, scoring |
| `CatanCkAi.kt` | the computer opponent for this rule set |
| `CatanRules.kt` | the single entry point; calls into `CatanCk` whenever `state.ck != null` |
Behaviour is pinned by `CatanCkRulesTest` (each rule on a crafted position) and
`CatanCkSelfPlayTest` (whole games, engine against engine). Three or four players,
as in the base game.
## Verification
The rules were checked against the in-game almanac of Catan Universe (rules text
only: costs, card wordings, the barbarian and improvement rules). Where the almanac
is silent or ambiguous the reading chosen is listed under *Choices made here* below.
## What changes from the base game
| | Base game | Cities & Knights |
| --- | --- | --- |
| Points to win | 10 | **13** (setup offers 8 / 10 / 13 / 15) |
| Development cards | 25-card deck | **none** — knights and progress cards replace them |
| Largest Army | 5 *(see RULES-catan.md)* | **none** |
| Longest Road | 5 *(see RULES-catan.md)* | **2** |
| Second setup piece | settlement | **city** (resources only, no commodities) |
| Dice | two | **two number dice + the event die** |
| A seven | discard half over 7; robber moves | discard half over **7 + 2 per wall**; the robber **only moves after the first barbarian attack** |
| Cities | 2 of a resource | forest: 1 lumber + **1 paper**; pasture: 1 wool + **1 cloth**; mountains: 1 ore + **1 coin**; hills / fields: 2 of the resource |
## The dice
The yellow die and the red die are added for production, as in the base game. The
**event die** has three ships and one gate each of yellow (trade), blue (politics)
and green (science). It is resolved **before** production:
- **Ship** — the barbarians come one step nearer (they start 7 steps out).
- **Gate** — every player with at least one level of improvement in that colour,
**in turn order from the roller**, whose **red die is no higher than their level**
in that track, draws a progress card of that colour.
`RollDice(forced, event)` stages both for tests, like the base game's forced roll.
An **Alchemist** played before the roll sets the two number dice (the event die is
still rolled).
## Commodities
Three commodities — **cloth, coin, paper** — join the five resources. The bank holds
12 of each (19 of each resource). Hands, discards, thefts and trades all treat them as
cards; progress cards are not cards for any of these purposes.
When the bank cannot meet the demand for a card type: with **one** seat owed it,
that seat takes what is left; with **two or more** owed, nobody receives it.
## The barbarians
The strip shows how many steps away the ship is. On the seventh ship it **lands**:
- Barbarian strength is the **number of cities on the board** (a metropolis is a city).
- Catan's strength is the sum of the strengths of every **active** knight (basic 1,
strong 2, mighty 3).
- If Catan's strength is **at least** the barbarians', Catan holds: the player with
the strictly highest contribution takes a **Defender of Catan** card (one point);
if several tie for highest, **each draws a progress card from a deck of their
choice** instead.
- Otherwise Catan falls: among the players who own a city that is not a metropolis,
those who contributed **least** each lose one city, **to their own choice**. A lost
city becomes a settlement (or, if the seat has no settlement piece left, is removed),
and its wall goes. **A metropolis can never be pillaged.**
- Either way every knight stands down, the ship returns to seven steps, and the
**robber wakes** — until now it has not been on the board.
## Knights
Each seat has **two knights of each strength** (six in all).
| Action | Cost | Rule |
| --- | --- | --- |
| Hire | 1 ore + 1 wool | a **basic** knight on an empty crossing at the end of one of your roads; only if one of your two basic knights is free |
| Activate | 1 grain | a sleeping knight wakes; only active knights count against the barbarians |
| Promote | 1 ore + 1 wool | one level, once per knight per turn; a **mighty** knight needs **level three of politics** (the fortress); the next level's pieces must be free |
| Move | — | an active knight moves along **its own roads** to an empty crossing, or onto a **strictly weaker** foreign knight, driving it off; it then rests |
| Chase the robber | — | an active knight on a crossing of the robber's hex sends the robber off (only once the robber is awake); it then rests, and the robber move follows |
- **A knight cannot act on the turn it woke.** It can be woken again after it acts.
- A driven-off knight is moved by **its owner** along its own roads to any empty
crossing it can reach; if there is none, it is lost.
- A knight is a wall, like an opposing building: roads cannot be laid past an enemy
knight, a settlement cannot be built on a knight, and the knight cuts **Longest Road**.
- Knights can act (wake, move, chase) **before the roll** as well as after.
## City walls
2 brick, on a city without a wall, **three per player**. Each wall raises the hand
limit on a seven by two (so a seven discards when you hold more than `7 + 2 × walls`).
## City improvements
Three tracks, five levels each, bought with the track's commodity: **trade (cloth,
yellow), politics (coin, blue), science (paper, green)**. Level *n* costs *n* of the
commodity. You must own a city to improve.
| Track | Level 3 gives |
| --- | --- |
| Trade | commodities trade with the bank **2:1** |
| Politics | knights may be promoted to **mighty** |
| Science | the **aqueduct**: when the dice pay you nothing, take any resource |
The level names (Trading Post … Grand Bazaar, Town Hall … Palace, Abbey … University)
are this app's own.
### Metropolises
The first player to reach **level four** in a track takes that track's **metropolis**
(a marker on one of their cities: +2 points; a city carries at most one). Reaching
**level five** takes it from a holder who is still on four. A player with several
cities chooses which; with one it is placed at once; a player with no free city
**waits**, and claims it when a city is built.
## Progress cards
54 cards: 18 in each of three decks. A card played returns to the **bottom** of its
deck. A hand holds **four**; a fifth must be given back at once. They can be played at
any point of your own turn (before or after the roll), except where noted.
**Science (green)** — Alchemist ×2 *(before the roll only)*, Crane ×2, Engineer, Inventor ×2,
Irrigation ×2, Medicine ×2, Mining ×2, Printer, Road Building ×2, Smith ×2.
**Politics (blue)** — Bishop ×2 *(after the first attack)*, Constitution, Deserter ×2,
Diplomat ×2, Intrigue ×2, Saboteur ×2, Spy ×3, Warlord ×2, Wedding ×2.
**Trade (yellow)** — Commercial Harbor ×2, Master Merchant ×2, Merchant ×6,
Merchant Fleet ×2, Resource Monopoly ×4, Trade Monopoly ×2.
**Printer** and **Constitution** are one point each and are **laid face up the moment
they are drawn**; they never sit in a hand.
How each plays is shown on the card in the app (`CatanText.progressHelp`); the engine's
behaviour is in `CatanCk.applyProgress`. Points worth knowing:
- **Saboteur** hits every other player with **as many points as you or more**, who each
discard half their cards (rounded down). **Wedding** takes two cards (or what they
have) from each player with **more** points. **Master Merchant** needs a victim with
more points, and takes two cards you choose.
- **Spy**: you see the target's progress cards and take one; nobody else sees them.
- **Merchant**: placed beside one of your buildings; you trade that hex's resource 2:1
and hold a victory point until someone else plays a Merchant.
- **Deserter**: the victim removes a knight of *their* choice; you place a free one of the
same strength (or a basic if yours is not free), awake or asleep as theirs was.
- **Diplomat**: removes a road with a loose end (an end touching no other road, building
or knight); if it was yours you may rebuild it anywhere for free.
- **Inventor** swaps two tokens, never a 2, 6, 8 or 12.
## The chore queue
Anything that needs an answer from somebody — a discard, a city to lose, a progress card
to hand over, a metropolis site, a knight to place — is queued as a **chore**
(`CatanPhase.CHORE`, `CkState.chores`). The game waits on the seat the first chore names
(not always the player whose turn it is), then resumes where `CkState.back` says. This is
how a seven, a barbarian landing, a wedding or a spy are all one mechanism, and why
the AI and the network driver needed no new concepts.
The two free-form chores — **discarding** and **handing over** cards — are structural
like the base game's trade offers: the engine checks them in `isLegalMove` and the legal
list carries one example, so a seat is never without a move.
## Scoring and winning
Settlement 1, city 2, **metropolis +2**, **Longest Road 2**, **Defender of Catan 1** each,
**Printer / Constitution 1** each, **the Merchant 1**. Nothing is hidden. Win at 13 (default),
checked after every move and when a turn arrives.
## Choices made here, and why
- **The red die must be no higher than the improvement level** to draw a progress card
(level 1 draws on a 1, level 5 on 1–5). The almanac says the red die must match a
number on one of your improvements and that the number rises with the level; this is
the reading that makes both true.
- **One resource is offered to every player for a Commercial Harbor.** The printed card
lets you offer a different one to each.
- **The merchant cannot go on the desert**, which produces nothing to trade.
- **Improvements need a city** to be built.
- **A 3:1 harbour trades commodities too**; a 2:1 harbour trades only its own resource.
- **A knight that cannot find a refuge is lost**, as the almanac says of an intrigue.
- **A claim on a metropolis with no city to carry it waits** and is made when a city is
built.
- **Domestic trade** may include commodities; the one-offer-at-a-time rules of the base
game apply.
- **No Seafarers, no 5–6 player extension**, no scenario or variant of any kind.
## The computer opponent
`CatanCkAi` plays a safe, unambitious game: it settles and raises cities as the base AI
does, spends commodities on improvements as soon as it can (leaning to politics), hires
and wakes knights as the barbarians draw near, chases the robber off hexes it needs, plays
the progress cards whose value is obvious, and answers every chore with the card it values
least. It never plans a knight's route, never plays an Alchemist, Inventor, Diplomat,
Deserter or Intrigue, and makes no domestic offers (though it answers them). Every engine
game in `CatanCkSelfPlayTest` ends in a win at thirteen points.
## Known gaps
- The screen is built and its logic is tested, but **not yet verified on two real
devices**; the catalogue entry stays a dev-channel preview.
- No animation for the event die, the barbarian landing or a knight's move.
- The Commercial Harbor offers a single resource type (see above).
- The AI is not a strong opponent and makes no claim to be.
+21 -1
View File
@@ -12,14 +12,27 @@ players, as published.
The engine is a pure state machine (`CatanRules`) with no Android types, and its The engine is a pure state machine (`CatanRules`) with no Android types, and its
behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`. behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`.
## Rule sets
A table plays one of two rule sets, chosen on the setup screen
(`CatanOptions.ruleSet`):
- **Base game** — everything in this document.
- **Cities & Knights** — the expansion, documented in
[RULES-catan-ck.md](RULES-catan-ck.md). It reuses this document's board, setup,
building, robber and trade rules and changes or replaces the rest; its state lives
behind `CatanState.ck`, which is `null` in the base game.
## Table ## Table
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).
- **Players — *toggle*.** Three or four. `CatanOptions.playerCount`; the engine - **Players — *toggle*.** Three or four. `CatanOptions.playerCount`; the engine
refuses anything else. refuses anything else.
- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at - **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at
least 2. A lower target makes a test game short. least 2. A lower target makes a test game short.
The setup screen exposes both (players: 3 or 4; game to 5, 8, 10 or 12 points). The setup screen exposes all three (players: 3 or 4; game to 5, 8, 10 or 12 points in the
base game, 8, 10, 13 or 15 in Cities & Knights, whose published target is 13).
## Board ## Board
@@ -296,6 +309,13 @@ timing rules; Longest Road and Largest Army worth five; victory at ten.
## Known gaps ## Known gaps
- **Longest Road and Largest Army are worth 5 each here.** The published base game awards
**2** each (Catan Universe's almanac says so for Largest Army, and the printed rules
for both). Cities & Knights scores Longest Road at 2, as printed. Left unchanged in
the base game so far; it is a one-constant change in `publicVictoryPoints`.
- **A bank short of a resource pays nobody that roll**, even when only one player is owed
it. The published ruling lets a lone claimant take what is left; Cities & Knights does.
- **No domestic auction or counter-offer.** An offer is addressed to a single - **No domestic auction or counter-offer.** An offer is addressed to a single
seat. Open trading to the whole table, and counter-offers, are the obvious next seat. Open trading to the whole table, and counter-offers, are the obvious next
step and are not implemented. step and are not implemented.
+28
View File
@@ -120,6 +120,34 @@ and play a game on a dev build.
- The board has a content description but no per-hex semantics; a screen-reader - The board has a content description but no per-hex semantics; a screen-reader
mode would need one. mode would need one.
## Cities & Knights on the screen
The same layers carry the expansion; the screen only adds pieces, and **none of them
decides what is legal** — each reads the engine's legal-move list.
| Layer | What it adds |
| --- | --- |
| `CatanStyle.kt` | commodity colours, a colour per improvement track, the wall and barbarian colours, the awake-knight halo |
| `CatanText.kt` | goods, track, knight and progress-card names and help text; the prompts for every chore |
| `CatanInteraction.kt` | `BuildKind.WALL` / `KNIGHT`; chore taps; the moving-knight and armed-card modes; `knightActions`, `progressHand`, `goodsBankTrades` |
| `CatanBoardView.kt` | shields for knights (strength dots; faded when asleep, white halo when awake), dashed walls, a pennant for each metropolis, the merchant coin, a faint robber until the first attack |
| `CatanPanels.kt` | commodity tiles in the hand, the barbarian strip, the yellow / red / event dice, the extra player-card lines, the Improve and Progress buttons |
| `CatanCkDialogs.kt` | goods picker (discard, wedding, harbour), improvements, progress cards, the knight menu, deck / aqueduct / spy / drop choices, goods trade |
| `CatanScreen.kt` | the visual state: knight menu, moving knight, armed card, and which chore dialog shows |
**Interaction model.** Tap one of your own knights (it gets a yellow ring when it can do
something) for its menu: wake, promote, move or chase the robber. *Move* arms the board
with the crossings the knight can reach. A progress card that needs a place on the board
(Engineer, Medicine, Merchant, Diplomat, Intrigue, Inventor) closes the dialog and
**arms** the board; the next tap plays it. Chores that are answered with a place — a city
to lose, a metropolis site, a driven knight's refuge, a free knight — light the crossings
on the board and the prompt line says what to tap; the others open a dialog that cannot
be dismissed, because the game is waiting on it.
**Testing.** `CatanCkInteractionTest` plays whole games and checks that for *every* legal
move at *every* position some control (a tap, a button or a dialog choice) produces it, so
a legal move with no way to make it — a stuck phone — fails the build.
## Status and known gaps ## Status and known gaps
The screen is **playable and feature-complete for the engine's rules**, but is The screen is **playable and feature-complete for the engine's rules**, but is