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
@@ -360,7 +360,7 @@ object GameCatalog {
minPlayers = 3,
maxPlayers = 4,
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
// devices yet. Dev builds can open it as a *Preview*; a production
// build does not list it. Flip `available` on, and `devPreview`
@@ -71,11 +71,14 @@ data class BoardTargets(
val edges: Set<Int> = emptySet(),
val hexes: 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. */
val tappableVertices: Set<Int> get() = vertices + knights
val tappableVertices: Set<Int> get() = vertices + knights + idleKnights
}
/**
@@ -143,6 +146,8 @@ object CatanInteraction {
setupVertex: Int?,
movingKnight: Int? = null,
armed: ArmedCard? = null,
/** The local seat, so its sleeping knights can be tapped for their menu too. */
localSeat: Int? = null,
): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
@@ -187,7 +192,15 @@ object CatanInteraction {
BuildKind.KNIGHT ->
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?,
movingKnight: Int? = 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 {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
@@ -350,7 +365,7 @@ object CatanInteraction {
BuildKind.CITY -> BuildCity(vertex)
BuildKind.WALL -> BuildWall(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)
} else {
TapOutcome.Ignored
@@ -239,7 +239,7 @@ fun CatanPlayerCard(
} else {
CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall)
CardLine(
"Knights ${CatanCk.knightsOf(state, seat).size} (strength $strength) · " +
"⚔ ${CatanCk.knightsOf(state, seat).size} · str $strength · " +
"Road ${CatanRules.longestRoadFor(state, seat)}",
MaterialTheme.typography.bodySmall,
)
@@ -117,7 +117,6 @@ fun CatanScreen(
setupVertex = 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 }
if (legal.isEmpty()) {
showCards = false
@@ -159,7 +158,8 @@ fun CatanScreen(
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 progressHand = CatanInteraction.progressHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
@@ -219,7 +219,12 @@ fun CatanScreen(
targets = targets,
selectedVertex = setupVertex,
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)) },
onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) },
@@ -58,8 +58,8 @@ data class CatanStyle(
/** Wall round a city, and the barbarian strip. */
val wall: Color = Color(0xFF5D4037),
val barbarian: Color = Color(0xFFB71C1C),
/** A knight that is awake and ready to act. */
val knightReady: Color = Color(0xFFFFEB3B),
/** The halo round a knight that is awake. White, so it is not mistaken for the yellow tap ring. */
val knightReady: Color = Color(0xFFFFFFFF),
// --- Players ------------------------------------------------------------
/** 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.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
@@ -261,4 +262,26 @@ class CatanCkInteractionTest {
listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn")
.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())
}
}