Merge pull request 'Katan: Cities & Knights' (#8) from feature/cities-and-knights into dev
CI / Build and check (push) Successful in 8m14s
CI / Deploy to Cloudflare Pages (push) Successful in 32s

This commit was merged in pull request #8.
This commit is contained in:
2026-10-17 08:57:06 -06:00
24 changed files with 6383 additions and 84 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`
@@ -20,10 +20,14 @@ import org.prolibertate.games.game.engine.GameAi
*/ */
class CatanAi : GameAi<CatanState, CatanMove> { class CatanAi : GameAi<CatanState, CatanMove> {
/** Plays the Cities & Knights tables; this class keeps the base game. */
private val ck = CatanCkAi(this)
override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove { override fun chooseMove(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) { require(legal.isNotEmpty() || state.phase == CatanPhase.PLAYING) {
"No legal move for seat $seat" "No legal move for seat $seat"
} }
if (state.isCk) return ck.choose(state, seat, legal)
return when (state.phase) { return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>()) chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
@@ -42,8 +46,8 @@ class CatanAi : GameAi<CatanState, CatanMove> {
CatanPhase.PLAYING -> choosePlaying(state, seat, legal) CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.GAME_OVER -> legal.firstOrNull() CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("The game is over; nobody chooses a move") ?: error("Nothing to choose in phase ${state.phase}")
} }
} }
@@ -51,7 +55,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// Setup // Setup
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
private fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace { internal fun chooseSetup(state: CatanState, seat: Int, places: List<SetupPlace>): SetupPlace {
require(places.isNotEmpty()) { "No legal settlement placement" } require(places.isNotEmpty()) { "No legal settlement placement" }
return places.maxByOrNull { place -> return places.maxByOrNull { place ->
spotValue(state, place.vertex) + spotValue(state, place.vertex) +
@@ -293,7 +297,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/** What a seat would think of building on [vertex]: production and ports. */ /** What a seat would think of building on [vertex]: production and ports. */
private fun spotValue(state: CatanState, vertex: Int): Double { internal fun spotValue(state: CatanState, vertex: Int): Double {
if (state.buildings[vertex] != null) return -1.0 if (state.buildings[vertex] != null) return -1.0
var value = 0.0 var value = 0.0
val kinds = mutableSetOf<Resource>() val kinds = mutableSetOf<Resource>()
@@ -314,7 +318,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
* What a road on this edge leads to: the best settlement spot it brings * What a road on this edge leads to: the best settlement spot it brings
* within reach, discounted for being one step further out. * within reach, discounted for being one step further out.
*/ */
private fun roadValue(state: CatanState, move: BuildRoad): Double { internal fun roadValue(state: CatanState, move: BuildRoad): Double {
var best = 0.0 var best = 0.0
val (from, to) = CatanBoard.EDGE_VERTICES[move.edge] val (from, to) = CatanBoard.EDGE_VERTICES[move.edge]
for (endpoint in listOf(from, to)) { for (endpoint in listOf(from, to)) {
@@ -326,7 +330,7 @@ class CatanAi : GameAi<CatanState, CatanMove> {
} }
/** How much the AI wants to hold one card of [resource]. */ /** How much the AI wants to hold one card of [resource]. */
private fun want(resource: Resource): Double = when (resource) { internal fun want(resource: Resource): Double = when (resource) {
Resource.ORE -> 1.20 // the city Resource.ORE -> 1.20 // the city
Resource.GRAIN -> 1.15 // the city and the dev cards Resource.GRAIN -> 1.15 // the city and the dev cards
Resource.LUMBER -> 1.0 // every road and settlement Resource.LUMBER -> 1.0 // every road and settlement
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,345 @@
package org.prolibertate.games.game.catan
/**
* The computer opponent for Cities & Knights, reached through [CatanAi].
*
* It plays the same cautious game as the base AI — settle well, raise cities —
* and adds the three things this rule set asks of a player: keep enough active
* knights standing when the barbarians land, spend commodities on improvements
* as soon as it can, and answer every chore the table raises with the card it
* values least. It reads only what a player at the table could see, plus its
* own hand.
*
* It is not a strong player. It never moves knights about (they stay where
* hired, and are activated when the barbarians draw near), never plays the
* progress cards whose value depends on a plan (Inventor, Alchemist, Diplomat,
* Deserter, Intrigue), and makes no domestic offers, though it answers them.
*/
internal class CatanCkAi(private val ai: CatanAi) {
fun choose(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove = when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND ->
ai.chooseSetup(state, seat, legal.filterIsInstance<SetupPlace>())
CatanPhase.AWAIT_ROLL -> legal.first { it is RollDice }
CatanPhase.CHORE -> chooseChore(state, seat, legal)
CatanPhase.ROBBER -> chooseRobber(state, seat, legal.filterIsInstance<MoveRobber>())
CatanPhase.FREE_ROADS -> chooseFreeRoad(state, seat, legal)
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull()
?: error("Nothing to choose in phase ${state.phase}")
}
// -----------------------------------------------------------------------
// Chores
// -----------------------------------------------------------------------
private fun chooseChore(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val ck = state.ck!!
return when (val chore = ck.chores.first()) {
is DiscardChore -> {
val n = minOf(chore.count, CatanCk.goodsCount(state, seat))
DiscardGoods(CatanCk.goodsList(state, seat).sortedBy { worth(state, seat, it) }.take(n))
}
is GiveChore -> {
val pool = CatanCk.goodsList(state, seat)
.filter { !chore.commoditiesOnly || it.isCommodity }
.sortedBy { worth(state, seat, it) }
GiveGoods(pool.take(minOf(chore.count, pool.size)))
}
is PillageChore ->
legal.filterIsInstance<PillageCity>().minByOrNull { production(state, it.vertex) } ?: legal.first()
is PickDeckChore -> {
val picks = legal.filterIsInstance<PickDeck>()
picks.maxByOrNull { CatanCk.improvement(state, seat, it.track) } ?: legal.first()
}
is DropProgressChore ->
legal.filterIsInstance<DropProgress>().minByOrNull { drop ->
ck.progressHands[seat].first { it.id == drop.card }.kind.let(::cardWorth)
} ?: legal.first()
is MetropolisChore ->
legal.filterIsInstance<PlaceMetropolis>().maxByOrNull { production(state, it.vertex) }
?: legal.first()
is AqueductChore ->
legal.filterIsInstance<TakeAqueduct>().maxByOrNull { ai.want(it.resource) } ?: legal.first()
is RelocateChore -> legal.first()
is RemoveKnightChore ->
legal.filterIsInstance<RemoveKnight>().minByOrNull {
CatanCk.knightAt(state, it.vertex)?.level ?: 0
} ?: legal.first()
is PlaceKnightChore -> legal.first()
is SpyChore -> legal.first()
is SmithChore ->
legal.filterIsInstance<PromoteKnight>().firstOrNull() ?: legal.first { it is EndSmith }
}
}
// -----------------------------------------------------------------------
// The robber
// -----------------------------------------------------------------------
private fun chooseRobber(state: CatanState, seat: Int, moves: List<MoveRobber>): MoveRobber {
require(moves.isNotEmpty()) { "No legal robber move" }
val points = CatanRules.publicVictoryPoints(state)
return moves.maxByOrNull { move ->
val hex = CatanBoard.hexIndex.getValue(move.hex)
var score = 0.0
for (v in CatanBoard.HEX_CORNERS[hex]) {
val building = state.buildings[v] ?: continue
val worth = if (building.type == BuildingType.CITY) 2 else 1
val odds = CatanBoard.numberOdds(state.numbers[hex]) / 36.0
score += if (building.owner == seat) -worth * odds else worth * odds
}
if (move.victim != null) {
score += 0.15 * CatanCk.goodsCount(state, move.victim) + 0.3 * points[move.victim]
}
score
} ?: moves.first()
}
// -----------------------------------------------------------------------
// The main phase
// -----------------------------------------------------------------------
private fun choosePlaying(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val ck = state.ck!!
val soon = ck.barbarians <= 3
// Chase the robber off a hex it is squatting on.
if (ck.attacked) {
val onMine = legal.filterIsInstance<ChaseRobber>().firstOrNull()
val robberHurtsMe = CatanBoard.HEX_CORNERS[state.robber].any { state.buildings[it]?.owner == seat }
if (onMine != null && robberHurtsMe) return onMine
}
// The cheap upgrade first, then the full-price one.
legal.filterIsInstance<PlayProgress>()
.filter { kindOf(state, seat, it) == ProgressType.MEDICINE }
.maxByOrNull { production(state, it.vertex!!) }
?.let { return it }
legal.filterIsInstance<BuildCity>().maxByOrNull { production(state, it.vertex) }
?.let { return it }
legal.filterIsInstance<BuildSettlement>().maxByOrNull { ai.spotValue(state, it.vertex) }
?.let { return it }
// Improvements spend cards nothing else needs. Favour the track furthest along.
legal.filterIsInstance<BuyImprovement>()
.maxByOrNull { CatanCk.improvement(state, seat, it.track) * 10 + trackLean(it.track) }
?.let { return it }
// Knights: stand ready by the time the barbarians land.
defence(state, seat, legal, soon)?.let { return it }
progress(state, seat, legal)?.let { return it }
legal.filterIsInstance<BuildWall>().firstOrNull()?.let {
if (CatanCk.goodsCount(state, seat) >= 7) return it
}
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.1 }
.maxByOrNull { ai.roadValue(state, it) }
?.let { return it }
legal.filterIsInstance<GoodsBankTrade>()
.filter { completes(state, seat, it) }
.minByOrNull { CatanCk.bankRatio(state, seat, it.give) }
?.let { return it }
return legal.lastOrNull { it is EndTurn } ?: legal.first()
}
/** Hire, promote and activate so the knights are standing when the barbarians land. */
private fun defence(state: CatanState, seat: Int, legal: List<CatanMove>, soon: Boolean): CatanMove? {
val mine = CatanCk.knightsOf(state, seat)
val grainLeft = state.resources[seat][Resource.GRAIN] ?: 0
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
val want = if (soon) 4 else 2
if (mine.size < want) {
legal.filterIsInstance<HireKnight>().maxByOrNull { ai.spotValue(state, it.vertex) }?.let { hire ->
// Keep a grain back for waking it, once the ship is close.
if (!soon || grainLeft >= 1) return hire
}
}
if (soon || mine.size >= 2) {
legal.filterIsInstance<PromoteKnight>().firstOrNull()?.let { return it }
}
if (soon) {
legal.filterIsInstance<ActivateKnight>().firstOrNull()?.let { return it }
}
return null
}
/** Progress cards worth playing without a plan. */
private fun progress(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove? {
val ck = state.ck!!
val soon = ck.barbarians <= 4
val plays = legal.filterIsInstance<PlayProgress>()
fun kindOf(play: PlayProgress) = ck.progressHands[seat].first { it.id == play.card }.kind
fun first(kind: ProgressType) = plays.firstOrNull { kindOf(it) == kind }
first(ProgressType.IRRIGATION)?.let { return it }
first(ProgressType.MINING)?.let { return it }
first(ProgressType.ENGINEER)?.let { return it }
first(ProgressType.SMITH)?.let { return it }
first(ProgressType.WEDDING)?.let { return it }
if (soon) first(ProgressType.WARLORD)?.let { return it }
plays.filter { kindOf(it) == ProgressType.MASTER_MERCHANT }
.maxByOrNull { play -> play.goods.orEmpty().sumOf { worth(state, seat, it) } }
?.let { return it }
first(ProgressType.SPY)?.let { return it }
first(ProgressType.BISHOP)?.let { return it }
if (legal.filterIsInstance<BuildRoad>().size >= 2) first(ProgressType.ROAD_BUILDING)?.let { return it }
// Crane: only when it makes the difference to an improvement.
first(ProgressType.CRANE)?.let { crane ->
val helps = Track.entries.any { track ->
val level = CatanCk.improvement(state, seat, track)
val have = ck.commodities[seat][track.commodity] ?: 0
level < IMPROVEMENT_MAX && have == level // one short of the next level's price
}
if (helps) return crane
}
// Saboteur: when someone level or ahead is sitting on a pile.
first(ProgressType.SABOTEUR)?.let { saboteur ->
val vp = CatanRules.publicVictoryPoints(state)
val pile = (0 until state.playerCount).filter { it != seat && vp[it] >= vp[seat] }
.sumOf { CatanCk.goodsCount(state, it) / 2 }
if (pile >= 4) return saboteur
}
// Monopolies: only when the table is holding a pile of it.
plays.filter { kindOf(it) == ProgressType.RESOURCE_MONOPOLY || kindOf(it) == ProgressType.TRADE_MONOPOLY }
.maxByOrNull { held(state, seat, it.good!!) }
?.let { best ->
val each = if (kindOf(best) == ProgressType.RESOURCE_MONOPOLY) 2 else 1
val take = (0 until state.playerCount).filter { it != seat }
.sumOf { minOf(each, CatanCk.goodsOf(state, it)[best.good!!] ?: 0) }
if (take >= 3) return best
}
// The merchant, on the best hex beside a building of ours.
plays.filter { kindOf(it) == ProgressType.MERCHANT }
.maxByOrNull { play ->
val hex = play.hexA!!
CatanBoard.numberOdds(state.numbers[hex]).toDouble()
}?.let { return it }
// A commercial harbour turns spare resources into commodities.
plays.filter { kindOf(it) == ProgressType.COMMERCIAL_HARBOR }
.maxByOrNull { held(state, seat, it.good!!) }
?.let { if (held(state, seat, it.good!!) >= 2) return it }
return null
}
private fun kindOf(state: CatanState, seat: Int, play: PlayProgress): ProgressType =
state.ck!!.progressHands[seat].first { it.id == play.card }.kind
/** What a building on [vertex] would draw, by the odds of its hexes and what the seat wants. */
private fun production(state: CatanState, vertex: Int): Double =
CatanBoard.VERTEX_HEXES[vertex].sumOf { h ->
val want = state.terrains[h].produces?.let { ai.want(it) } ?: 0.0
want * CatanBoard.numberOdds(state.numbers[h]) / 36.0
}
private fun held(state: CatanState, seat: Int, good: Good): Int = CatanCk.goodsOf(state, seat)[good] ?: 0
/** Whether the trade leaves a hand that can pay for a city, a settlement or an improvement. */
private fun completes(state: CatanState, seat: Int, trade: GoodsBankTrade): Boolean {
val hand = CatanCk.goodsOf(state, seat).toMutableMap()
hand[trade.give] = (hand[trade.give] ?: 0) - CatanCk.bankRatio(state, seat, trade.give)
hand[trade.get] = (hand[trade.get] ?: 0) + 1
fun covers(cost: Map<Resource, Int>) = cost.all { (r, n) -> (hand[Good.of(r)] ?: 0) >= n }
if (covers(CatanRules.CITY_COST) || covers(CatanRules.SETTLEMENT_COST)) return true
return Track.entries.any { track ->
val level = CatanCk.improvement(state, seat, track)
level < IMPROVEMENT_MAX &&
(hand[Good.of(track.commodity)] ?: 0) >= level + 1 &&
(state.ck!!.commodities[seat][track.commodity] ?: 0) < level + 1
}
}
private fun chooseFreeRoad(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove =
legal.filterIsInstance<BuildRoad>()
.filter { ai.roadValue(state, it) > 0.05 }
.maxByOrNull { ai.roadValue(state, it) }
?: legal.firstOrNull { it is EndFreeRoads }
?: legal.first()
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val decline = legal.first { it is DeclineTrade }
val accept = legal.firstOrNull { it is AcceptTrade } ?: return decline
val offer = state.ck?.goodsOffer
if (offer != null) {
val incoming = offer.give.sumOf { worth(state, seat, it) }
val outgoing = offer.get.sumOf { worth(state, seat, it) }
return if (incoming > outgoing) accept else decline
}
val plain = state.pendingTrade ?: return decline
val incoming = plain.give.sumOf { ai.want(it) }
val outgoing = plain.get.sumOf { ai.want(it) }
return if (incoming > outgoing) accept else decline
}
/** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove =
legal.firstOrNull { it is DismissCounters } ?: legal.first()
// -----------------------------------------------------------------------
// Valuations
// -----------------------------------------------------------------------
/** What a card is worth to this seat: resources as the base AI sees them, commodities by what they buy. */
private fun worth(state: CatanState, seat: Int, good: Good): Double =
good.resource?.let { ai.want(it) } ?: when (good) {
Good.PAPER -> 0.95
Good.COIN -> 0.9
else -> 0.85
}
/** Which track to lean toward when two are level: politics for the knights, then science. */
private fun trackLean(track: Track): Int = when (track) {
Track.POLITICS -> 2
Track.SCIENCE -> 1
Track.TRADE -> 0
}
private fun cardWorth(kind: ProgressType): Double = when (kind) {
ProgressType.INVENTOR, ProgressType.ALCHEMIST, ProgressType.DIPLOMAT,
ProgressType.DESERTER, ProgressType.INTRIGUE, ProgressType.MERCHANT_FLEET,
-> 0.0
ProgressType.MERCHANT, ProgressType.COMMERCIAL_HARBOR, ProgressType.CRANE -> 0.5
else -> 1.0
}
}
@@ -0,0 +1,486 @@
package org.prolibertate.games.game.catan
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/*
* The data of the Cities & Knights rule set. The rules themselves live in
* [CatanCk]; docs/RULES-catan-ck.md describes them and the choices made.
*
* Everything here is reached through [CatanState.ck], which is null for the
* base game, so a base game never sees any of it.
*/
/** Steps the barbarian ship must make before it lands. */
const val BARBARIAN_STEPS = 7
/** Commodity cards of each type the bank starts with (resources have 19). */
const val BANK_PER_COMMODITY = 12
/** Knights of each strength one player owns. */
const val KNIGHTS_PER_LEVEL = 2
/** City walls one player owns. */
const val MAX_WALLS = 3
/** Levels of city improvement in each track. */
const val IMPROVEMENT_MAX = 5
/** Progress cards a player may keep in hand. */
const val PROGRESS_HAND_MAX = 4
/** Cards a player may hold through a seven, before walls. */
const val BASE_HAND_LIMIT = 7
/** Longest Road is worth two points here; there is no Largest Army. */
const val CK_LONGEST_ROAD_VP = 2
/** Improvement level that unlocks the track's special power. */
const val IMPROVEMENT_POWER_LEVEL = 3
/** Level at which a track can claim its metropolis, and the level that can steal one. */
const val METROPOLIS_LEVEL = 4
/**
* What the three cities-of-industry give in place of a second resource:
* forest gives paper, pasture cloth and mountains coin.
*/
@Serializable
enum class Commodity { CLOTH, COIN, PAPER }
/**
* Any card a player can hold in hand and trade: a resource or a commodity.
* Progress cards are not goods.
*/
@Serializable
enum class Good {
LUMBER, BRICK, WOOL, GRAIN, ORE, CLOTH, COIN, PAPER;
val resource: Resource?
get() = when (this) {
LUMBER -> Resource.LUMBER
BRICK -> Resource.BRICK
WOOL -> Resource.WOOL
GRAIN -> Resource.GRAIN
ORE -> Resource.ORE
else -> null
}
val commodity: Commodity?
get() = when (this) {
CLOTH -> Commodity.CLOTH
COIN -> Commodity.COIN
PAPER -> Commodity.PAPER
else -> null
}
val isCommodity: Boolean get() = commodity != null
companion object {
fun of(resource: Resource): Good = when (resource) {
Resource.LUMBER -> LUMBER
Resource.BRICK -> BRICK
Resource.WOOL -> WOOL
Resource.GRAIN -> GRAIN
Resource.ORE -> ORE
}
fun of(commodity: Commodity): Good = when (commodity) {
Commodity.CLOTH -> CLOTH
Commodity.COIN -> COIN
Commodity.PAPER -> PAPER
}
val RESOURCES: List<Good> = Resource.entries.map { of(it) }
val COMMODITIES: List<Good> = Commodity.entries.map { of(it) }
}
}
/**
* The three tracks of city improvement. Each is bought with one commodity, has
* a colour of progress-card deck and a power that unlocks at level three.
*/
@Serializable
enum class Track(val commodity: Commodity) {
/** Yellow, bought with cloth: trade commodities two for one. */
TRADE(Commodity.CLOTH),
/** Blue, bought with coin: knights may become mighty. */
POLITICS(Commodity.COIN),
/** Green, bought with paper: the aqueduct. */
SCIENCE(Commodity.PAPER),
}
/**
* One face of the event die: a ship, or a city gate of a track's colour.
* Three faces are ships.
*/
@Serializable
enum class EventFace { SHIP, TRADE, POLITICS, SCIENCE }
/** The progress cards: [copies] of each in the deck of its [track]. */
@Serializable
enum class ProgressType(val track: Track, val copies: Int, val scoresAtOnce: Boolean = false) {
// Science (green)
ALCHEMIST(Track.SCIENCE, 2),
CRANE(Track.SCIENCE, 2),
ENGINEER(Track.SCIENCE, 1),
INVENTOR(Track.SCIENCE, 2),
IRRIGATION(Track.SCIENCE, 2),
MEDICINE(Track.SCIENCE, 2),
MINING(Track.SCIENCE, 2),
PRINTER(Track.SCIENCE, 1, scoresAtOnce = true),
ROAD_BUILDING(Track.SCIENCE, 2),
SMITH(Track.SCIENCE, 2),
// Politics (blue)
BISHOP(Track.POLITICS, 2),
CONSTITUTION(Track.POLITICS, 1, scoresAtOnce = true),
DESERTER(Track.POLITICS, 2),
DIPLOMAT(Track.POLITICS, 2),
INTRIGUE(Track.POLITICS, 2),
SABOTEUR(Track.POLITICS, 2),
SPY(Track.POLITICS, 3),
WARLORD(Track.POLITICS, 2),
WEDDING(Track.POLITICS, 2),
// Trade (yellow)
COMMERCIAL_HARBOR(Track.TRADE, 2),
MASTER_MERCHANT(Track.TRADE, 2),
MERCHANT(Track.TRADE, 6),
MERCHANT_FLEET(Track.TRADE, 2),
RESOURCE_MONOPOLY(Track.TRADE, 4),
TRADE_MONOPOLY(Track.TRADE, 2),
}
/** One progress card; [id] is stable for the life of the game. */
@Serializable
data class ProgressCard(val id: Int, val kind: ProgressType)
/**
* A knight on the board. [level] is its strength: 1 basic, 2 strong, 3 mighty.
*
* [activatedTurn] and [promotedTurn] record the turn number each happened on:
* a knight cannot act on the turn it was activated, and cannot be promoted twice
* in one turn.
*/
@Serializable
data class Knight(
val vertex: Int,
val owner: Int,
val level: Int,
val active: Boolean = false,
val activatedTurn: Int = 0,
val promotedTurn: Int = 0,
)
/** A metropolis stands on one city: [vertex] of seat [seat]. */
@Serializable
data class MetropolisAt(val seat: Int, val vertex: Int)
/** The merchant sits on a hex, controlled by [seat] (who holds its point). */
@Serializable
data class MerchantAt(val seat: Int, val hex: Int)
/** A counter-proposal to a [GoodsOffer]: seat [by] would [give] these if the offerer handed over [get]. */
@Serializable
data class GoodsCounter(val by: Int, val give: List<Good>, val get: List<Good>)
/**
* A domestic trade offer in goods, which may include commodities. It is the
* base game's [TradeOffer] in cards of any kind, and works the same way: [to] is
* the one seat it is addressed to or null for the whole table, [asking] is the
* seat that may answer now, and [counters] collect for the offerer to choose from.
*/
@Serializable
data class GoodsOffer(
val by: Int,
val to: Int?,
val give: List<Good>,
val get: List<Good>,
val asking: Int = to ?: -1,
val counters: List<GoodsCounter> = emptyList(),
)
/** Where play goes when the queue of [Chore]s runs out. */
@Serializable
enum class Back { PLAYING, AWAIT_ROLL, AFTER_EVENT, AFTER_DISCARDS, AFTER_PRODUCTION }
/**
* Something one seat must decide before the game can go on. A chore is queued
* when a roll, an attack or a card needs an answer from somebody (possibly not
* the player whose turn it is); the first in [CkState.chores] is the one being
* asked.
*/
@Serializable
sealed interface Chore {
val seat: Int
}
/** Give [count] cards to the bank, of the seat's choosing (a seven, a saboteur). */
@Serializable
@SerialName("c_discard")
data class DiscardChore(override val seat: Int, val count: Int) : Chore
/**
* Hand [count] cards of the seat's choosing to seat [to] (a wedding, a
* commercial harbour). [commoditiesOnly] restricts them to commodities, and
* [reward] is the resource [to] gives back for each.
*/
@Serializable
@SerialName("c_give")
data class GiveChore(
override val seat: Int,
val count: Int,
val to: Int,
val commoditiesOnly: Boolean = false,
val reward: Good? = null,
) : Chore
/** The barbarians won and this seat lost the contest: pick a city to fall. */
@Serializable
@SerialName("c_pillage")
data class PillageChore(override val seat: Int) : Chore
/** Choose a colour of progress deck to draw from. */
@Serializable
@SerialName("c_pickDeck")
data class PickDeckChore(override val seat: Int) : Chore
/** More progress cards than the hand holds: give one back. */
@Serializable
@SerialName("c_dropProgress")
data class DropProgressChore(override val seat: Int) : Chore
/** Choose the city that will bear the [track] metropolis. */
@Serializable
@SerialName("c_metropolis")
data class MetropolisChore(override val seat: Int, val track: Track) : Chore
/** The aqueduct pays out: take any one resource. */
@Serializable
@SerialName("c_aqueduct")
data class AqueductChore(override val seat: Int) : Chore
/** A knight was driven from its place; move it, or lose it. [knight] stood on [Knight.vertex]. */
@Serializable
@SerialName("c_relocate")
data class RelocateChore(override val seat: Int, val knight: Knight) : Chore
/** A deserter was played against this seat: remove one of your knights. */
@Serializable
@SerialName("c_removeKnight")
data class RemoveKnightChore(override val seat: Int, val by: Int) : Chore
/** Place a knight of [level] on the board for free, active or not as given. */
@Serializable
@SerialName("c_placeKnight")
data class PlaceKnightChore(override val seat: Int, val level: Int, val active: Boolean) : Chore
/** Choose one of [target]'s progress cards to take. */
@Serializable
@SerialName("c_spy")
data class SpyChore(override val seat: Int, val target: Int) : Chore
/** Promote up to [left] more knights for free. */
@Serializable
@SerialName("c_smith")
data class SmithChore(override val seat: Int, val left: Int) : Chore
/**
* Everything Cities & Knights adds to the table.
*
* Counts that are public ([progressCounts], the knights, the improvement
* levels) are stored directly; the decks and the other seats' progress hands are
* here in full because the host owns the truth, and [CatanRules.viewFor] keeps
* them off the wire.
*/
@Serializable
data class CkState(
/** Commodity cards per seat. */
val commodities: List<Map<Commodity, Int>>,
/** Improvement level (0..5) per seat and track. */
val improvements: List<Map<Track, Int>>,
/** The metropolis of each track that has one. */
val metropolises: Map<Track, MetropolisAt> = emptyMap(),
val knights: List<Knight> = emptyList(),
/** Vertices whose city has a wall. */
val walls: List<Int> = emptyList(),
/** Steps left before the barbarians land, [BARBARIAN_STEPS] at the start. */
val barbarians: Int = BARBARIAN_STEPS,
/** Whether the barbarians have landed once: until then the robber sleeps. */
val attacked: Boolean = false,
val eventDie: EventFace? = null,
/** Progress decks, top first. Order is hidden. */
val decks: Map<Track, List<ProgressCard>>,
val progressHands: List<List<ProgressCard>>,
/** Public mirror of the progress-hand sizes. */
val progressCounts: List<Int>,
/** Victory points from Constitution and Printer, played face up at once. */
val progressVp: List<Int>,
/** Defender of Catan cards won, one point each. */
val defenderVp: List<Int>,
val merchant: MerchantAt? = null,
/** A crane was played this turn and its discount is unspent. */
val crane: Boolean = false,
/** A merchant fleet was played this turn for this good. */
val fleet: Good? = null,
/** Dice named by an alchemist, used by the next roll. */
val alchemist: List<Int>? = null,
/** The robber is being moved by a bishop, which robs everyone nearby. */
val bishop: Boolean = false,
val chores: List<Chore> = emptyList(),
val back: Back = Back.PLAYING,
val goodsOffer: GoodsOffer? = null,
/** Seats that have already drawn a defender or winners' card this attack, for the log only. */
val lastAttack: String? = null,
)
// ---------------------------------------------------------------------------
// Moves
// ---------------------------------------------------------------------------
/** Buy the next level of improvement in [track]. */
@Serializable
@SerialName("buyImprovement")
data class BuyImprovement(val track: Track) : CatanMove
/** Hire a basic knight onto [vertex], an empty crossing at the end of one of your roads. */
@Serializable
@SerialName("hireKnight")
data class HireKnight(val vertex: Int) : CatanMove
/** Pay a grain to activate the knight on [vertex]. */
@Serializable
@SerialName("activateKnight")
data class ActivateKnight(val vertex: Int) : CatanMove
/** Promote the knight on [vertex] one level (paid, or free under a Smith). */
@Serializable
@SerialName("promoteKnight")
data class PromoteKnight(val vertex: Int) : CatanMove
/** Move the active knight on [from] along your roads to [to], driving off a weaker knight there. */
@Serializable
@SerialName("moveKnight")
data class MoveKnight(val from: Int, val to: Int) : CatanMove
/** The active knight on [vertex] chases the robber; the robber move follows. */
@Serializable
@SerialName("chaseRobber")
data class ChaseRobber(val vertex: Int) : CatanMove
/** Raise a wall around the city on [vertex]. */
@Serializable
@SerialName("buildWall")
data class BuildWall(val vertex: Int) : CatanMove
/**
* Play a progress card. Which of the optional arguments are used depends on
* the card (see [CatanCk]); the engine's legal list names every combination it
* accepts, so a client only ever chooses among them.
*/
@Serializable
@SerialName("progress")
data class PlayProgress(
val card: Int,
val target: Int? = null,
val vertex: Int? = null,
val edge: Int? = null,
val hexA: Int? = null,
val hexB: Int? = null,
val good: Good? = null,
val dice: List<Int>? = null,
val goods: List<Good>? = null,
) : CatanMove
/**
* Trade [give] to the bank for [get], at the best ratio this seat has for the
* card it gives. Replaces [MaritimeTrade] in Cities & Knights, where
* commodities trade too.
*/
@Serializable
@SerialName("goodsBank")
data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove
/**
* Offer a trade in goods to seat [to], or to the whole table when [to] is null.
* Free-form like [OfferTrade], so it is checked structurally and is not in the
* enumerated move list.
*/
@Serializable
@SerialName("offerGoods")
data class OfferGoods(val to: Int?, val give: List<Good>, val get: List<Good>) : CatanMove
/**
* Answer the goods offer on the table with different terms. Structural, like
* [OfferGoods]; the answers to the counters are the shared [AcceptCounter] and
* [DismissCounters].
*/
@Serializable
@SerialName("counterGoods")
data class CounterGoods(val give: List<Good>, val get: List<Good>) : CatanMove
/**
* Give up exactly the cards named, to a [DiscardChore]. Structural, not
* enumerated; the legal list carries one example so a seat is never without a move.
*/
@Serializable
@SerialName("discardGoods")
data class DiscardGoods(val cards: List<Good>) : CatanMove
/** Hand over the cards named to a [GiveChore]. Structural, like [DiscardGoods]. */
@Serializable
@SerialName("giveGoods")
data class GiveGoods(val cards: List<Good>) : CatanMove
/** Let the city on [vertex] fall to the barbarians. */
@Serializable
@SerialName("pillageCity")
data class PillageCity(val vertex: Int) : CatanMove
/** Draw from the deck of this track's colour. */
@Serializable
@SerialName("pickDeck")
data class PickDeck(val track: Track) : CatanMove
/** Give up one progress card from a hand that is over the limit. */
@Serializable
@SerialName("dropProgress")
data class DropProgress(val card: Int) : CatanMove
/** Put the metropolis on the city at [vertex]. */
@Serializable
@SerialName("placeMetropolis")
data class PlaceMetropolis(val vertex: Int) : CatanMove
/** Take one [resource] from the bank for an aqueduct. */
@Serializable
@SerialName("aqueduct")
data class TakeAqueduct(val resource: Resource) : CatanMove
/** Move the knight that was driven off to [to]. */
@Serializable
@SerialName("relocateKnight")
data class RelocateKnight(val to: Int) : CatanMove
/** Take the knight on [vertex] off the board, for a deserter. */
@Serializable
@SerialName("removeKnight")
data class RemoveKnight(val vertex: Int) : CatanMove
/** Place the free knight from a deserter on [vertex]. */
@Serializable
@SerialName("placeKnight")
data class PlaceKnight(val vertex: Int) : CatanMove
/** Take the spied progress card with this id. */
@Serializable
@SerialName("spyTake")
data class SpyTake(val card: Int) : CatanMove
/** Stop promoting knights under a Smith. */
@Serializable
@SerialName("endSmith")
data object EndSmith : CatanMove
@@ -133,12 +133,30 @@ enum class CatanPhase {
TRADE_COUNTERS, TRADE_COUNTERS,
GAME_OVER, GAME_OVER,
/**
* Cities & Knights only: the first seat in [CkState.chores] has something
* to decide (a discard, a city to give up, a card to draw…) and the game
* waits on it. Play resumes where [CkState.back] says.
*/
CHORE,
}
/** Which rules a table plays. The expansion is a whole rule set, not a toggle. */
@Serializable
enum class CatanRuleSet {
/** The base game: development cards, Largest Army, a robber from the start. */
BASE,
/** Cities & Knights: commodities, knights, barbarians, city improvements. */
CITIES_AND_KNIGHTS,
} }
@Serializable @Serializable
data class CatanOptions( data class CatanOptions(
val playerCount: Int = 3, val playerCount: Int = 3,
val pointsToWin: Int = 10, val pointsToWin: Int = 10,
val ruleSet: CatanRuleSet = CatanRuleSet.BASE,
) { ) {
init { init {
require(playerCount in 3..4) { "The island seats three or four players" } require(playerCount in 3..4) { "The island seats three or four players" }
@@ -210,9 +228,14 @@ data class CatanState(
val rngStep: Int, val rngStep: Int,
val winner: Int?, val winner: Int?,
val log: List<String>, val log: List<String>,
/** Everything Cities & Knights adds; null in the base game. */
val ck: CkState? = null,
) { ) {
val playerCount: Int get() = options.playerCount val playerCount: Int get() = options.playerCount
/** Whether this table plays Cities & Knights. */
val isCk: Boolean get() = ck != null
/** Total resource cards held by [seat]. */ /** Total resource cards held by [seat]. */
fun resourceCount(seat: Int): Int = resources[seat].values.sum() fun resourceCount(seat: Int): Int = resources[seat].values.sum()
@@ -247,7 +270,11 @@ data class SetupPlace(val vertex: Int, val edge: Int) : CatanMove
*/ */
@Serializable @Serializable
@SerialName("roll") @SerialName("roll")
data class RollDice(val forced: List<Int>? = null) : CatanMove data class RollDice(
val forced: List<Int>? = null,
/** Test-only, like [forced]: the event die of a Cities & Knights roll. */
val event: EventFace? = null,
) : CatanMove
/** Discard exactly these cards. */ /** Discard exactly these cards. */
@Serializable @Serializable
@@ -57,9 +57,16 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val board = CatanBoard.generate(config.seed) val board = CatanBoard.generate(config.seed)
// A different random stream than the island, so the same seed still // A different random stream than the island, so the same seed still
// produces both a fixed island and a fixed deck. // produces both a fixed island and a fixed deck.
val devDeck = DEV_DECK val ck = options.ruleSet == CatanRuleSet.CITIES_AND_KNIGHTS
.mapIndexed { id, type -> DevCard(id, type) } // Cities & Knights has no development cards: knights and progress
.shuffled(Random(config.seed + 1)) // cards take their place.
val devDeck = if (ck) {
emptyList()
} else {
DEV_DECK
.mapIndexed { id, type -> DevCard(id, type) }
.shuffled(Random(config.seed + 1))
}
return CatanState( return CatanState(
options = options, options = options,
@@ -91,8 +98,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
rngStep = 0, rngStep = 0,
winner = null, winner = null,
log = listOf( log = listOf(
"Catan — ${options.playerCount} players, first to ${options.pointsToWin} victory points.", "Catan${if (ck) " — Cities & Knights" else ""} — ${options.playerCount} players, " +
"first to ${options.pointsToWin} victory points.",
), ),
ck = if (ck) CatanCk.initial(options, config.seed) else null,
) )
} }
@@ -102,7 +111,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
CatanPhase.DISCARDING -> state.discardSeat CatanPhase.DISCARDING -> state.discardSeat
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 } CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
CatanPhase.TRADE_COUNTERS -> state.turn CatanPhase.TRADE_COUNTERS -> state.turn
CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER, CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS, CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn -> state.turn
@@ -133,12 +144,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is CounterTrade -> counterLegal(state, seat, move) is CounterTrade -> counterLegal(state, seat, move)
else -> move in legalMoves(state, seat) else -> if (state.isCk) {
CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat))
} else {
move in legalMoves(state, seat)
}
} }
} }
override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> { override fun legalMoves(state: CatanState, seat: Int): List<CatanMove> {
if (currentSeat(state) != seat) return emptyList() if (currentSeat(state) != seat) return emptyList()
if (state.isCk) return CatanCk.legalMoves(state, seat)
return when (state.phase) { return when (state.phase) {
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state) CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> setupMoves(state)
@@ -157,11 +173,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.TRADE_COUNTERS -> counterChoices(state) CatanPhase.TRADE_COUNTERS -> counterChoices(state)
CatanPhase.GAME_OVER -> emptyList() CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList()
} }
} }
private fun setupMoves(state: CatanState): List<SetupPlace> { internal fun setupMoves(state: CatanState): List<SetupPlace> {
val moves = mutableListOf<SetupPlace>() val moves = mutableListOf<SetupPlace>()
for (v in 0 until CatanBoard.VERTEX_COUNT) { for (v in 0 until CatanBoard.VERTEX_COUNT) {
if (!vertexOpenForSettlement(state, v)) continue if (!vertexOpenForSettlement(state, v)) continue
@@ -281,7 +297,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* Road placements that connect to [seat]'s network, cost aside. A player * Road placements that connect to [seat]'s network, cost aside. A player
* with all fifteen roads laid has none. * with all fifteen roads laid has none.
*/ */
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> = internal fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
if (state.roads.count { it == seat } >= MAX_ROADS) { if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList() emptyList()
} else { } else {
@@ -296,7 +312,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* connection requirement does not apply — that is why setup uses its * connection requirement does not apply — that is why setup uses its
* own move.) * own move.)
*/ */
private fun settlementVertices(state: CatanState, seat: Int): List<Int> = internal fun settlementVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) { if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList() emptyList()
} else { } else {
@@ -306,7 +322,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
} }
private fun cityVertices(state: CatanState, seat: Int): List<Int> = internal fun cityVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) { if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList() emptyList()
} else { } else {
@@ -315,11 +331,14 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
} }
private fun hasKnight(state: CatanState, vertex: Int): Boolean =
state.ck?.knights?.any { it.vertex == vertex } == true
/** How many settlements or cities [seat] currently has standing. */ /** How many settlements or cities [seat] currently has standing. */
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int = private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
state.buildings.count { it?.owner == seat && it.type == type } state.buildings.count { it?.owner == seat && it.type == type }
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> { internal fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
val offer = state.pendingTrade ?: return emptyList() val offer = state.pendingTrade ?: return emptyList()
val responses = mutableListOf<CatanMove>() val responses = mutableListOf<CatanMove>()
if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) { if (state.holdsAll(seat, offer.get) && state.holdsAll(offer.by, offer.give)) {
@@ -330,7 +349,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
/** What the offerer may do with the counters: take one that is still affordable, or none. */ /** What the offerer may do with the counters: take one that is still affordable, or none. */
private fun counterChoices(state: CatanState): List<CatanMove> { internal fun counterChoices(state: CatanState): List<CatanMove> {
val offer = state.pendingTrade ?: return emptyList() val offer = state.pendingTrade ?: return emptyList()
val moves = mutableListOf<CatanMove>() val moves = mutableListOf<CatanMove>()
for (counter in offer.counters) { for (counter in offer.counters) {
@@ -353,8 +372,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
/** An empty vertex with no building on it or one edge away. */ /** An empty vertex with no building on it or one edge away. */
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && state.buildings[v] == null && !hasKnight(state, v) &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/** /**
@@ -373,6 +392,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
/** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */ /** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean { private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
val building = state.buildings[vertex] val building = state.buildings[vertex]
// A knight is a wall to everyone but its owner, like a building.
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
if (knight != null && knight.owner != seat) return false
// Your own building is always a way in; anyone else's is a wall. // Your own building is always a way in; anyone else's is a wall.
if (building != null) return building.owner == seat if (building != null) return building.owner == seat
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat } return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
@@ -418,18 +440,22 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is MaritimeTrade -> applyMaritimeTrade(state, seat, move) is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
EndTurn -> applyEndTurn(state, seat) EndTurn -> applyEndTurn(state, seat)
EndFreeRoads -> applyEndFreeRoads(state, seat) EndFreeRoads -> applyEndFreeRoads(state, seat)
} else -> CatanCk.apply(state, seat, move)
}.let { if (it.isCk) CatanCk.settle(it) else it }
} }
private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState { private fun applySetup(state: CatanState, seat: Int, move: SetupPlace): CatanState {
val n = state.playerCount val n = state.playerCount
val second = state.phase == CatanPhase.SETUP_SECOND val second = state.phase == CatanPhase.SETUP_SECOND
// In Cities & Knights the second placement is a city.
val first = if (state.isCk && second) BuildingType.CITY else BuildingType.SETTLEMENT
var s = state.copy( var s = state.copy(
buildings = state.buildings.toMutableList().also { buildings = state.buildings.toMutableList().also {
it[move.vertex] = CatanBuilding(BuildingType.SETTLEMENT, seat) it[move.vertex] = CatanBuilding(first, seat)
}, },
roads = state.roads.toMutableList().also { it[move.edge] = seat }, roads = state.roads.toMutableList().also { it[move.edge] = seat },
log = state.log + "Seat $seat settles at ${move.vertex} and lays a road on ${move.edge}.", log = state.log + ("Seat $seat ${if (first == BuildingType.CITY) "raises a city" else "settles"} " +
"at ${move.vertex} and lays a road on ${move.edge}."),
) )
if (second) { if (second) {
@@ -463,6 +489,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState { private fun applyRoll(state: CatanState, seat: Int, move: RollDice): CatanState {
if (state.isCk) return CatanCk.applyRoll(state, seat, move)
val dice = move.forced ?: derivedDice(state) val dice = move.forced ?: derivedDice(state)
val total = dice[0] + dice[1] val total = dice[0] + dice[1]
val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1) val s = state.copy(lastDice = dice, rngStep = state.rngStep + 1)
@@ -537,6 +564,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 } ((after + 1) until state.playerCount).firstOrNull { state.resourceCount(it) > 7 }
private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState { private fun applyRobber(state: CatanState, seat: Int, move: MoveRobber): CatanState {
if (state.isCk) return CatanCk.applyRobber(state, seat, move)
val hex = CatanBoard.hexIndex[move.hex] val hex = CatanBoard.hexIndex[move.hex]
?: throw IllegalArgumentException("No hex at ${move.hex}") ?: throw IllegalArgumentException("No hex at ${move.hex}")
var s = state.copy(robber = hex, rngStep = state.rngStep + 1) var s = state.copy(robber = hex, rngStep = state.rngStep + 1)
@@ -553,8 +581,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
return s.copy( return s.copy(
phase = CatanPhase.PLAYING, phase = CatanPhase.PLAYING,
log = s.log + "Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" + log = s.log + ("Seat $seat moves the robber to the ${terrainName(state.terrains[hex])}" +
" (${move.hex.q},${move.hex.r})$stolen.", " (${move.hex.q},${move.hex.r})$stolen."),
) )
} }
@@ -566,7 +594,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (free) { if (free) {
val left = s.pendingFreeRoads - 1 val left = s.pendingFreeRoads - 1
s = s.copy(pendingFreeRoads = left) s = s.copy(pendingFreeRoads = left)
if (left == 0) s = s.copy(phase = CatanPhase.PLAYING) if (left == 0) s = s.copy(phase = phaseAfterRoads(s))
} }
return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}.")) return checkWin(s.copy(log = s.log + "Seat $seat builds a road on ${move.edge}."))
} }
@@ -591,7 +619,10 @@ object CatanRules : GameRules<CatanState, CatanMove> {
}, },
) )
s = payCost(s, seat, CITY_COST) s = payCost(s, seat, CITY_COST)
return checkWin(s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.")) s = s.copy(log = s.log + "Seat $seat raises a city at ${move.vertex}.")
// A city can be what a waiting metropolis needed.
if (s.isCk) s = CatanCk.afterCityBuilt(s, seat)
return checkWin(s)
} }
private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState { private fun applyBuyDevCard(state: CatanState, seat: Int): CatanState {
@@ -730,6 +761,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState { private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true)
val offer = state.pendingTrade!! val offer = state.pendingTrade!!
return state.copy( return state.copy(
phase = CatanPhase.PLAYING, phase = CatanPhase.PLAYING,
@@ -740,7 +772,11 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState = private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.") if (state.ck?.goodsOffer != null) {
CatanCk.answerOffer(state, seat, accept = false)
} else {
advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.")
}
private fun applyCounterTrade(state: CatanState, seat: Int, move: CounterTrade): CatanState { private fun applyCounterTrade(state: CatanState, seat: Int, move: CounterTrade): CatanState {
val offer = state.pendingTrade!! val offer = state.pendingTrade!!
@@ -754,6 +790,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
private fun applyAcceptCounter(state: CatanState, move: AcceptCounter): CatanState { private fun applyAcceptCounter(state: CatanState, move: AcceptCounter): CatanState {
if (state.ck?.goodsOffer != null) return CatanCk.acceptCounter(state, move)
val offer = state.pendingTrade!! val offer = state.pendingTrade!!
val counter = offer.counters.first { it.by == move.seat } val counter = offer.counters.first { it.by == move.seat }
return state.copy( return state.copy(
@@ -765,7 +802,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
} }
private fun applyDismissCounters(state: CatanState): CatanState = private fun applyDismissCounters(state: CatanState): CatanState =
state.copy( if (state.ck?.goodsOffer != null) CatanCk.dismissCounters(state) else state.copy(
phase = CatanPhase.PLAYING, phase = CatanPhase.PLAYING,
pendingTrade = null, pendingTrade = null,
log = state.log + "Seat ${state.turn} turns the counter-offers down.", log = state.log + "Seat ${state.turn} turns the counter-offers down.",
@@ -777,8 +814,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1)) val s = state.copy(resources = withCards(paid, seat, listOf(move.get), 1))
val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour" val via = if (ratio == 4) "the bank" else "a $ratio:1 harbour"
return s.copy( return s.copy(
log = s.log + "Seat $seat trades $ratio ${move.give.name.lowercase()} for a " + log = s.log + ("Seat $seat trades $ratio ${move.give.name.lowercase()} for a " +
"${move.get.name.lowercase()} via $via.", "${move.get.name.lowercase()} via $via."),
) )
} }
@@ -788,7 +825,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
if (settled.phase == CatanPhase.GAME_OVER) return settled if (settled.phase == CatanPhase.GAME_OVER) return settled
val next = (state.turn + 1) % state.playerCount val next = (state.turn + 1) % state.playerCount
val s = state.copy( val s = (if (state.isCk) CatanCk.endTurn(state) else state).copy(
turn = next, turn = next,
turnNumber = state.turnNumber + 1, turnNumber = state.turnNumber + 1,
phase = CatanPhase.AWAIT_ROLL, phase = CatanPhase.AWAIT_ROLL,
@@ -801,9 +838,13 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return checkWin(s) return checkWin(s)
} }
/** Where play goes after free roads: a card played before the roll returns to the roll. */
private fun phaseAfterRoads(state: CatanState): CatanPhase =
if (state.ck?.back == Back.AWAIT_ROLL) CatanPhase.AWAIT_ROLL else CatanPhase.PLAYING
private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState = private fun applyEndFreeRoads(state: CatanState, seat: Int): CatanState =
state.copy( state.copy(
phase = CatanPhase.PLAYING, phase = phaseAfterRoads(state),
pendingFreeRoads = 0, pendingFreeRoads = 0,
log = state.log + "Seat $seat forfeits the remaining free road.", log = state.log + "Seat $seat forfeits the remaining free road.",
) )
@@ -819,6 +860,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* view or the host's copy. * view or the host's copy.
*/ */
fun publicVictoryPoints(state: CatanState): List<Int> { fun publicVictoryPoints(state: CatanState): List<Int> {
if (state.isCk) return CatanCk.publicVictoryPoints(state)
val points = IntArray(state.playerCount) val points = IntArray(state.playerCount)
state.buildings.forEach { building -> state.buildings.forEach { building ->
if (building != null) { if (building != null) {
@@ -834,7 +876,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
publicVictoryPoints(state)[seat] + publicVictoryPoints(state)[seat] +
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT } state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
private fun checkWin(state: CatanState): CatanState { internal fun checkWin(state: CatanState): CatanState {
if (state.phase == CatanPhase.GAME_OVER) return state if (state.phase == CatanPhase.GAME_OVER) return state
val seat = state.turn val seat = state.turn
val points = victoryPoints(state, seat) val points = victoryPoints(state, seat)
@@ -855,7 +897,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* road falls below five segments the award reopens to the longest road * road falls below five segments the award reopens to the longest road
* of five or more, lowest seat first on a tie. * of five or more, lowest seat first on a tie.
*/ */
private fun refreshLongestRoad(state: CatanState): CatanState { internal fun refreshLongestRoad(state: CatanState): CatanState {
val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) } val lengths = (0 until state.playerCount).map { longestRoadFor(state, it) }
val holder = state.longestRoadOwner val holder = state.longestRoadOwner
val best = lengths val best = lengths
@@ -891,6 +933,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
val building = state.buildings[v] val building = state.buildings[v]
if (building != null && building.owner != seat) blocked[v] = true if (building != null && building.owner != seat) blocked[v] = true
} }
state.ck?.knights?.forEach { if (it.owner != seat) blocked[it.vertex] = true }
for (start in 0 until CatanBoard.VERTEX_COUNT) { for (start in 0 until CatanBoard.VERTEX_COUNT) {
if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue if (CatanBoard.VERTEX_EDGES[start].none { state.roads[it] == seat }) continue
val visited = BooleanArray(CatanBoard.VERTEX_COUNT) val visited = BooleanArray(CatanBoard.VERTEX_COUNT)
@@ -952,7 +995,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
fun bankSupply(state: CatanState, resource: Resource): Int = fun bankSupply(state: CatanState, resource: Resource): Int =
BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 } BANK_PER_RESOURCE - state.resources.sumOf { it[resource] ?: 0 }
private fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean = internal fun affordable(state: CatanState, seat: Int, cost: Map<Resource, Int>): Boolean =
cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need } cost.all { (resource, need) -> (state.resources[seat][resource] ?: 0) >= need }
private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState = private fun payCost(state: CatanState, seat: Int, cost: Map<Resource, Int>): CatanState =
@@ -1003,6 +1046,16 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return best return best
} }
/** 3 if [seat] has a settlement on a generic harbour, else the bank's 4. */
fun genericPortRatio(state: CatanState, seat: Int): Int {
for (port in state.ports) {
if (port.resource != null) continue
val (a, b) = CatanBoard.EDGE_VERTICES[port.edge]
if (state.buildings[a]?.owner == seat || state.buildings[b]?.owner == seat) return port.ratio
}
return 4
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Results, redaction and wire format // Results, redaction and wire format
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -1017,12 +1070,15 @@ object CatanRules : GameRules<CatanState, CatanMove> {
else -> "Seat $winner wins" else -> "Seat $winner wins"
} }
override fun viewFor(state: CatanState, seat: Int): CatanState = state.copy( override fun viewFor(state: CatanState, seat: Int): CatanState = CatanCk.viewFor(
// Hidden information stays with the holder and the host. state.copy(
devDeck = emptyList(), // Hidden information stays with the holder and the host.
devHands = state.devHands.mapIndexed { index, hand -> devDeck = emptyList(),
if (index == seat) hand else emptyList() devHands = state.devHands.mapIndexed { index, hand ->
}, if (index == seat) hand else emptyList()
},
),
seat,
) )
override fun encodeState(state: CatanState): String = json.encodeToString(state) override fun encodeState(state: CatanState): String = json.encodeToString(state)
@@ -58,6 +58,7 @@ import org.prolibertate.games.game.pirates.PiratesLevel
import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions import org.prolibertate.games.game.tayu.TayuOptions
@@ -334,9 +335,55 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
else -> seat else -> seat
} }
/** The targets the setup screen offers for a Katan rule set. */
internal fun catanPointsChoices(ruleSet: CatanRuleSet): List<Int> = when (ruleSet) {
// Five is a short game for trying things out; ten is the published target.
CatanRuleSet.BASE -> listOf(5, 8, 10, 12)
// Cities & Knights is played to thirteen, which is also why it runs longer.
CatanRuleSet.CITIES_AND_KNIGHTS -> listOf(8, 10, 13, 15)
}
/** The published target for a rule set. */
internal fun catanDefaultPoints(ruleSet: CatanRuleSet): Int = when (ruleSet) {
CatanRuleSet.BASE -> 10
CatanRuleSet.CITIES_AND_KNIGHTS -> 13
}
/**
* [options] switched to [ruleSet]. The target moves to the new rule set's own
* default unless the one already chosen is on its menu too.
*/
internal fun catanSwitchRules(options: CatanOptions, ruleSet: CatanRuleSet): CatanOptions =
options.copy(
ruleSet = ruleSet,
pointsToWin = options.pointsToWin.takeIf { it in catanPointsChoices(ruleSet) && it != catanDefaultPoints(options.ruleSet) }
?: catanDefaultPoints(ruleSet),
)
private fun catanRuleSetName(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE -> "Base game"
CatanRuleSet.CITIES_AND_KNIGHTS -> "Cities & Knights"
}
private fun catanRuleSetBlurb(ruleSet: CatanRuleSet): String = when (ruleSet) {
CatanRuleSet.BASE ->
"Settle the island, trade, and play development cards. Longest Road and Largest Army score."
CatanRuleSet.CITIES_AND_KNIGHTS ->
"Cities make commodities, knights hold off the barbarians, and improvements earn progress " +
"cards and metropolises. No development cards; the robber sleeps until the first attack."
}
@Composable @Composable
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) { private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Rules",
values = CatanRuleSet.entries,
selected = options.ruleSet,
display = ::catanRuleSetName,
onSelect = { onChange(catanSwitchRules(options, it)) },
)
Text(catanRuleSetBlurb(options.ruleSet), style = MaterialTheme.typography.bodySmall)
ChipRow( ChipRow(
label = "Players", label = "Players",
values = listOf(3, 4), values = listOf(3, 4),
@@ -346,8 +393,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
) )
ChipRow( ChipRow(
label = "Game to", label = "Game to",
// Five is a short game for trying things out; ten is the published target. values = catanPointsChoices(options.ruleSet),
values = listOf(5, 8, 10, 12),
selected = options.pointsToWin, selected = options.pointsToWin,
display = { "$it points" }, display = { "$it points" },
onSelect = { onChange(options.copy(pointsToWin = it)) }, onSelect = { onChange(options.copy(pointsToWin = it)) },
@@ -14,6 +14,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
@@ -58,6 +59,8 @@ fun CatanBoardView(
style: CatanStyle = LocalCatanStyle.current, style: CatanStyle = LocalCatanStyle.current,
/** Hexes to light up as having produced on the latest roll. */ /** Hexes to light up as having produced on the latest roll. */
rolledHexes: Set<Int> = emptySet(), rolledHexes: Set<Int> = emptySet(),
/** Cities & Knights: the knight whose menu is open or that is about to move. */
selectedKnight: Int? = null,
) { ) {
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
var size by remember { mutableStateOf(IntSize.Zero) } var size by remember { mutableStateOf(IntSize.Zero) }
@@ -86,8 +89,8 @@ fun CatanBoardView(
val at = Pt(tap.x, tap.y) val at = Pt(tap.x, tap.y)
val reach = board.radius * style.tapReach val reach = board.radius * style.tapReach
when { when {
targets.vertices.isNotEmpty() -> targets.tappableVertices.isNotEmpty() ->
board.nearestVertex(at, reach, targets.vertices)?.let { vertexTap(it) } board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) }
targets.edges.isNotEmpty() -> targets.edges.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) } board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
@@ -105,10 +108,15 @@ fun CatanBoardView(
drawHexes(state, board, style, measurer) drawHexes(state, board, style, measurer)
drawRolled(rolledHexes, board, style) drawRolled(rolledHexes, board, style)
drawRobber(state, board, style) drawRobber(state, board, style)
drawMerchant(state, board, style, measurer)
drawRoads(state, board, style) drawRoads(state, board, style)
drawEdgeTargets(targets, board, style) drawEdgeTargets(targets, board, style)
drawBuildings(state, board, style) drawBuildings(state, board, style)
drawVertexTargets(targets, selectedVertex, board, style) drawWalls(state, board, style)
drawMetropolises(state, board, style)
drawKnights(state, board, style)
drawKnightTargets(targets, board, style)
drawVertexTargets(targets, selectedVertex ?: selectedKnight, board, style)
drawHexTargets(targets, board, style) drawHexTargets(targets, board, style)
} }
} }
@@ -185,6 +193,10 @@ private fun DrawScope.drawToken(
} }
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) { private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
// In Cities & Knights the robber sleeps, drawn faintly, until the barbarians first land.
val asleep = state.ck?.attacked == false
val tint = if (asleep) style.robber.copy(alpha = 0.3f) else style.robber
val rim = Color.White.copy(alpha = if (asleep) 0.25f else 0.7f)
val hex = board.hexCenter(state.robber) val hex = board.hexCenter(state.robber)
// Beside the number token rather than on it, so the number stays readable. // Beside the number token rather than on it, so the number stays readable.
val x = hex.x + board.radius * 0.5f val x = hex.x + board.radius * 0.5f
@@ -197,11 +209,11 @@ private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: C
lineTo(x + s * 0.55f, y + s * 0.9f) lineTo(x + s * 0.55f, y + s * 0.9f)
close() close()
} }
drawPath(pawn, color = style.robber) drawPath(pawn, color = tint)
drawCircle(color = style.robber, radius = s * 0.38f, center = Offset(x, y - s * 0.42f)) drawCircle(color = tint, radius = s * 0.38f, center = Offset(x, y - s * 0.42f))
drawPath(pawn, color = Color.White.copy(alpha = 0.7f), style = Stroke(width = s * 0.07f)) drawPath(pawn, color = rim, style = Stroke(width = s * 0.07f))
drawCircle( drawCircle(
color = Color.White.copy(alpha = 0.7f), color = rim,
radius = s * 0.38f, radius = s * 0.38f,
center = Offset(x, y - s * 0.42f), center = Offset(x, y - s * 0.42f),
style = Stroke(width = s * 0.07f), style = Stroke(width = s * 0.07f),
@@ -409,6 +421,131 @@ private fun cityPath(at: Pt, s: Float): Path = Path().apply {
close() close()
} }
// ---------------------------------------------------------------------------
// Cities & Knights pieces
// ---------------------------------------------------------------------------
/** A dashed wall round each walled city, crenellated by the dashes. */
private fun DrawScope.drawWalls(state: CatanState, board: BoardLayout, style: CatanStyle) {
val walls = state.ck?.walls ?: return
for (vertex in walls) {
val at = board.vertex(vertex).offset()
val radius = board.radius * style.citySize * 1.25f
val dash = PathEffect.dashPathEffect(floatArrayOf(radius * 0.38f, radius * 0.22f))
drawCircle(
color = style.pieceOutline,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.13f, pathEffect = dash),
)
drawCircle(
color = style.wall,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.09f, pathEffect = dash),
)
}
}
/** A pennant in the track's colour flying over a city that carries its metropolis. */
private fun DrawScope.drawMetropolises(state: CatanState, board: BoardLayout, style: CatanStyle) {
val metropolises = state.ck?.metropolises ?: return
for ((track, at) in metropolises) {
val base = board.vertex(at.vertex)
val unit = board.radius * style.citySize
val poleTop = Pt(base.x + unit * 0.9f, base.y - unit * 2.0f)
val poleBottom = Pt(base.x + unit * 0.9f, base.y - unit * 0.4f)
drawLine(
color = style.pieceOutline,
start = poleBottom.offset(),
end = poleTop.offset(),
strokeWidth = board.radius * 0.05f,
)
val flag = Path().apply {
moveTo(poleTop.x, poleTop.y)
lineTo(poleTop.x + unit * 1.1f, poleTop.y + unit * 0.35f)
lineTo(poleTop.x, poleTop.y + unit * 0.7f)
close()
}
drawPath(flag, color = style.track.getValue(track))
drawPath(flag, color = style.pieceOutline, style = Stroke(width = board.radius * 0.035f))
}
}
/**
* Each knight is a shield in its owner's colour, marked with one, two or three
* dots for its strength. A sleeping knight is drawn faded; an awake one has a
* bright rim.
*/
private fun DrawScope.drawKnights(state: CatanState, board: BoardLayout, style: CatanStyle) {
val knights = state.ck?.knights ?: return
for (knight in knights) {
val at = board.vertex(knight.vertex)
val s = board.radius * style.knightSize
val seat = style.seats[knight.owner % style.seats.size]
val path = shieldPath(at, s)
if (knight.active) {
drawPath(path, color = style.knightReady, style = Stroke(width = s * 0.55f))
}
drawPath(path, color = if (knight.active) seat else seat.copy(alpha = 0.55f))
drawPath(path, color = style.pieceOutline, style = Stroke(width = s * 0.16f))
val pip = s * 0.14f
val gap = pip * 2.8f
val startX = at.x - gap * (knight.level - 1) / 2f
for (i in 0 until knight.level) {
drawCircle(
color = style.pieceOutline,
radius = pip,
center = Offset(startX + i * gap, at.y - s * 0.05f),
)
}
}
}
private fun shieldPath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s * 0.8f, at.y - s)
lineTo(at.x + s * 0.8f, at.y - s)
lineTo(at.x + s * 0.8f, at.y + s * 0.1f)
quadraticBezierTo(at.x + s * 0.8f, at.y + s * 0.9f, at.x, at.y + s * 1.25f)
quadraticBezierTo(at.x - s * 0.8f, at.y + s * 0.9f, at.x - s * 0.8f, at.y + s * 0.1f)
close()
}
/** A ring round each of the player's knights that can do something now. */
private fun DrawScope.drawKnightTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (vertex in targets.knights) {
drawCircle(
color = style.target,
radius = board.radius * style.knightSize * 1.9f,
center = board.vertex(vertex).offset(),
style = Stroke(width = board.radius * 0.07f),
)
}
}
/** The merchant: a coin in its controller's colour, beside the hex it sits on. */
private fun DrawScope.drawMerchant(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
val merchant = state.ck?.merchant ?: return
val hex = board.hexCenter(merchant.hex)
val at = Pt(hex.x - board.radius * 0.5f, hex.y + board.radius * 0.3f)
val r = board.radius * 0.2f
drawCircle(color = style.seats[merchant.seat % style.seats.size], radius = r, center = at.offset())
drawCircle(color = style.pieceOutline, radius = r, center = at.offset(), style = Stroke(width = r * 0.3f))
drawCentered(
measurer = measurer,
text = "M",
at = at,
fontPx = r * 1.3f,
color = style.pieceOutline,
weight = FontWeight.ExtraBold,
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tap targets // Tap targets
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -0,0 +1,813 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.GoodsCounter
import org.prolibertate.games.game.catan.GoodsOffer
import org.prolibertate.games.game.catan.IMPROVEMENT_MAX
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.PickDeck
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.ProgressCard
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.catan.PromoteKnight
import org.prolibertate.games.game.catan.SpyTake
import org.prolibertate.games.game.catan.TakeAqueduct
import org.prolibertate.games.game.catan.Track
/*
* The Cities & Knights dialogs. Like the base game's, each is a stateless
* composable apart from the choice it is helping the player make, and what is
* allowed is always asked of the engine's legal list through CatanInteraction.
*/
// ---------------------------------------------------------------------------
// Shared pieces
// ---------------------------------------------------------------------------
/** A card tile with a minus and a plus, for choosing how many of a good. */
@Composable
private fun GoodCounter(good: Good, count: Int, max: Int, onChange: (Int) -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
GoodTile(good, count, enabled = max > 0 || count > 0)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedButton(
onClick = { onChange(count - 1) },
enabled = count > 0,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("−") }
OutlinedButton(
onClick = { onChange(count + 1) },
enabled = count < max,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("+") }
}
}
}
private fun Map<Good, Int>.asGoods(): List<Good> = Good.entries.flatMap { g -> List(this[g] ?: 0) { g } }
/** A row of the three track colours, used for decks and improvements. */
@Composable
private fun TrackChip(track: Track, style: CatanStyle = LocalCatanStyle.current) {
Box(
modifier = Modifier
.size(14.dp)
.clip(RoundedCornerShape(3.dp))
.background(style.track.getValue(track)),
)
}
// ---------------------------------------------------------------------------
// Choosing cards to give up
// ---------------------------------------------------------------------------
/**
* Pick exactly [quota] cards out of [held] — to discard on a seven or a saboteur,
* or to hand over for a wedding or a commercial harbour. Not dismissable: the
* game is waiting on it. [allowed] is the engine's structural check, so the
* confirm button only lights when the move would be accepted.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsPickDialog(
title: String,
intro: String,
held: Map<Good, Int>,
quota: Int,
confirmLabel: String,
selectable: (Good) -> Boolean,
allowed: (List<Good>) -> Boolean,
onConfirm: (List<Good>) -> Unit,
) {
var picked by remember { mutableStateOf(emptyMap<Good, Int>()) }
val selection = picked.asGoods()
AlertDialog(
onDismissRequest = {},
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("$intro (${selection.size} of $quota chosen).")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Good.entries.forEach { good ->
val have = held[good] ?: 0
if (have > 0 && selectable(good)) {
GoodCounter(
good = good,
count = picked[good] ?: 0,
max = if (selection.size < quota) have else (picked[good] ?: 0),
onChange = { n -> picked = picked + (good to n.coerceIn(0, have)) },
)
}
}
}
}
},
confirmButton = {
Button(onClick = { onConfirm(selection) }, enabled = allowed(selection)) { Text(confirmLabel) }
},
)
}
// ---------------------------------------------------------------------------
// City improvements
// ---------------------------------------------------------------------------
/** The three improvement tracks: where the player stands, what the next level costs, and what it earns. */
@Composable
fun CatanImproveDialog(
state: CatanState,
seat: Int,
legal: List<CatanMove>,
onBuy: (BuyImprovement) -> Unit,
onCancel: () -> Unit,
) {
val ck = state.ck ?: return
val buyable = CatanInteraction.improvable(legal)
AlertDialog(
onDismissRequest = onCancel,
title = { Text(CatanText.IMPROVE) },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Track.entries.forEach { track ->
val level = CatanCk.improvement(state, seat, track)
val have = ck.commodities[seat][track.commodity] ?: 0
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TrackChip(track)
Text(CatanText.track(track), fontWeight = FontWeight.Bold)
Text("level $level of $IMPROVEMENT_MAX", style = MaterialTheme.typography.labelMedium)
}
Text(CatanText.trackPower(track), style = MaterialTheme.typography.bodySmall)
if (level >= IMPROVEMENT_MAX) {
Text("Fully built.", style = MaterialTheme.typography.bodySmall)
} else {
val cost = CatanCk.improvementCost(state, seat, track)
Button(onClick = { onBuy(BuyImprovement(track)) }, enabled = track in buyable) {
Text(
"${CatanCk.improvementName(track, level + 1)}: $cost " +
"${CatanText.good(Good.of(track.commodity))} (you hold $have)",
)
}
if (level + 1 == org.prolibertate.games.game.catan.METROPOLIS_LEVEL ||
level + 1 == IMPROVEMENT_MAX
) {
Text(
if (level + 1 == IMPROVEMENT_MAX) {
"Level five takes a metropolis from a seat still on four."
} else {
"Level four earns the first metropolis in this track: two more points."
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
)
}
// ---------------------------------------------------------------------------
// Progress cards
// ---------------------------------------------------------------------------
/**
* The player's progress cards. A card with nothing to decide plays at once; one
* that names a seat, a card or dice asks for it in a second step; one that
* needs a tap on the board closes the dialog and arms the board (see [ArmedCard]).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanProgressDialog(
hand: List<ProgressInHand>,
seats: CatanSeats,
onPlay: (CatanMove) -> Unit,
onArm: (ArmedCard) -> Unit,
onCancel: () -> Unit,
) {
var chosen by remember { mutableStateOf<ProgressInHand?>(null) }
var target by remember { mutableStateOf<Int?>(null) }
var yellow by remember { mutableStateOf(1) }
var red by remember { mutableStateOf(1) }
val entry = chosen
AlertDialog(
onDismissRequest = onCancel,
title = { Text(entry?.let { CatanText.progress(it.card.kind) } ?: "Progress cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (entry == null) {
if (hand.isEmpty()) Text("You hold no progress cards.")
hand.forEach { card ->
ProgressRow(card) { picked ->
val kind = picked.card.kind
when {
kind in CatanInteraction.BOARD_CARDS -> onArm(ArmedCard(kind, picked.plays))
picked.plays.size == 1 && picked.plays[0].isBare() -> onPlay(picked.plays[0])
else -> {
chosen = picked
target = null
}
}
}
}
} else {
Text(CatanText.progressHelp(entry.card.kind), style = MaterialTheme.typography.bodySmall)
val kind = entry.card.kind
val plays = entry.plays
when {
kind == ProgressType.ALCHEMIST -> {
Text("Production die (yellow)", fontWeight = FontWeight.Bold)
DiePicker(yellow) { yellow = it }
Text("Progress die (red)", fontWeight = FontWeight.Bold)
DiePicker(red) { red = it }
}
kind == ProgressType.DESERTER || kind == ProgressType.SPY -> {
Text("Choose a player:", fontWeight = FontWeight.Bold)
plays.mapNotNull { it.target }.distinct().forEach { seat ->
OutlinedButton(
onClick = { plays.firstOrNull { it.target == seat }?.let(onPlay) },
modifier = Modifier.fillMaxWidth(),
) { Text(seats.label(seat)) }
}
}
kind == ProgressType.MASTER_MERCHANT -> {
Text("Choose a player with more points:", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
plays.mapNotNull { it.target }.distinct().forEach { seat ->
if (target == seat) {
FilledTonalButton(onClick = { target = seat }) { Text(seats.label(seat)) }
} else {
OutlinedButton(onClick = { target = seat }) { Text(seats.label(seat)) }
}
}
}
val seat = target
if (seat != null) {
Text("Take:", fontWeight = FontWeight.Bold)
plays.filter { it.target == seat }.forEach { play ->
OutlinedButton(
onClick = { onPlay(play) },
modifier = Modifier.fillMaxWidth(),
) { Text(CatanText.goods(play.goods.orEmpty())) }
}
}
}
else -> {
val label = when (kind) {
ProgressType.COMMERCIAL_HARBOR -> "Offer which resource?"
ProgressType.MERCHANT_FLEET -> "Trade which card two for one?"
ProgressType.RESOURCE_MONOPOLY -> "Name a resource:"
else -> "Name a commodity:"
}
Text(label, fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
plays.mapNotNull { it.good }.distinct().forEach { good ->
GoodTile(
good = good,
count = 0,
onClick = { plays.firstOrNull { it.good == good }?.let(onPlay) },
)
}
}
}
}
}
}
},
confirmButton = {
if (entry != null && entry.card.kind == ProgressType.ALCHEMIST) {
val play = entry.plays.firstOrNull { it.dice == listOf(yellow, red) }
Button(onClick = { play?.let(onPlay) }, enabled = play != null) { Text("Play") }
}
},
dismissButton = {
TextButton(
onClick = {
if (entry == null) {
onCancel()
} else {
chosen = null
target = null
}
},
) { Text(if (entry == null) CatanText.CANCEL else "Back") }
},
)
}
private fun PlayProgress.isBare(): Boolean =
target == null && vertex == null && edge == null && hexA == null && hexB == null &&
good == null && dice == null && goods == null
@Composable
private fun DiePicker(selected: Int, onPick: (Int) -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(1..6).forEach { n ->
if (n == selected) {
FilledTonalButton(
onClick = { onPick(n) },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(40.dp),
) { Text("$n") }
} else {
OutlinedButton(
onClick = { onPick(n) },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.size(40.dp),
) { Text("$n") }
}
}
}
}
@Composable
private fun ProgressRow(entry: ProgressInHand, onChoose: (ProgressInHand) -> Unit) {
val kind = entry.card.kind
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrackChip(kind.track)
Column(modifier = Modifier.weight(1f)) {
Text(CatanText.progress(kind), fontWeight = FontWeight.Bold)
Text(CatanText.progressHelp(kind), style = MaterialTheme.typography.bodySmall)
}
if (entry.playable) {
Button(onClick = { onChoose(entry) }) { Text("Play") }
} else {
Text("Not now", style = MaterialTheme.typography.labelSmall)
}
}
}
// ---------------------------------------------------------------------------
// Knights
// ---------------------------------------------------------------------------
/** What the knight on one crossing can do: wake, promote, move, chase the robber. */
@Composable
fun CatanKnightDialog(
state: CatanState,
vertex: Int,
actions: List<CatanMove>,
onDo: (CatanMove) -> Unit,
onMove: (Int) -> Unit,
onCancel: () -> Unit,
) {
val knight = state.ck?.knights?.firstOrNull { it.vertex == vertex }
AlertDialog(
onDismissRequest = onCancel,
title = { Text(knight?.let { CatanText.knightName(it.level) } ?: "Knight") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (knight != null) {
val mood = when {
!knight.active -> "Asleep. Waking it costs ${CatanText.ACTIVATE_COST}."
knight.activatedTurn == state.turnNumber -> "Awake, but it woke this turn and cannot act yet."
else -> "Awake and ready. It counts against the barbarians."
}
Text(mood, style = MaterialTheme.typography.bodySmall)
}
actions.forEach { move ->
when (move) {
is ActivateKnight ->
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Wake (${CatanText.ACTIVATE_COST})")
}
is PromoteKnight ->
Button(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Promote (${CatanText.KNIGHT_COST})")
}
is MoveKnight ->
OutlinedButton(onClick = { onMove(move.from) }, modifier = Modifier.fillMaxWidth()) {
Text("Move along your roads")
}
is ChaseRobber ->
OutlinedButton(onClick = { onDo(move) }, modifier = Modifier.fillMaxWidth()) {
Text("Chase away the robber")
}
else -> Unit
}
}
if (actions.isEmpty()) Text("Nothing to do with it right now.")
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text("Close") } },
)
}
// ---------------------------------------------------------------------------
// Chores that are answered in a dialog
// ---------------------------------------------------------------------------
/** Choose a colour of progress deck to draw from. */
@Composable
fun CatanPickDeckDialog(legal: List<CatanMove>, onPick: (PickDeck) -> Unit) {
val open = legal.filterIsInstance<PickDeck>()
AlertDialog(
onDismissRequest = {},
title = { Text("Draw a progress card") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Catan held. You tied for the strongest defence, so choose any deck.")
open.forEach { pick ->
Button(onClick = { onPick(pick) }, modifier = Modifier.fillMaxWidth()) {
TrackChip(pick.track)
Text(" ${CatanText.track(pick.track)}")
}
}
}
},
confirmButton = {},
)
}
/** The hand is over its limit: give one progress card back. */
@Composable
fun CatanDropProgressDialog(hand: List<ProgressCard>, onDrop: (DropProgress) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Too many progress cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("You may keep four. Choose one to give back.")
hand.forEach { card ->
OutlinedButton(onClick = { onDrop(DropProgress(card.id)) }, modifier = Modifier.fillMaxWidth()) {
TrackChip(card.kind.track)
Text(" ${CatanText.progress(card.kind)}")
}
}
}
},
confirmButton = {},
)
}
/** The aqueduct pays: take any one resource. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanAqueductDialog(legal: List<CatanMove>, onTake: (TakeAqueduct) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Aqueduct") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("The dice paid you nothing. Take any resource.")
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
legal.filterIsInstance<TakeAqueduct>().forEach { take ->
ResourceTile(resource = take.resource, count = 1, onClick = { onTake(take) })
}
}
}
},
confirmButton = {},
)
}
/** A spy: these are the progress cards the other player holds; take one. */
@Composable
fun CatanSpyDialog(cards: List<ProgressCard>, legal: List<CatanMove>, onTake: (SpyTake) -> Unit) {
AlertDialog(
onDismissRequest = {},
title = { Text("Spy") },
text = {
Column(
modifier = Modifier.heightIn(max = 380.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Choose a card to take.")
cards.forEach { card ->
val take = SpyTake(card.id)
OutlinedButton(
onClick = { onTake(take) },
enabled = take in legal,
modifier = Modifier.fillMaxWidth(),
) {
TrackChip(card.kind.track)
Text(" ${CatanText.progress(card.kind)}")
}
}
}
},
confirmButton = {},
)
}
// ---------------------------------------------------------------------------
// Trading in goods
// ---------------------------------------------------------------------------
private enum class GoodsTab { BANK, PLAYER }
/**
* Trade with the bank at the best ratio each card has, or offer cards —
* commodities included — to one other player.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsTradeDialog(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (GoodsBankTrade) -> Unit,
onOffer: (OfferGoods) -> Unit,
onCancel: () -> Unit,
) {
var tab by remember { mutableStateOf(GoodsTab.BANK) }
var bankGive by remember { mutableStateOf<Good?>(null) }
var everyone by remember { mutableStateOf(true) }
var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Good, Int>()) }
var get by remember { mutableStateOf(emptyMap<Good, Int>()) }
val held = CatanCk.goodsOf(state, seat)
AlertDialog(
onDismissRequest = onCancel,
title = { Text(CatanText.TRADE) },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GoodsTabButton(CatanText.TRADE_WITH_BANK, tab == GoodsTab.BANK) { tab = GoodsTab.BANK }
GoodsTabButton(CatanText.TRADE_WITH_PLAYER, tab == GoodsTab.PLAYER) { tab = GoodsTab.PLAYER }
}
if (tab == GoodsTab.BANK) {
val trades = CatanInteraction.goodsBankTrades(legal)
if (trades.isEmpty()) {
Text("You do not hold enough of any one card to trade with the bank.")
} else {
Text("Give:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.keys.forEach { good ->
val ratio = CatanCk.bankRatio(state, seat, good)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
GoodTile(good, ratio, selected = bankGive == good, onClick = { bankGive = good })
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
}
}
}
val chosen = bankGive
if (chosen != null && chosen in trades) {
Text("Get one:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.getValue(chosen).forEach { good ->
GoodTile(good, 1, onClick = { onBank(GoodsBankTrade(chosen, good)) })
}
}
}
}
} else {
Text("Offer to:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
GoodsTabButton(CatanText.EVERYONE, everyone) {
everyone = true
partner = null
}
(0 until state.playerCount).filter { it != seat }.forEach { other ->
GoodsTabButton(seats.label(other), !everyone && partner == other) {
everyone = false
partner = other
}
}
}
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
GoodCounters(give, { held[it] ?: 0 }) { give = it }
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
GoodCounters(get, { 9 }) { get = it }
}
}
},
confirmButton = {
if (tab == GoodsTab.PLAYER) {
val to = if (everyone) null else partner
val giveCards = give.asGoods()
val getCards = get.asGoods()
val allowed = (everyone || partner != null) &&
CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards)
Button(
onClick = { onOffer(OfferGoods(to, giveCards, getCards)) },
enabled = allowed,
) { Text(CatanText.SEND_OFFER) }
}
},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GoodCounters(counts: Map<Good, Int>, limit: (Good) -> Int, onChange: (Map<Good, Int>) -> Unit) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Good.entries.forEach { good ->
GoodCounter(
good = good,
count = counts[good] ?: 0,
max = limit(good),
onChange = { n -> onChange(counts + (good to n.coerceIn(0, limit(good)))) },
)
}
}
}
@Composable
private fun GoodsTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
if (selected) {
FilledTonalButton(onClick = onClick) { Text(label) }
} else {
OutlinedButton(onClick = onClick) { Text(label) }
}
}
/**
* A goods offer has reached the local player: accept it, decline it, or answer
* with different terms. The counter's two sides start as the offer turned around.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanGoodsTradeResponseDialog(
state: CatanState,
seat: Int,
offer: GoodsOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
onCounter: (CounterGoods) -> Unit,
) {
var countering by remember(offer) { mutableStateOf(false) }
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
AlertDialog(
onDismissRequest = {},
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to " +
"${if (offer.to == null) "the table" else "you"} for ${CatanText.goods(offer.get)}",
)
if (countering) {
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
GoodCounters(give, { CatanCk.goodsOf(state, seat)[it] ?: 0 }) { give = it }
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
GoodCounters(get, { CatanCk.goodsOf(state, offer.by)[it] ?: 0 }) { get = it }
} else if (!canAccept) {
Text(
"You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
if (countering) {
val move = CounterGoods(give.asGoods(), get.asGoods())
Button(
onClick = { onCounter(move) },
enabled = CatanInteraction.goodsCounterAllowed(state, seat, move.give, move.get),
) { Text(CatanText.SEND_COUNTER) }
} else {
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
}
},
dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (countering) {
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
} else {
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
}
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
}
},
)
}
/** The offerer's choice once nobody accepted a goods offer: take a counter-offer, or none. */
@Composable
fun CatanGoodsCounterChoiceDialog(
counters: List<GoodsCounter>,
seats: CatanSeats,
onTake: (AcceptCounter) -> Unit,
onNoDeal: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("Counter-offers") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (counters.isEmpty()) Text("None of the counter-offers can be met any more.")
counters.forEach { counter ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"${seats.label(counter.by)} would give ${CatanText.goods(counter.give)} " +
"for ${CatanText.goods(counter.get)}",
)
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
}
}
}
},
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
)
}
@@ -2,45 +2,100 @@ package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AcceptCounter import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.AcceptTrade 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.BuildCity
import org.prolibertate.games.game.catan.BuildRoad import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyDevCard import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanBoard import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.Chore
import org.prolibertate.games.game.catan.CounterTrade import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.DeclineTrade import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DevCard import org.prolibertate.games.game.catan.DevCard
import org.prolibertate.games.game.catan.DevCardType import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.EndFreeRoads 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.EndTurn
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.GoodsCounter
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.HexCoord import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.HireKnight
import org.prolibertate.games.game.catan.MaritimeTrade import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.OfferTrade import org.prolibertate.games.game.catan.OfferTrade
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.TradeCounter import org.prolibertate.games.game.catan.TradeCounter
import org.prolibertate.games.game.catan.PlayKnight import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.PlayRoadBuilding import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.PlayYearOfPlenty import org.prolibertate.games.game.catan.PlayYearOfPlenty
import org.prolibertate.games.game.catan.ProgressCard
import org.prolibertate.games.game.catan.ProgressType
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.Resource import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.RollDice import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.Track
/** What a player can ask to build by tapping the board. */ /**
enum class BuildKind { ROAD, SETTLEMENT, CITY } * What a player can ask to build by tapping the board. Wall and Knight exist
* only in Cities & Knights; the base game's build menu lists the first three.
*/
enum class BuildKind {
ROAD, SETTLEMENT, CITY, WALL, KNIGHT;
/** The parts of the board the player may tap right now. Everything else is inert. */ companion object {
val BASE: List<BuildKind> = listOf(ROAD, SETTLEMENT, CITY)
}
}
/**
* The parts of the board the player may tap right now. Everything else is inert.
* [knights] are crossings holding one of the player's own knights that can do
* something this turn: tapping one opens its menu.
*/
data class BoardTargets( data class BoardTargets(
val vertices: Set<Int> = emptySet(), val vertices: Set<Int> = emptySet(),
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(),
/** 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() 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 + idleKnights
}
/**
* A progress card that is waiting for a tap on the board: [plays] are every play
* of it the engine accepts, and the player's tap picks one. [firstHex] is the
* first of the two hexes an Inventor swaps, once tapped.
*/
data class ArmedCard(val kind: ProgressType, val plays: List<PlayProgress>, val firstHex: Int? = null)
/** One progress card in the local player's hand, and the plays the engine allows for it. */
data class ProgressInHand(val card: ProgressCard, val plays: List<PlayProgress>) {
val playable: Boolean get() = plays.isNotEmpty()
} }
/** What a tap on the board amounts to. */ /** What a tap on the board amounts to. */
@@ -56,6 +111,12 @@ sealed interface TapOutcome {
/** The robber's hex has more than one possible victim: ask which. */ /** The robber's hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
/** One of the player's own knights was tapped: open its menu. */
data class PickKnight(val vertex: Int) : TapOutcome
/** An Inventor's first hex was tapped; the second completes the swap. */
data class PickFirstHex(val hex: Int) : TapOutcome
} }
/** /**
@@ -88,6 +149,10 @@ object CatanInteraction {
legal: List<CatanMove>, legal: List<CatanMove>,
building: BuildKind?, building: BuildKind?,
setupVertex: Int?, 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 { ): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>() val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) { if (setup.isNotEmpty()) {
@@ -103,9 +168,21 @@ object CatanInteraction {
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet()) return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
} }
// Cities & Knights: answers to a queued chore are taps on the board.
val chore = choreVertices(legal)
if (chore.isNotEmpty()) return BoardTargets(vertices = chore)
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet() val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads) if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
if (armed != null) return armedTargets(armed)
if (movingKnight != null) {
return BoardTargets(
vertices = legal.filterIsInstance<MoveKnight>().filter { it.from == movingKnight }.map { it.to }.toSet(),
)
}
return when (building) { return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads) BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SETTLEMENT -> BuildKind.SETTLEMENT ->
@@ -114,15 +191,169 @@ object CatanInteraction {
BuildKind.CITY -> BuildKind.CITY ->
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet()) BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
null -> BoardTargets() BuildKind.WALL ->
BoardTargets(vertices = legal.filterIsInstance<BuildWall>().map { it.vertex }.toSet())
BuildKind.KNIGHT ->
BoardTargets(vertices = legal.filterIsInstance<HireKnight>().map { it.vertex }.toSet())
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)
}
} }
} }
/** The crossings a queued chore is asking the player to tap, from the legal list. */
private fun choreVertices(legal: List<CatanMove>): Set<Int> = buildSet {
legal.forEach { move ->
when (move) {
is PillageCity -> add(move.vertex)
is PlaceMetropolis -> add(move.vertex)
is RelocateKnight -> add(move.to)
is RemoveKnight -> add(move.vertex)
is PlaceKnight -> add(move.vertex)
else -> Unit
}
}
// A Smith's promotions are knights to tap, while the chore is open.
if (legal.any { it is EndSmith }) {
legal.filterIsInstance<PromoteKnight>().forEach { add(it.vertex) }
}
}
private fun choreMove(legal: List<CatanMove>, vertex: Int): CatanMove? = legal.firstOrNull { move ->
when (move) {
is PillageCity -> move.vertex == vertex
is PlaceMetropolis -> move.vertex == vertex
is RelocateKnight -> move.to == vertex
is RemoveKnight -> move.vertex == vertex
is PlaceKnight -> move.vertex == vertex
is PromoteKnight -> legal.any { it is EndSmith } && move.vertex == vertex
else -> false
}
}
// --- Cities & Knights: knights ---------------------------------------------
/** Crossings of the player's knights that have an action available. */
fun knightsWithActions(legal: List<CatanMove>): Set<Int> = buildSet {
legal.forEach { move ->
when (move) {
is ActivateKnight -> add(move.vertex)
is MoveKnight -> add(move.from)
is ChaseRobber -> add(move.vertex)
is PromoteKnight -> if (legal.none { it is EndSmith }) add(move.vertex)
else -> Unit
}
}
}
/** What the knight on [vertex] can do right now, in the order its menu lists them. */
fun knightActions(legal: List<CatanMove>, vertex: Int): List<CatanMove> =
legal.filter { move ->
when (move) {
is ActivateKnight -> move.vertex == vertex
is PromoteKnight -> move.vertex == vertex && legal.none { it is EndSmith }
is ChaseRobber -> move.vertex == vertex
else -> false
}
} + listOfNotNull(legal.firstOrNull { it is MoveKnight && it.from == vertex })
fun canEndSmith(legal: List<CatanMove>): Boolean = legal.any { it is EndSmith }
// --- Cities & Knights: progress cards --------------------------------------
/** [seat]'s progress cards, each with the plays the engine currently allows. */
fun progressHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<ProgressInHand> =
state.ck?.progressHands?.getOrNull(seat).orEmpty().map { card ->
ProgressInHand(card, legal.filterIsInstance<PlayProgress>().filter { it.card == card.id })
}.sortedWith(compareBy({ it.card.kind.track.ordinal }, { it.card.kind.ordinal }, { it.card.id }))
/** The cards that are played by tapping the board, not by choosing in a dialog. */
val BOARD_CARDS: Set<ProgressType> = setOf(
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.MERCHANT,
ProgressType.DIPLOMAT, ProgressType.INTRIGUE, ProgressType.INVENTOR,
)
private fun armedTargets(armed: ArmedCard): BoardTargets = when (armed.kind) {
ProgressType.ENGINEER, ProgressType.MEDICINE, ProgressType.INTRIGUE ->
BoardTargets(vertices = armed.plays.mapNotNull { it.vertex }.toSet())
ProgressType.DIPLOMAT -> BoardTargets(edges = armed.plays.mapNotNull { it.edge }.toSet())
ProgressType.MERCHANT -> BoardTargets(hexes = armed.plays.mapNotNull { it.hexA }.toSet())
ProgressType.INVENTOR -> {
val first = armed.firstHex
val all = armed.plays.flatMap { listOfNotNull(it.hexA, it.hexB) }.toSet()
if (first == null) {
BoardTargets(hexes = all)
} else {
BoardTargets(
hexes = armed.plays
.filter { it.hexA == first || it.hexB == first }
.flatMap { listOfNotNull(it.hexA, it.hexB) }
.filter { it != first }
.toSet(),
)
}
}
else -> BoardTargets()
}
/** The improvement tracks the player can buy a level of right now. */
fun improvable(legal: List<CatanMove>): Set<Track> =
legal.filterIsInstance<BuyImprovement>().map { it.track }.toSet()
// --- Cities & Knights: goods ----------------------------------------------
/** Bank trades on offer in Cities & Knights: what can be given, and for what. */
fun goodsBankTrades(legal: List<CatanMove>): Map<Good, List<Good>> =
legal.filterIsInstance<GoodsBankTrade>()
.groupBy({ it.give }, { it.get })
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
.toSortedMap(compareBy<Good> { it.ordinal })
fun goodsOfferAllowed(
state: CatanState,
seat: Int,
to: Int?,
give: List<Good>,
get: List<Good>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get))
/** Whether [seat] may answer the goods offer on the table with [give] for [get]. */
fun goodsCounterAllowed(state: CatanState, seat: Int, give: List<Good>, get: List<Good>): Boolean =
give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, CounterGoods(give, get))
/** The goods counter-offers the offerer may take right now, in the order they came in. */
fun goodsCounterChoices(state: CatanState, legal: List<CatanMove>): List<GoodsCounter> {
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
return state.ck?.goodsOffer?.counters.orEmpty().filter { it.by in takeable }
}
/** The chore at the front of the queue, if the table is waiting on one. */
fun chore(state: CatanState): Chore? =
if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null
fun onVertexTap( fun onVertexTap(
vertex: Int, vertex: Int,
legal: List<CatanMove>, legal: List<CatanMove>,
building: BuildKind?, building: BuildKind?,
setupVertex: Int?, 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 { ): TapOutcome {
val setup = legal.filterIsInstance<SetupPlace>() val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) { if (setup.isNotEmpty()) {
@@ -134,10 +365,27 @@ object CatanInteraction {
TapOutcome.Ignored TapOutcome.Ignored
} }
} }
choreMove(legal, vertex)?.let { return TapOutcome.Submit(it) }
if (armed != null) {
val play = armed.plays.firstOrNull { it.vertex == vertex } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
if (movingKnight != null) {
val move = MoveKnight(movingKnight, vertex)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
val move: CatanMove = when (building) { val move: CatanMove = when (building) {
BuildKind.SETTLEMENT -> BuildSettlement(vertex) BuildKind.SETTLEMENT -> BuildSettlement(vertex)
BuildKind.CITY -> BuildCity(vertex) BuildKind.CITY -> BuildCity(vertex)
else -> return TapOutcome.Ignored BuildKind.WALL -> BuildWall(vertex)
BuildKind.KNIGHT -> HireKnight(vertex)
else -> return if (building == null && (vertex in knightsWithActions(legal) || vertex in ownKnights)) {
TapOutcome.PickKnight(vertex)
} else {
TapOutcome.Ignored
}
} }
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
} }
@@ -147,12 +395,17 @@ object CatanInteraction {
legal: List<CatanMove>, legal: List<CatanMove>,
building: BuildKind?, building: BuildKind?,
setupVertex: Int?, setupVertex: Int?,
armed: ArmedCard? = null,
): TapOutcome { ): TapOutcome {
if (legal.any { it is SetupPlace }) { if (legal.any { it is SetupPlace }) {
if (setupVertex == null) return TapOutcome.Ignored if (setupVertex == null) return TapOutcome.Ignored
val move = SetupPlace(setupVertex, edge) val move = SetupPlace(setupVertex, edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
} }
if (armed != null) {
val play = armed.plays.firstOrNull { it.edge == edge } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
// Free roads need no build mode; a paid road does. // Free roads need no build mode; a paid road does.
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads } val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
if (!canBuild) return TapOutcome.Ignored if (!canBuild) return TapOutcome.Ignored
@@ -160,7 +413,8 @@ object CatanInteraction {
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
} }
fun onHexTap(hex: Int, legal: List<CatanMove>): TapOutcome { fun onHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard? = null): TapOutcome {
if (armed != null) return onArmedHexTap(hex, legal, armed)
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord } val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
return when { return when {
@@ -170,6 +424,22 @@ object CatanInteraction {
} }
} }
private fun onArmedHexTap(hex: Int, legal: List<CatanMove>, armed: ArmedCard): TapOutcome {
if (armed.kind == ProgressType.INVENTOR) {
val first = armed.firstHex ?: return if (hex in armedTargets(armed).hexes) {
TapOutcome.PickFirstHex(hex)
} else {
TapOutcome.Ignored
}
val play = armed.plays.firstOrNull {
(it.hexA == first && it.hexB == hex) || (it.hexA == hex && it.hexB == first)
} ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
val play = armed.plays.firstOrNull { it.hexA == hex } ?: return TapOutcome.Ignored
return if (play in legal) TapOutcome.Submit(play) else TapOutcome.Ignored
}
// --- Action bar ------------------------------------------------------------ // --- Action bar ------------------------------------------------------------
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice } fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
@@ -203,6 +473,8 @@ object CatanInteraction {
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD) if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT) if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
if (legal.any { it is BuildCity }) add(BuildKind.CITY) if (legal.any { it is BuildCity }) add(BuildKind.CITY)
if (legal.any { it is BuildWall }) add(BuildKind.WALL)
if (legal.any { it is HireKnight }) add(BuildKind.KNIGHT)
} }
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> = fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
@@ -40,10 +40,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.EventFace
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.Resource import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Track
/* /*
* Stateless building blocks for the Katan screen. * Stateless building blocks for the Katan screen.
@@ -102,7 +107,52 @@ fun ResourceTile(
} }
} }
/** The local player's cards: one tile per resource, in a row that wraps. */ /**
* One card type — a resource or a commodity — as a coloured tile with its count.
* Cities & Knights hands, discards and trades use this so commodities look the
* same everywhere.
*/
@Composable
fun GoodTile(
good: Good,
count: Int,
modifier: Modifier = Modifier,
selected: Boolean = false,
enabled: Boolean = true,
style: CatanStyle = LocalCatanStyle.current,
onClick: (() -> Unit)? = null,
) {
val resource = good.resource
if (resource != null) {
ResourceTile(resource, count, modifier, selected, enabled, style, onClick)
return
}
val commodity = good.commodity!!
val shape = RoundedCornerShape(10.dp)
val ink = style.commodityInk.getValue(commodity)
Column(
modifier = modifier
.defaultMinSize(minWidth = 56.dp)
.clip(shape)
.background(style.commodity.getValue(commodity).copy(alpha = if (enabled) 1f else 0.4f))
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) Color.White else Color(0x33000000),
shape = shape,
)
.then(if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 6.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = count.toString(), color = ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(text = CatanText.good(good), color = ink, fontSize = 11.sp, maxLines = 1)
}
}
/**
* The local player's cards: one tile per resource, and in Cities & Knights one
* per commodity as well, in a row that wraps.
*/
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun CatanHand( fun CatanHand(
@@ -115,8 +165,13 @@ fun CatanHand(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Resource.entries.forEach { resource -> if (state.isCk) {
ResourceTile(resource, state.resources[seat][resource] ?: 0) val held = CatanCk.goodsOf(state, seat)
Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) }
} else {
Resource.entries.forEach { resource ->
ResourceTile(resource, state.resources[seat][resource] ?: 0)
}
} }
} }
} }
@@ -175,6 +230,41 @@ fun CatanPlayerCard(
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold) CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
} }
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold) CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
val ck = state.ck
if (ck != null) {
val cards = CatanCk.goodsCount(state, seat)
val strength = CatanCk.strength(state, seat)
if (compact) {
CardLine("$cards cards · ⚔$strength", MaterialTheme.typography.bodySmall)
} else {
CardLine("$cards cards · ${ck.progressCounts[seat]} progress", MaterialTheme.typography.bodySmall)
CardLine(
"⚔ ${CatanCk.knightsOf(state, seat).size} · str $strength · " +
"Road ${CatanRules.longestRoadFor(state, seat)}",
MaterialTheme.typography.bodySmall,
)
}
// Trade, politics, science: the three improvement levels.
CardLine(
Track.entries.joinToString(" ") { "${it.name.first()}${CatanCk.improvement(state, seat, it)}" },
MaterialTheme.typography.bodySmall,
color = Color.Unspecified,
)
val ckAwards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
ck.metropolises.forEach { (track, at) ->
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
}
if (ck.merchant?.seat == seat) add("Merchant")
}
CardLine(
text = ckAwards.joinToString(" · ").ifEmpty { BLANK },
style = MaterialTheme.typography.labelSmall,
weight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
return@Column
}
if (compact) { if (compact) {
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall) CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
} else { } else {
@@ -226,21 +316,36 @@ private fun CardLine(
// Dice // Dice
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** The two dice as last rolled, with their total. Blank until the first roll. */ /**
* The two dice as last rolled, with their total. Blank until the first roll. In
* Cities & Knights the dice are yellow and red (the red one decides progress
* cards) and a third, the event die, shows a ship or a coloured city gate.
*/
@Composable @Composable
fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) { fun CatanDice(
dice: List<Int>,
modifier: Modifier = Modifier,
ck: Boolean = false,
event: EventFace? = null,
style: CatanStyle = LocalCatanStyle.current,
) {
Row( Row(
modifier = modifier, modifier = modifier,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
val shown = if (dice.size == 2) dice else listOf(null, null) val shown = if (dice.size == 2) dice else listOf(null, null)
shown.forEach { value -> shown.forEachIndexed { index, value ->
val face = when {
!ck -> Color.White
index == 0 -> Color(0xFFFFF176)
else -> Color(0xFFEF9A9A)
}
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(Color.White) .background(face)
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)), .border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -253,6 +358,31 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
) )
} }
} }
if (ck) {
val gate = when (event) {
EventFace.TRADE -> style.track.getValue(Track.TRADE)
EventFace.POLITICS -> style.track.getValue(Track.POLITICS)
EventFace.SCIENCE -> style.track.getValue(Track.SCIENCE)
EventFace.SHIP -> style.barbarian
null -> Color(0x33000000)
}
Box(
modifier = Modifier
.size(width = 52.dp, height = 36.dp)
.clip(RoundedCornerShape(8.dp))
.background(gate)
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = if (event == EventFace.SHIP) "⛵ Ship" else CatanText.event(event),
color = if (event == EventFace.TRADE) Color(0xFF1C1B1F) else Color.White,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
maxLines = 1,
)
}
}
// Always drawn ("= –" before the first roll) so the row never changes width. // Always drawn ("= –" before the first roll) so the row never changes width.
Text( Text(
text = if (dice.size == 2) "= ${dice.sum()}" else "= –", text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
@@ -262,6 +392,48 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
} }
} }
/**
* Cities & Knights: how close the barbarians are, and how the cities on the
* island compare with the knights standing ready. **Fixed height**, like every
* other strip around the board.
*/
@Composable
fun CatanBarbarians(state: CatanState, modifier: Modifier = Modifier, style: CatanStyle = LocalCatanStyle.current) {
val ck = state.ck ?: return
val cities = state.buildings.count { it?.type == BuildingType.CITY }
val defence = (0 until state.playerCount).sumOf { CatanCk.strength(state, it) }
Row(
modifier = modifier.fillMaxWidth().height(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("⛵", fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) {
// The ship starts seven steps out and lands when the last box fills.
val sailed = BARBARIAN_STEPS_DISPLAY - ck.barbarians
for (i in 0 until BARBARIAN_STEPS_DISPLAY) {
Box(
modifier = Modifier
.weight(1f)
.height(10.dp)
.clip(RoundedCornerShape(3.dp))
.background(if (i < sailed) style.barbarian else Color(0x33000000)),
)
}
}
val verdict = if (defence >= cities) "holds" else "falls"
Text(
text = "${CatanText.barbarianSteps(ck.barbarians)} · knights $defence v. $cities cities ($verdict)",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** Steps shown on the barbarian strip: the engine's seven. */
private const val BARBARIAN_STEPS_DISPLAY = org.prolibertate.games.game.catan.BARBARIAN_STEPS
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Actions // Actions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -278,6 +450,14 @@ data class CatanActions(
val canEndFreeRoads: Boolean = false, val canEndFreeRoads: Boolean = false,
/** A setup settlement is chosen and a different one may still be picked. */ /** A setup settlement is chosen and a different one may still be picked. */
val canRepickSetup: Boolean = false, val canRepickSetup: Boolean = false,
/** Which kinds the Build menu lists: three in the base game, five in Cities & Knights. */
val buildKinds: List<BuildKind> = BuildKind.BASE,
/** Cities & Knights: the table plays the expansion, so the bar shows its buttons instead. */
val ck: Boolean = false,
val canImprove: Boolean = false,
val canPlayProgress: Boolean = false,
val progressCount: Int = 0,
val canEndSmith: Boolean = false,
) )
/** /**
@@ -305,6 +485,9 @@ fun CatanActionBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */ /** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
compact: Boolean = false, compact: Boolean = false,
onImprove: () -> Unit = {},
onProgress: () -> Unit = {},
onEndSmith: () -> Unit = {},
) { ) {
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row( Row(
@@ -315,6 +498,7 @@ fun CatanActionBar(
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) } if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) } if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) } if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
if (actions.canEndSmith) Button(onClick = onEndSmith) { Text(CatanText.STOP_SMITH) }
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
} }
@@ -323,18 +507,27 @@ fun CatanActionBar(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (compact) { if (compact || actions.ck) {
BuildMenuButton(actions, onBuild) BuildMenuButton(actions, onBuild)
} else { } else {
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
} }
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { if (actions.ck) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) OutlinedButton(onClick = onImprove, enabled = actions.canImprove) {
} LabelWithCost(CatanText.IMPROVE, CatanText.NO_COST)
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { }
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST) OutlinedButton(onClick = onProgress, enabled = actions.canPlayProgress) {
LabelWithCost(CatanText.CARDS_BUTTON, "${actions.progressCount} held")
}
} else {
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
}
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
}
} }
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
LabelWithCost(CatanText.TRADE, CatanText.NO_COST) LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
@@ -384,7 +577,7 @@ private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() } OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
} }
DropdownMenu(expanded = open, onDismissRequest = { open = false }) { DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
for (kind in BuildKind.entries) { for (kind in actions.buildKinds) {
DropdownMenuItem( DropdownMenuItem(
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) }, text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
enabled = kind in actions.canBuild, enabled = kind in actions.canBuild,
@@ -402,12 +595,16 @@ private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD BuildKind.ROAD -> CatanText.ROAD
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
BuildKind.CITY -> CatanText.CITY BuildKind.CITY -> CatanText.CITY
BuildKind.WALL -> CatanText.WALL
BuildKind.KNIGHT -> CatanText.KNIGHT
} }
private fun buildCost(kind: BuildKind): String = when (kind) { private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD_COST BuildKind.ROAD -> CatanText.ROAD_COST
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
BuildKind.CITY -> CatanText.CITY_COST BuildKind.CITY -> CatanText.CITY_COST
BuildKind.WALL -> CatanText.WALL_COST
BuildKind.KNIGHT -> CatanText.KNIGHT_COST
} }
@Composable @Composable
@@ -28,18 +28,30 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptTrade import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.AqueductChore
import org.prolibertate.games.game.catan.BuyDevCard import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DeclineTrade import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DiscardChore
import org.prolibertate.games.game.catan.DiscardGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.DropProgressChore
import org.prolibertate.games.game.catan.DismissCounters import org.prolibertate.games.game.catan.DismissCounters
import org.prolibertate.games.game.catan.EndFreeRoads 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.EndTurn
import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.RollDice import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.SpyChore
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold import org.prolibertate.games.ui.ScreenScaffold
@@ -85,6 +97,13 @@ fun CatanScreen(
var showTrade by remember { mutableStateOf(false) } var showTrade by remember { mutableStateOf(false) }
var confirmingEnd by remember { mutableStateOf(false) } var confirmingEnd by remember { mutableStateOf(false) }
var resultDismissed by remember { mutableStateOf(false) } var resultDismissed by remember { mutableStateOf(false) }
// Cities & Knights: the knight whose menu is open, the one about to move,
// a progress card waiting for a tap on the board, and the two list dialogs.
var knightMenu by remember { mutableStateOf<Int?>(null) }
var movingKnight by remember { mutableStateOf<Int?>(null) }
var armed by remember { mutableStateOf<ArmedCard?>(null) }
var showImprove by remember { mutableStateOf(false) }
var showProgress by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val style = remember(dark) { catanStyle(dark) } val style = remember(dark) { catanStyle(dark) }
@@ -98,10 +117,17 @@ fun CatanScreen(
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) { if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
setupVertex = null setupVertex = null
} }
if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
if (legal.isEmpty()) { if (legal.isEmpty()) {
showCards = false showCards = false
showTrade = false showTrade = false
victimChoice = null victimChoice = null
showImprove = false
showProgress = false
knightMenu = null
movingKnight = null
armed = null
} }
} }
@@ -112,6 +138,11 @@ fun CatanScreen(
victimChoice = null victimChoice = null
showCards = false showCards = false
showTrade = false showTrade = false
showImprove = false
showProgress = false
knightMenu = null
movingKnight = null
armed = null
} }
val endGameAction: @Composable () -> Unit = { val endGameAction: @Composable () -> Unit = {
@@ -128,10 +159,18 @@ fun CatanScreen(
return return
} }
val targets = CatanInteraction.targets(current, legal, building, setupVertex) 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 playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions( val actions = CatanActions(
buildKinds = if (current.isCk) BuildKind.entries else BuildKind.BASE,
ck = current.isCk,
canImprove = CatanInteraction.improvable(legal).isNotEmpty(),
canPlayProgress = progressHand.any { it.playable },
progressCount = progressHand.size,
canEndSmith = CatanInteraction.canEndSmith(legal),
canRoll = CatanInteraction.canRoll(legal), canRoll = CatanInteraction.canRoll(legal),
canBuild = CatanInteraction.canBuild(legal), canBuild = CatanInteraction.canBuild(legal),
building = building, building = building,
@@ -143,7 +182,14 @@ fun CatanScreen(
canRepickSetup = setupVertex != null, canRepickSetup = setupVertex != null,
) )
val rolledHexes = CatanInteraction.rolledHexes(current) val rolledHexes = CatanInteraction.rolledHexes(current)
val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building) val prompt = CatanText.prompt(
current,
seats,
setupVertexChosen = setupVertex != null,
building = building,
movingKnight = movingKnight != null,
armed = armed?.kind,
)
val handle: (TapOutcome) -> Unit = { outcome -> val handle: (TapOutcome) -> Unit = { outcome ->
when (outcome) { when (outcome) {
@@ -151,6 +197,8 @@ fun CatanScreen(
is TapOutcome.Submit -> submit(outcome.move) is TapOutcome.Submit -> submit(outcome.move)
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome is TapOutcome.PickRobberVictim -> victimChoice = outcome
is TapOutcome.PickKnight -> knightMenu = outcome.vertex
is TapOutcome.PickFirstHex -> armed = armed?.copy(firstHex = outcome.hex)
} }
} }
@@ -171,12 +219,20 @@ fun CatanScreen(
state = current, state = current,
targets = targets, targets = targets,
selectedVertex = setupVertex, selectedVertex = setupVertex,
onVertexTap = { handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex)) }, onVertexTap = {
onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex)) }, handle(
onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) }, 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)) },
modifier = boardModifier, modifier = boardModifier,
style = style, style = style,
rolledHexes = rolledHexes, rolledHexes = rolledHexes,
selectedKnight = movingKnight ?: knightMenu,
) )
} }
val players: @Composable (Boolean) -> Unit = { grid -> val players: @Composable (Boolean) -> Unit = { grid ->
@@ -201,6 +257,9 @@ fun CatanScreen(
}, },
onRepickSetup = { setupVertex = null }, onRepickSetup = { setupVertex = null },
compact = compactBuild, compact = compactBuild,
onImprove = { showImprove = true },
onProgress = { showProgress = true },
onEndSmith = { legal.firstOrNull { m -> m is EndSmith }?.let(submit) },
) )
} }
} }
@@ -215,6 +274,7 @@ fun CatanScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
players(true) players(true)
if (current.isCk) CatanBarbarians(current, style = style)
promptRow() promptRow()
handAndActions() handAndActions()
CatanLog(current, Modifier.fillMaxWidth().height(120.dp)) CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
@@ -223,6 +283,7 @@ fun CatanScreen(
} else { } else {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
players(false) players(false)
if (current.isCk) CatanBarbarians(current, style = style)
promptRow() promptRow()
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
board(Modifier) board(Modifier)
@@ -244,6 +305,21 @@ fun CatanScreen(
showCards = showCards, showCards = showCards,
showTrade = showTrade, showTrade = showTrade,
devHand = devHand, devHand = devHand,
knightMenu = knightMenu,
showImprove = showImprove,
showProgress = showProgress,
progressHand = progressHand,
onMoveKnight = { from ->
knightMenu = null
movingKnight = from
},
onArmCard = { card ->
showProgress = false
armed = card
},
onCancelKnight = { knightMenu = null },
onCancelImprove = { showImprove = false },
onCancelProgress = { showProgress = false },
onExit = onExit, onExit = onExit,
onSubmit = submit, onSubmit = submit,
onStay = controller::dismissAbandoned, onStay = controller::dismissAbandoned,
@@ -316,7 +392,7 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
CatanDice(state.lastDice) CatanDice(state.lastDice, ck = state.isCk, event = state.ck?.eventDie)
} }
} }
@@ -338,6 +414,15 @@ private fun CatanDialogHost(
showCards: Boolean, showCards: Boolean,
showTrade: Boolean, showTrade: Boolean,
devHand: List<DevCardInHand>, devHand: List<DevCardInHand>,
knightMenu: Int?,
showImprove: Boolean,
showProgress: Boolean,
progressHand: List<ProgressInHand>,
onMoveKnight: (Int) -> Unit,
onArmCard: (ArmedCard) -> Unit,
onCancelKnight: () -> Unit,
onCancelImprove: () -> Unit,
onCancelProgress: () -> Unit,
onExit: () -> Unit, onExit: () -> Unit,
onSubmit: (CatanMove) -> Unit, onSubmit: (CatanMove) -> Unit,
onStay: () -> Unit, onStay: () -> Unit,
@@ -348,6 +433,7 @@ private fun CatanDialogHost(
onCancelTrade: () -> Unit, onCancelTrade: () -> Unit,
) { ) {
val offer = state.pendingTrade val offer = state.pendingTrade
val goodsOffer = state.ck?.goodsOffer
when { when {
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay) abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
@@ -364,6 +450,29 @@ private fun CatanDialogHost(
onSubmit = onSubmit, onSubmit = onSubmit,
) )
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
ChoreDialog(state, localSeat, legal, onSubmit)
state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.asking == localSeat ->
CatanGoodsTradeResponseDialog(
state = state,
seat = localSeat,
offer = goodsOffer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
onCounter = onSubmit,
)
state.phase == CatanPhase.TRADE_COUNTERS && goodsOffer != null && state.turn == localSeat && legal.isNotEmpty() ->
CatanGoodsCounterChoiceDialog(
counters = CatanInteraction.goodsCounterChoices(state, legal),
seats = seats,
onTake = onSubmit,
onNoDeal = { onSubmit(DismissCounters) },
)
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat -> state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat ->
CatanTradeResponseDialog( CatanTradeResponseDialog(
state = state, state = state,
@@ -392,8 +501,47 @@ private fun CatanDialogHost(
onCancel = onCancelVictim, onCancel = onCancelVictim,
) )
knightMenu != null ->
CatanKnightDialog(
state = state,
vertex = knightMenu,
actions = CatanInteraction.knightActions(legal, knightMenu),
onDo = onSubmit,
onMove = onMoveKnight,
onCancel = onCancelKnight,
)
showImprove ->
CatanImproveDialog(
state = state,
seat = localSeat,
legal = legal,
onBuy = onSubmit,
onCancel = onCancelImprove,
)
showProgress ->
CatanProgressDialog(
hand = progressHand,
seats = seats,
onPlay = onSubmit,
onArm = onArmCard,
onCancel = onCancelProgress,
)
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards) showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
showTrade && state.isCk ->
CatanGoodsTradeDialog(
state = state,
seat = localSeat,
seats = seats,
legal = legal,
onBank = onSubmit,
onOffer = onSubmit,
onCancel = onCancelTrade,
)
showTrade -> showTrade ->
CatanTradeDialog( CatanTradeDialog(
state = state, state = state,
@@ -406,3 +554,61 @@ private fun CatanDialogHost(
) )
} }
} }
/**
* The dialog for the chore at the front of the queue, when it is the local
* player's. Chores that are answered by tapping the board (a city to lose, a
* metropolis site, a knight to place) have no dialog: the prompt line tells the
* player what to tap.
*/
@Composable
private fun ChoreDialog(
state: CatanState,
localSeat: Int,
legal: List<CatanMove>,
onSubmit: (CatanMove) -> Unit,
) {
val ck = state.ck ?: return
val check: (List<org.prolibertate.games.game.catan.Good>, (List<org.prolibertate.games.game.catan.Good>) -> CatanMove) -> Boolean =
{ cards, make -> CatanRules.isLegalMove(state, localSeat, make(cards)) }
when (val chore = ck.chores.firstOrNull()) {
is DiscardChore -> {
val quota = minOf(chore.count, CatanCk.goodsCount(state, localSeat))
CatanGoodsPickDialog(
title = "Discard $quota cards",
intro = "Choose $quota cards to give up",
held = CatanCk.goodsOf(state, localSeat),
quota = quota,
confirmLabel = "Discard",
selectable = { true },
allowed = { check(it, ::DiscardGoods) },
onConfirm = { onSubmit(DiscardGoods(it)) },
)
}
is GiveChore -> {
val pool = CatanCk.goodsOf(state, localSeat).filterKeys { !chore.commoditiesOnly || it.isCommodity }
val quota = minOf(chore.count, pool.values.sum())
CatanGoodsPickDialog(
title = "Hand over $quota ${if (quota == 1) "card" else "cards"}",
intro = if (chore.commoditiesOnly) "Choose a commodity to give" else "Choose which cards to give",
held = pool,
quota = quota,
confirmLabel = "Give",
selectable = { !chore.commoditiesOnly || it.isCommodity },
allowed = { check(it, ::GiveGoods) },
onConfirm = { onSubmit(GiveGoods(it)) },
)
}
is PickDeckChore -> CatanPickDeckDialog(legal) { onSubmit(it) }
is DropProgressChore -> CatanDropProgressDialog(ck.progressHands[localSeat]) { onSubmit(it) }
is AqueductChore -> CatanAqueductDialog(legal) { onSubmit(it) }
is SpyChore -> CatanSpyDialog(ck.progressHands[chore.target], legal) { onSubmit(it) }
else -> Unit
}
}
@@ -3,8 +3,10 @@ package org.prolibertate.games.ui.game.catan
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import org.prolibertate.games.game.catan.Commodity
import org.prolibertate.games.game.catan.Resource import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.catan.Track
/** /**
* Every visual decision for the Katan screen, in one place. * Every visual decision for the Katan screen, in one place.
@@ -46,6 +48,19 @@ data class CatanStyle(
val resource: Map<Resource, Color>, val resource: Map<Resource, Color>,
val resourceInk: Color, val resourceInk: Color,
// --- Cities & Knights ---------------------------------------------------
/** Commodity cards: cloth, coin and paper. */
val commodity: Map<Commodity, Color> = LightCommodities,
/** Ink on a commodity tile (paper is pale, so it is not white). */
val commodityInk: Map<Commodity, Color> = LightCommodityInk,
/** The colour of each improvement track, its city gate, its deck and its metropolis. */
val track: Map<Track, Color> = LightTracks,
/** Wall round a city, and the barbarian strip. */
val wall: Color = Color(0xFF5D4037),
val barbarian: Color = Color(0xFFB71C1C),
/** 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 ------------------------------------------------------------ // --- 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. */
val seats: List<Color>, val seats: List<Color>,
@@ -72,6 +87,25 @@ data class CatanStyle(
val tapReach: Float = 0.55f, val tapReach: Float = 0.55f,
val tokenText: Float = 0.34f, val tokenText: Float = 0.34f,
val portText: Float = 0.27f, val portText: Float = 0.27f,
val knightSize: Float = 0.30f,
)
private val LightCommodities = mapOf(
Commodity.CLOTH to Color(0xFF7E57C2),
Commodity.COIN to Color(0xFFF9A825),
Commodity.PAPER to Color(0xFFECEFF1),
)
private val LightCommodityInk = mapOf(
Commodity.CLOTH to Color(0xFFFFFFFF),
Commodity.COIN to Color(0xFF3E2723),
Commodity.PAPER to Color(0xFF263238),
)
private val LightTracks = mapOf(
Track.TRADE to Color(0xFFF2C300),
Track.POLITICS to Color(0xFF1E6FD9),
Track.SCIENCE to Color(0xFF2E9E4F),
) )
/** The light palette, which is also the reference for the dark one. */ /** The light palette, which is also the reference for the dark one. */
@@ -1,12 +1,29 @@
package org.prolibertate.games.ui.game.catan package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AqueductChore
import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DevCardType import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.DiscardChore
import org.prolibertate.games.game.catan.DropProgressChore
import org.prolibertate.games.game.catan.EventFace
import org.prolibertate.games.game.catan.GiveChore
import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.MetropolisChore
import org.prolibertate.games.game.catan.PickDeckChore
import org.prolibertate.games.game.catan.PillageChore
import org.prolibertate.games.game.catan.PlaceKnightChore
import org.prolibertate.games.game.catan.Port import org.prolibertate.games.game.catan.Port
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.catan.RelocateChore
import org.prolibertate.games.game.catan.RemoveKnightChore
import org.prolibertate.games.game.catan.Resource import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.SmithChore
import org.prolibertate.games.game.catan.SpyChore
import org.prolibertate.games.game.catan.Terrain import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.catan.Track
/** /**
* Who is who at the table, for wording. [names] may be empty or short — a * Who is who at the table, for wording. [names] may be empty or short — a
@@ -140,7 +157,12 @@ object CatanText {
seats: CatanSeats, seats: CatanSeats,
setupVertexChosen: Boolean, setupVertexChosen: Boolean,
building: BuildKind?, building: BuildKind?,
/** Cities & Knights: a knight has been picked to move, and its destination is awaited. */
movingKnight: Boolean = false,
/** Cities & Knights: a progress card is waiting for a tap on the board. */
armed: ProgressType? = null,
): String { ): String {
if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed)
val me = seats.me val me = seats.me
val onClock = CatanRules.currentSeat(state) val onClock = CatanRules.currentSeat(state)
val mine = onClock == me val mine = onClock == me
@@ -194,6 +216,8 @@ object CatanText {
"$turn is placing free roads…" "$turn is placing free roads…"
} }
CatanPhase.CHORE -> "Waiting for ${seats.label(onClock ?: state.turn)}…"
CatanPhase.TRADE_PENDING -> { CatanPhase.TRADE_PENDING -> {
val offer = state.pendingTrade val offer = state.pendingTrade
when { when {
@@ -236,4 +260,216 @@ object CatanText {
val winner = state.winner ?: return "Game over" val winner = state.winner ?: return "Game over"
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won" return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
} }
// --- Cities & Knights ----------------------------------------------------
fun good(good: Good): String = when (good) {
Good.LUMBER -> "Lumber"
Good.BRICK -> "Brick"
Good.WOOL -> "Wool"
Good.GRAIN -> "Grain"
Good.ORE -> "Ore"
Good.CLOTH -> "Cloth"
Good.COIN -> "Coin"
Good.PAPER -> "Paper"
}
/** "2 Lumber + 1 Cloth" for a flat list of goods. */
fun goods(cards: List<Good>): String =
cards.groupingBy { it }.eachCount().entries
.sortedBy { it.key.ordinal }
.joinToString(" + ") { (g, n) -> "$n ${good(g)}" }
.ifEmpty { "nothing" }
fun track(track: Track): String = when (track) {
Track.TRADE -> "Trade"
Track.POLITICS -> "Politics"
Track.SCIENCE -> "Science"
}
/** What the third level of a track gives, for the improvement dialog. */
fun trackPower(track: Track): String = when (track) {
Track.TRADE -> "From level 3, trade commodities with the bank two for one."
Track.POLITICS -> "From level 3, knights can be promoted to mighty."
Track.SCIENCE -> "From level 3, when the dice pay you nothing, take any resource."
}
fun knightName(level: Int): String = when (level) {
1 -> "Basic knight"
2 -> "Strong knight"
else -> "Mighty knight"
}
fun event(face: EventFace?): String = when (face) {
null -> "–"
EventFace.SHIP -> "Ship"
EventFace.TRADE -> "Trade"
EventFace.POLITICS -> "Politics"
EventFace.SCIENCE -> "Science"
}
fun progress(kind: ProgressType): String = CatanCk.progressName(kind)
fun progressHelp(kind: ProgressType): String = when (kind) {
ProgressType.ALCHEMIST -> "Before rolling, choose what the two number dice show."
ProgressType.CRANE -> "Your next city improvement costs one commodity less."
ProgressType.ENGINEER -> "Raise a wall round one of your cities for free."
ProgressType.INVENTOR -> "Swap two number tokens (not 2, 6, 8 or 12)."
ProgressType.IRRIGATION -> "Take 2 grain for each fields hex beside your settlements and cities."
ProgressType.MEDICINE -> "Upgrade a settlement to a city for 2 ore and 1 grain."
ProgressType.MINING -> "Take 2 ore for each mountains hex beside your settlements and cities."
ProgressType.PRINTER -> "One victory point."
ProgressType.ROAD_BUILDING -> "Build two roads for free."
ProgressType.SMITH -> "Promote up to two of your knights one level for free."
ProgressType.BISHOP -> "Move the robber and take a card from every player beside its new hex."
ProgressType.CONSTITUTION -> "One victory point."
ProgressType.DESERTER -> "Another player removes a knight; you place a free one of the same strength."
ProgressType.DIPLOMAT -> "Remove an open road. If it was yours, build it again anywhere for free."
ProgressType.INTRIGUE -> "Drive an opponent's knight off a crossing beside one of your roads."
ProgressType.SABOTEUR -> "Every player with as many points as you or more discards half their cards."
ProgressType.SPY -> "Look at another player's progress cards and take one."
ProgressType.WARLORD -> "Wake all your knights without paying grain."
ProgressType.WEDDING -> "Every player with more points gives you 2 cards."
ProgressType.COMMERCIAL_HARBOR -> "Offer a resource to each player; each swaps it for a commodity."
ProgressType.MASTER_MERCHANT -> "Take 2 cards from a player with more points."
ProgressType.MERCHANT -> "Put the merchant beside your settlement: trade that resource 2:1 and score a point."
ProgressType.MERCHANT_FLEET -> "This turn, trade one chosen card type with the bank two for one."
ProgressType.RESOURCE_MONOPOLY -> "Name a resource. Every other player gives you 2 of it."
ProgressType.TRADE_MONOPOLY -> "Name a commodity. Every other player gives you 1 of it."
}
const val IMPROVE = "Improve"
const val KNIGHTS = "Knights"
const val CARDS_BUTTON = "Progress"
const val WALL = "Wall"
const val KNIGHT = "Knight"
const val STOP_SMITH = "Done promoting"
val KNIGHT_COST: String get() = cost(CatanCk.KNIGHT_COST)
val ACTIVATE_COST: String get() = cost(CatanCk.ACTIVATE_COST)
val WALL_COST: String get() = cost(CatanCk.WALL_COST)
private fun ckPrompt(
state: CatanState,
seats: CatanSeats,
setupVertexChosen: Boolean,
building: BuildKind?,
movingKnight: Boolean,
armed: ProgressType?,
): String {
val me = seats.me
val onClock = CatanRules.currentSeat(state)
val mine = onClock == me
val turn = seats.label(state.turn)
val ck = state.ck!!
return when (state.phase) {
CatanPhase.GAME_OVER -> {
val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
}
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
setupVertexChosen -> "Now tap the road to place beside that settlement."
state.phase == CatanPhase.SETUP_SECOND ->
"Place your second piece: it is a city, and earns resources from the hexes around it."
else -> "Place a settlement, then a road."
}
CatanPhase.AWAIT_ROLL -> when {
!mine -> "$turn is rolling…"
armed != null -> armedPrompt(armed)
movingKnight -> "Tap where the knight should go."
else -> "Your turn. Roll the dice, or wake knights and play progress cards first."
}
CatanPhase.ROBBER -> when {
!mine -> "$turn is moving the robber…"
ck.bishop -> "The bishop: tap a hex. Everyone beside it pays you a card."
else -> "Move the robber. Tap a hex."
}
CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…"
armed != null -> armedPrompt(armed)
movingKnight -> "Tap where the knight should go."
building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
building == BuildKind.WALL -> "Tap one of your cities to raise a wall."
building == BuildKind.KNIGHT -> "Tap an empty crossing at the end of your road to hire a knight."
else -> "Your turn. Build, improve, trade, or end the turn. Tap a knight to use it."
}
CatanPhase.FREE_ROADS ->
if (mine) {
"Place ${state.pendingFreeRoads} free " +
if (state.pendingFreeRoads == 1) "road." else "roads."
} else {
"$turn is placing free roads…"
}
CatanPhase.TRADE_PENDING -> {
val offer = ck.goodsOffer
val asking = offer?.asking?.takeIf { it >= 0 }
when {
offer == null || asking == null -> "Waiting for a reply…"
asking == me -> "${seats.label(offer.by)} offers you a trade."
offer.by == me -> "Waiting for ${seats.label(asking)} to answer your offer…"
else -> "${seats.label(offer.by)} has offered ${seats.label(asking)} a trade."
}
}
CatanPhase.TRADE_COUNTERS ->
if (mine) {
"Nobody accepted, but there are counter-offers. Take one or turn them down."
} else {
"$turn is weighing counter-offers…"
}
CatanPhase.DISCARDING -> "Discarding…"
CatanPhase.CHORE -> {
val chore = ck.chores.firstOrNull()
val who = seats.label(chore?.seat ?: state.turn)
if (chore == null || chore.seat != me) {
when (chore) {
is PillageChore -> "The barbarians have landed. $who must give up a city."
is DiscardChore -> "$who must discard ${chore.count} cards."
else -> "Waiting for $who…"
}
} else {
when (chore) {
is DiscardChore -> "Discard ${chore.count} cards."
is GiveChore -> "Give ${chore.count} card${if (chore.count == 1) "" else "s"} to ${seats.lower(chore.to)}."
is PillageChore -> "The barbarians won. Tap a city of yours to give up."
is PickDeckChore -> "Choose a progress deck to draw from."
is DropProgressChore -> "You hold too many progress cards. Give one back."
is MetropolisChore -> "Tap the city that will bear the ${track(chore.track).lowercase()} metropolis."
is AqueductChore -> "Your aqueduct pays. Take any resource."
is RelocateChore -> "Your knight was driven off. Tap where it goes."
is RemoveKnightChore -> "Tap one of your knights to remove."
is PlaceKnightChore -> "Tap where your free ${knightName(chore.level).lowercase()} stands."
is SpyChore -> "Choose a progress card to take."
is SmithChore -> "Tap a knight to promote (${chore.left} left), or stop."
}
}
}
}
}
private fun armedPrompt(kind: ProgressType): String = when (kind) {
ProgressType.ENGINEER -> "Engineer: tap a city to wall."
ProgressType.MEDICINE -> "Medicine: tap a settlement to upgrade."
ProgressType.MERCHANT -> "Merchant: tap a hex beside one of your buildings."
ProgressType.DIPLOMAT -> "Diplomat: tap an open road to remove."
ProgressType.INTRIGUE -> "Intrigue: tap an enemy knight beside your road."
ProgressType.INVENTOR -> "Inventor: tap two hexes to swap their tokens."
else -> "Tap the board."
}
/** "6 steps away", "1 step away" for the barbarian ship. */
fun barbarianSteps(steps: Int): String = "$steps ${if (steps == 1) "step" else "steps"} away"
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
package org.prolibertate.games.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.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* Engines playing Cities & Knights against each other. These are the
* playtests: every move is checked for legality, the state is round-tripped
* through its wire format as it goes, nothing may leave a seat without a move,
* and the game has to reach a result.
*/
class CatanCkSelfPlayTest {
private val json = Json { encodeDefaults = true }
private fun config(options: CatanOptions, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
/** What one game looked like, for the summary printed at the end. */
private data class Report(
val seed: Long,
val moves: Int,
val turns: Int,
val attacks: Int,
val progressPlayed: Int,
val metropolises: Int,
val winner: Int?,
val points: List<Int>,
)
private fun playOut(options: CatanOptions, seed: Long, cap: Int = 30_000): Pair<CatanState, Report> {
val ai = CatanAi()
var state = CatanRules.initialState(config(options, seed))
var moves = 0
var attacks = 0
var progress = 0
while (!CatanRules.isFinished(state)) {
assertTrue("seed $seed ran past $cap moves; log tail: ${state.log.takeLast(8)}", moves++ < cap)
val seat = CatanRules.currentSeat(state)
?: error("seed $seed: nobody on the clock in ${state.phase}; log tail: ${state.log.takeLast(8)}")
val legal = CatanRules.legalMoves(state, seat)
assertTrue("seed $seed: seat $seat has no legal move in ${state.phase}", legal.isNotEmpty())
val move = ai.chooseMove(state, seat, legal)
assertTrue(
"seed $seed: seat $seat chose the illegal move $move in ${state.phase}; " +
"log tail: ${state.log.takeLast(5)}",
CatanRules.isLegalMove(state, seat, move),
)
val before = state.ck!!.attacked
val wasAttacks = state.log.count { it.contains("The barbarians land") }
state = CatanRules.applyMove(state, seat, move)
attacks += state.log.count { it.contains("The barbarians land") } - wasAttacks
if (move is PlayProgress) progress++
if (!before && state.ck!!.attacked) Unit
if (moves % 97 == 0) {
val back = CatanRules.decodeState(CatanRules.encodeState(state))
assertEquals("seed $seed: the state did not survive its wire format", state, back)
// A seat's view never carries another seat's progress cards or the decks.
for (viewer in 0 until options.playerCount) {
val view = CatanRules.viewFor(state, viewer)
assertTrue(view.ck!!.decks.values.all { it.isEmpty() })
for (other in 0 until options.playerCount) {
if (other != viewer) {
val spied = (state.ck!!.chores.firstOrNull() as? SpyChore)
val allowed = spied != null && spied.seat == viewer && spied.target == other
if (!allowed) assertTrue(view.ck!!.progressHands[other].isEmpty())
}
}
}
}
checkInvariants(state, seed)
}
val ck = state.ck!!
return state to Report(
seed = seed,
moves = moves,
turns = state.turnNumber,
attacks = attacks,
progressPlayed = progress,
metropolises = ck.metropolises.size,
winner = state.winner,
points = CatanRules.publicVictoryPoints(state),
)
}
/** Things that must hold in every state of every game. */
private fun checkInvariants(state: CatanState, seed: Long) {
val ck = state.ck!!
val tag = "seed $seed turn ${state.turnNumber}"
// Cards are conserved: no good is held in more copies than the bank started with.
for (good in Good.entries) {
val held = (0 until state.playerCount).sumOf { CatanCk.goodsOf(state, it)[good] ?: 0 }
val cap = if (good.isCommodity) BANK_PER_COMMODITY else BANK_PER_RESOURCE
assertTrue("$tag: $held ${good} cards exist, the bank only had $cap", held in 0..cap)
}
for (seat in 0 until state.playerCount) {
assertTrue(
"$tag: seat $seat holds negative cards",
CatanCk.goodsOf(state, seat).values.all { it >= 0 },
)
for (level in 1..3) {
assertTrue(
"$tag: seat $seat has more than two level-$level knights",
ck.knights.count { it.owner == seat && it.level == level } <= KNIGHTS_PER_LEVEL,
)
}
assertTrue("$tag: too many progress cards in seat $seat's hand", ck.progressHands[seat].size <= PROGRESS_HAND_MAX + 1)
assertTrue("$tag: seat $seat has more than three walls", CatanCk.wallsOf(state, seat) <= MAX_WALLS)
assertTrue(
"$tag: seat $seat has more than five settlements",
state.buildings.count { it == CatanBuilding(BuildingType.SETTLEMENT, seat) } <= MAX_SETTLEMENTS,
)
assertTrue(
"$tag: seat $seat has more than four cities",
state.buildings.count { it == CatanBuilding(BuildingType.CITY, seat) } <= MAX_CITIES,
)
}
// A knight stands on a crossing nothing else stands on, and no two share one.
assertEquals("$tag: two knights share a crossing", ck.knights.size, ck.knights.map { it.vertex }.toSet().size)
for (k in ck.knights) {
assertTrue("$tag: a knight stands on a building at ${k.vertex}", state.buildings[k.vertex] == null)
}
// Metropolises stand on cities of their owners.
for ((_, m) in ck.metropolises) {
assertEquals(
"$tag: a metropolis is not on its owner's city",
CatanBuilding(BuildingType.CITY, m.seat),
state.buildings[m.vertex],
)
}
// Every progress card exists exactly once, in a deck or a hand, or was played face up.
val cards = ck.decks.values.sumOf { it.size } + ck.progressHands.sumOf { it.size } + ck.progressVp.sum()
assertEquals("$tag: progress cards were lost or duplicated", 54, cards)
assertTrue("$tag: barbarians out of range", ck.barbarians in 1..BARBARIAN_STEPS)
}
@Test
fun `three engines play Cities and Knights to a finish`() {
val reports = (1L..12L).map { seed ->
val (state, report) = playOut(
CatanOptions(playerCount = 3, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
seed,
)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
val winner = state.winner
assertTrue("seed $seed finished without a winner", winner != null)
assertTrue(
"seed $seed: the winner fell short of the target",
CatanRules.victoryPoints(state, winner!!) >= 13,
)
report
}
reports.forEach { println("CK3 $it") }
}
@Test
fun `four engines play Cities and Knights to a finish`() {
val reports = (101L..108L).map { seed ->
val (state, report) = playOut(
CatanOptions(playerCount = 4, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
seed,
)
assertEquals("seed $seed", CatanPhase.GAME_OVER, state.phase)
report
}
reports.forEach { println("CK4 $it") }
}
}
@@ -0,0 +1,291 @@
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.AcceptCounter
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.CounterGoods
import org.prolibertate.games.game.catan.DismissCounters
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, is CounterGoods -> "tradeDialog"
is AcceptCounter, DismissCounters -> "counterDialog"
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) }
}
@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())
}
}
@@ -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)
}
}
+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) |
+222
View File
@@ -0,0 +1,222 @@
# 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** works as in the base game — an offer to one seat or to the whole
table, asked in seat order, each seat accepting, declining or countering — and may
include commodities (`OfferGoods`, `CounterGoods`; accepting and dismissing counters
reuse the base game's `AcceptCounter` and `DismissCounters`).
- **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.
+20 -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
@@ -311,6 +324,12 @@ 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 timer or cancel on offers.** An offer waits for each asked seat in turn; the offerer cannot withdraw it mid-round, and a player who never answers stalls the table. - **No timer or cancel on offers.** An offer waits for each asked seat in turn; the offerer cannot withdraw it mid-round, and a player who never answers stalls the table.
- **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them. - **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them.
- **No trading away a development card.** Not part of the base game. - **No trading away a development card.** Not part of the base game.
+28
View File
@@ -121,6 +121,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