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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user