Katan: Cities & Knights screen, setup chooser and UI coverage tests

The setup screen picks the rule set (base game or Cities & Knights, each with its
own point targets). The board draws knights, walls, metropolis pennants and the
merchant; the screen adds the barbarian strip, event die, commodity hand, improve
and progress dialogs, the knight menu, and dialogs for every chore.

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:28:19 +00:00
parent d88083679d
commit e344d1d817
10 changed files with 2177 additions and 36 deletions
@@ -0,0 +1,264 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DiscardGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.HireKnight
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.PickDeck
import org.prolibertate.games.game.catan.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.PromoteKnight
import org.prolibertate.games.game.catan.RelocateKnight
import org.prolibertate.games.game.catan.RemoveKnight
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.SpyTake
import org.prolibertate.games.game.catan.TakeAqueduct
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* Cities & Knights has no device in this test suite, so the screen's logic is
* checked another way: engines play whole games, and at every position the
* player on the clock is treated as the local one. For every move the engine says
* is legal, [CatanInteraction] (and the dialogs' own filters) must offer a way to
* make it — a tap, a button or a dialog choice. A legal move that no control can
* produce would be a stuck game on a real phone.
*/
class CatanCkInteractionTest {
private val json = Json { encodeDefaults = true }
private fun config(players: Int, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
),
seed = seed,
)
/** How a move reaches the engine from the screen, or null if nothing can produce it. */
private fun reachable(state: CatanState, legal: List<CatanMove>, move: CatanMove): String? {
fun tapVertex(vertex: Int, building: BuildKind? = null, moving: Int? = null, armed: ArmedCard? = null): TapOutcome =
CatanInteraction.onVertexTap(vertex, legal, building, null, moving, armed)
return when (move) {
is SetupPlace -> {
val vertex = CatanInteraction.targets(state, legal, null, null).vertices
val edges = CatanInteraction.targets(state, legal, null, move.vertex).edges
"setup".takeIf {
move.vertex in vertex && move.edge in edges &&
CatanInteraction.onVertexTap(move.vertex, legal, null, null) is TapOutcome.PickSetupVertex &&
CatanInteraction.onEdgeTap(move.edge, legal, null, move.vertex) == TapOutcome.Submit(move)
}
}
is BuildRoad -> {
val targets = CatanInteraction.targets(state, legal, BuildKind.ROAD, null)
"road".takeIf {
move.edge in targets.edges &&
CatanInteraction.onEdgeTap(move.edge, legal, BuildKind.ROAD, null) == TapOutcome.Submit(move)
}
}
is BuildSettlement -> viaBuild(state, legal, BuildKind.SETTLEMENT, move.vertex, move)
is BuildCity -> viaBuild(state, legal, BuildKind.CITY, move.vertex, move)
is BuildWall -> viaBuild(state, legal, BuildKind.WALL, move.vertex, move)
is HireKnight -> viaBuild(state, legal, BuildKind.KNIGHT, move.vertex, move)
is MoveRobber -> {
val hex = CatanBoard.hexIndex.getValue(move.hex)
val targets = CatanInteraction.targets(state, legal, null, null)
val tap = CatanInteraction.onHexTap(hex, legal)
"robber".takeIf {
hex in targets.hexes &&
(tap == TapOutcome.Submit(move) || (tap is TapOutcome.PickRobberVictim && move.victim in tap.victims))
}
}
is MoveKnight -> {
val targets = CatanInteraction.targets(state, legal, null, null, movingKnight = move.from)
"moveKnight".takeIf {
move.to in targets.vertices && tapVertex(move.to, moving = move.from) == TapOutcome.Submit(move) &&
move.from in CatanInteraction.targets(state, legal, null, null).knights &&
CatanInteraction.knightActions(legal, move.from).any { it is MoveKnight }
}
}
is ActivateKnight, is ChaseRobber, is PromoteKnight -> {
val vertex = when (move) {
is ActivateKnight -> move.vertex
is ChaseRobber -> move.vertex
else -> (move as PromoteKnight).vertex
}
val smith = legal.any { it is EndSmith }
if (move is PromoteKnight && smith) {
val targets = CatanInteraction.targets(state, legal, null, null)
"smith".takeIf {
vertex in targets.vertices && tapVertex(vertex) == TapOutcome.Submit(move)
}
} else {
val targets = CatanInteraction.targets(state, legal, null, null)
"knightMenu".takeIf {
vertex in targets.knights && tapVertex(vertex) == TapOutcome.PickKnight(vertex) &&
move in CatanInteraction.knightActions(legal, vertex)
}
}
}
is PillageCity -> viaChore(state, legal, move.vertex, move)
is PlaceMetropolis -> viaChore(state, legal, move.vertex, move)
is RelocateKnight -> viaChore(state, legal, move.to, move)
is RemoveKnight -> viaChore(state, legal, move.vertex, move)
is PlaceKnight -> viaChore(state, legal, move.vertex, move)
is PlayProgress -> viaCard(state, legal, move)
is BuyImprovement -> "improve".takeIf { move.track in CatanInteraction.improvable(legal) }
is GoodsBankTrade ->
"bankTrade".takeIf { move.get in CatanInteraction.goodsBankTrades(legal)[move.give].orEmpty() }
RollDice(), is RollDice -> "roll".takeIf { CatanInteraction.canRoll(legal) }
EndTurn -> "endTurn".takeIf { CatanInteraction.canEndTurn(legal) }
EndFreeRoads -> "endRoads".takeIf { CatanInteraction.canEndFreeRoads(legal) }
EndSmith -> "endSmith".takeIf { CatanInteraction.canEndSmith(legal) }
AcceptTrade, DeclineTrade -> "tradeReply".takeIf { CatanInteraction.tradeResponses(legal) != (false to false) }
// Answered in a dialog that offers exactly the moves in the legal list.
is PickDeck, is DropProgress, is TakeAqueduct, is SpyTake -> "choreDialog"
// Structural: the discard and give dialogs build these and ask the engine.
is DiscardGoods, is GiveGoods -> "goodsDialog"
is OfferGoods -> "tradeDialog"
else -> null
}
}
private fun viaBuild(state: CatanState, legal: List<CatanMove>, kind: BuildKind, vertex: Int, move: CatanMove): String? {
val targets = CatanInteraction.targets(state, legal, kind, null)
return "build $kind".takeIf {
vertex in targets.vertices &&
CatanInteraction.onVertexTap(vertex, legal, kind, null) == TapOutcome.Submit(move) &&
kind in CatanInteraction.canBuild(legal)
}
}
private fun viaChore(state: CatanState, legal: List<CatanMove>, vertex: Int, move: CatanMove): String? {
val targets = CatanInteraction.targets(state, legal, null, null)
return "chore".takeIf {
vertex in targets.vertices &&
CatanInteraction.onVertexTap(vertex, legal, null, null) == TapOutcome.Submit(move)
}
}
private fun viaCard(state: CatanState, legal: List<CatanMove>, move: PlayProgress): String? {
val seat = CatanRules.currentSeat(state) ?: return null
val entry = CatanInteraction.progressHand(state, seat, legal).firstOrNull { it.card.id == move.card }
?: return null
val kind = entry.card.kind
if (move !in entry.plays) return null
if (kind !in CatanInteraction.BOARD_CARDS) return "cardDialog"
val armed = ArmedCard(kind, entry.plays)
val targets = CatanInteraction.targets(state, legal, null, null, armed = armed)
return when (kind) {
org.prolibertate.games.game.catan.ProgressType.DIPLOMAT ->
"armed".takeIf {
move.edge in targets.edges &&
CatanInteraction.onEdgeTap(move.edge!!, legal, null, null, armed) == TapOutcome.Submit(move)
}
org.prolibertate.games.game.catan.ProgressType.MERCHANT ->
"armed".takeIf {
move.hexA in targets.hexes &&
CatanInteraction.onHexTap(move.hexA!!, legal, armed) == TapOutcome.Submit(move)
}
org.prolibertate.games.game.catan.ProgressType.INVENTOR -> {
val first = CatanInteraction.onHexTap(move.hexA!!, legal, armed)
if (first != TapOutcome.PickFirstHex(move.hexA!!)) return null
val second = armed.copy(firstHex = move.hexA)
val partners = CatanInteraction.targets(state, legal, null, null, armed = second).hexes
"armed".takeIf {
move.hexB in partners &&
CatanInteraction.onHexTap(move.hexB!!, legal, second) == TapOutcome.Submit(move)
}
}
else ->
"armed".takeIf {
move.vertex in targets.vertices &&
CatanInteraction.onVertexTap(move.vertex!!, legal, null, null, null, armed) == TapOutcome.Submit(move)
}
}
}
private fun playAndCheck(players: Int, seed: Long): Map<String, Int> {
val ai = CatanAi()
var state = CatanRules.initialState(config(players, seed))
val seen = mutableMapOf<String, Int>()
var moves = 0
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
val seat = CatanRules.currentSeat(state) ?: break
val legal = CatanRules.legalMoves(state, seat)
for (move in legal) {
val how = reachable(state, legal, move)
assertTrue(
"seed $seed: no control produces $move in ${state.phase} " +
"(chore ${state.ck?.chores?.firstOrNull()}); log tail: ${state.log.takeLast(3)}",
how != null,
)
seen.merge(how!!, 1, Int::plus)
}
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
return seen
}
@Test
fun `every legal move in a full game has a control that produces it`() {
val total = mutableMapOf<String, Int>()
listOf(3 to 21L, 4 to 22L, 3 to 23L, 4 to 24L).forEach { (players, seed) ->
playAndCheck(players, seed).forEach { (k, v) -> total.merge(k, v, Int::plus) }
}
println("CK UI coverage: ${total.toSortedMap()}")
// The ways the screen builds, moves and answers all got exercised.
listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn")
.forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) }
}
}
@@ -0,0 +1,75 @@
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
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.ui.catanDefaultPoints
import org.prolibertate.games.ui.catanPointsChoices
import org.prolibertate.games.ui.catanSwitchRules
class CatanCkTextTest {
private val json = Json { encodeDefaults = true }
@Test
fun `every position of a Cities and Knights game has a prompt for every seat`() {
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
),
seed = 31L,
)
var state = CatanRules.initialState(config)
val ai = CatanAi()
var moves = 0
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
for (me in 0 until 3) {
val seats = CatanSeats(me, listOf("A", "B", "C"))
val text = CatanText.prompt(state, seats, setupVertexChosen = false, building = null)
assertTrue("blank prompt in ${state.phase} for seat $me", text.isNotBlank())
}
val seat = CatanRules.currentSeat(state) ?: break
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat)))
}
}
@Test
fun `every progress card has a name and a description`() {
ProgressType.entries.forEach {
assertTrue(CatanText.progress(it).isNotBlank())
assertTrue("no help for $it", CatanText.progressHelp(it).length > 10)
}
}
@Test
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
val base = CatanOptions()
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
// A short game picked on purpose is kept when both menus have it.
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
assertEquals(8, short.pointsToWin)
assertEquals(3, ck.playerCount)
}
}