Release: dev to main (Cities & Knights) #9

Merged
devadmin merged 6 commits from dev into main 2026-10-17 12:43:31 -06:00
10 changed files with 2177 additions and 36 deletions
Showing only changes of commit e344d1d817 - Show all commits
@@ -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,728 @@
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.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.DropProgress
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.Good
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 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)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(0 until state.playerCount).filter { it != seat }.forEach { other ->
GoodsTabButton(seats.label(other), partner == other) { 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 = partner
val giveCards = give.asGoods()
val getCards = get.asGoods()
val allowed = to != null && CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards)
Button(
onClick = { if (to != null) 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) }
}
}
/** Another player has offered the local player a trade in goods. */
@Composable
fun CatanGoodsTradeResponseDialog(
offer: GoodsOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("Trade offer") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
"${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to you " +
"for ${CatanText.goods(offer.get)}",
)
if (!canAccept) {
Text(
"You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } },
dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } },
)
}
@@ -1,43 +1,93 @@
package org.prolibertate.games.ui.game.catan package org.prolibertate.games.ui.game.catan
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.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.GoodsBankTrade
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.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(),
) { ) {
val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty() && knights.isEmpty()
/** Every crossing a tap can land on. */
val tappableVertices: Set<Int> get() = vertices + knights
}
/**
* 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. */
@@ -53,6 +103,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
} }
/** /**
@@ -85,6 +141,8 @@ object CatanInteraction {
legal: List<CatanMove>, legal: List<CatanMove>,
building: BuildKind?, building: BuildKind?,
setupVertex: Int?, setupVertex: Int?,
movingKnight: Int? = null,
armed: ArmedCard? = null,
): BoardTargets { ): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>() val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) { if (setup.isNotEmpty()) {
@@ -100,9 +158,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 ->
@@ -111,15 +181,148 @@ 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 -> BoardTargets(knights = knightsWithActions(legal))
} }
} }
/** 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))
/** 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,
): TapOutcome { ): TapOutcome {
val setup = legal.filterIsInstance<SetupPlace>() val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) { if (setup.isNotEmpty()) {
@@ -131,10 +334,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)) {
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
} }
@@ -144,12 +364,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
@@ -157,7 +382,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 {
@@ -167,6 +393,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 }
@@ -200,6 +442,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,11 +165,16 @@ fun CatanHand(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
if (state.isCk) {
val held = CatanCk.goodsOf(state, seat)
Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) }
} else {
Resource.entries.forEach { resource -> Resource.entries.forEach { resource ->
ResourceTile(resource, state.resources[seat][resource] ?: 0) ResourceTile(resource, state.resources[seat][resource] ?: 0)
} }
} }
} }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Players // Players
@@ -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(
"Knights ${CatanCk.knightsOf(state, seat).size} (strength $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,19 +507,28 @@ 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)
} }
if (actions.ck) {
OutlinedButton(onClick = onImprove, enabled = actions.canImprove) {
LabelWithCost(CatanText.IMPROVE, CatanText.NO_COST)
}
OutlinedButton(onClick = onProgress, enabled = actions.canPlayProgress) {
LabelWithCost(CatanText.CARDS_BUTTON, "${actions.progressCount} held")
}
} else {
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
} }
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST) 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,17 +28,29 @@ 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.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
@@ -84,6 +96,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) }
@@ -97,10 +116,18 @@ 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
if (knightMenu != null && CatanInteraction.knightActions(legal, knightMenu!!).isEmpty()) knightMenu = null
armed?.let { card -> if (card.plays.none { it in legal }) armed = null }
if (legal.isEmpty()) { 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
} }
} }
@@ -111,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 = {
@@ -127,10 +159,17 @@ fun CatanScreen(
return return
} }
val targets = CatanInteraction.targets(current, legal, building, setupVertex) val targets = CatanInteraction.targets(current, legal, building, setupVertex, movingKnight, armed)
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,
@@ -142,7 +181,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) {
@@ -150,6 +196,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)
} }
} }
@@ -170,12 +218,15 @@ 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(CatanInteraction.onVertexTap(it, legal, building, setupVertex, movingKnight, armed))
onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) }, },
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 ->
@@ -200,6 +251,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) },
) )
} }
} }
@@ -214,6 +268,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))
@@ -222,6 +277,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)
@@ -243,6 +299,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,
@@ -315,7 +386,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)
} }
} }
@@ -337,6 +408,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,
@@ -347,6 +427,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)
@@ -363,6 +444,18 @@ 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.to == localSeat ->
CatanGoodsTradeResponseDialog(
offer = goodsOffer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
)
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat -> state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat ->
CatanTradeResponseDialog( CatanTradeResponseDialog(
offer = offer, offer = offer,
@@ -380,8 +473,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,
@@ -394,3 +526,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),
/** A knight that is awake and ready to act. */
val knightReady: Color = Color(0xFFFFEB3B),
// --- 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
@@ -136,7 +153,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
@@ -218,4 +240,208 @@ 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
when {
offer == null -> "Waiting for a reply…"
offer.to == me -> "${seats.label(offer.by)} offers you a trade."
offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…"
else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade."
}
}
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"
} }
@@ -0,0 +1,264 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildWall
import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DiscardGoods
import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.GiveGoods
import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.HireKnight
import org.prolibertate.games.game.catan.MoveKnight
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferGoods
import org.prolibertate.games.game.catan.PickDeck
import org.prolibertate.games.game.catan.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis
import org.prolibertate.games.game.catan.PlayProgress
import org.prolibertate.games.game.catan.PromoteKnight
import org.prolibertate.games.game.catan.RelocateKnight
import org.prolibertate.games.game.catan.RemoveKnight
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.game.catan.SpyTake
import org.prolibertate.games.game.catan.TakeAqueduct
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* Cities & Knights has no device in this test suite, so the screen's logic is
* checked another way: engines play whole games, and at every position the
* player on the clock is treated as the local one. For every move the engine says
* is legal, [CatanInteraction] (and the dialogs' own filters) must offer a way to
* make it — a tap, a button or a dialog choice. A legal move that no control can
* produce would be a stuck game on a real phone.
*/
class CatanCkInteractionTest {
private val json = Json { encodeDefaults = true }
private fun config(players: Int, seed: Long) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(
CatanOptions(players, pointsToWin = 13, ruleSet = CatanRuleSet.CITIES_AND_KNIGHTS),
),
seed = seed,
)
/** How a move reaches the engine from the screen, or null if nothing can produce it. */
private fun reachable(state: CatanState, legal: List<CatanMove>, move: CatanMove): String? {
fun tapVertex(vertex: Int, building: BuildKind? = null, moving: Int? = null, armed: ArmedCard? = null): TapOutcome =
CatanInteraction.onVertexTap(vertex, legal, building, null, moving, armed)
return when (move) {
is SetupPlace -> {
val vertex = CatanInteraction.targets(state, legal, null, null).vertices
val edges = CatanInteraction.targets(state, legal, null, move.vertex).edges
"setup".takeIf {
move.vertex in vertex && move.edge in edges &&
CatanInteraction.onVertexTap(move.vertex, legal, null, null) is TapOutcome.PickSetupVertex &&
CatanInteraction.onEdgeTap(move.edge, legal, null, move.vertex) == TapOutcome.Submit(move)
}
}
is BuildRoad -> {
val targets = CatanInteraction.targets(state, legal, BuildKind.ROAD, null)
"road".takeIf {
move.edge in targets.edges &&
CatanInteraction.onEdgeTap(move.edge, legal, BuildKind.ROAD, null) == TapOutcome.Submit(move)
}
}
is BuildSettlement -> viaBuild(state, legal, BuildKind.SETTLEMENT, move.vertex, move)
is BuildCity -> viaBuild(state, legal, BuildKind.CITY, move.vertex, move)
is BuildWall -> viaBuild(state, legal, BuildKind.WALL, move.vertex, move)
is HireKnight -> viaBuild(state, legal, BuildKind.KNIGHT, move.vertex, move)
is MoveRobber -> {
val hex = CatanBoard.hexIndex.getValue(move.hex)
val targets = CatanInteraction.targets(state, legal, null, null)
val tap = CatanInteraction.onHexTap(hex, legal)
"robber".takeIf {
hex in targets.hexes &&
(tap == TapOutcome.Submit(move) || (tap is TapOutcome.PickRobberVictim && move.victim in tap.victims))
}
}
is MoveKnight -> {
val targets = CatanInteraction.targets(state, legal, null, null, movingKnight = move.from)
"moveKnight".takeIf {
move.to in targets.vertices && tapVertex(move.to, moving = move.from) == TapOutcome.Submit(move) &&
move.from in CatanInteraction.targets(state, legal, null, null).knights &&
CatanInteraction.knightActions(legal, move.from).any { it is MoveKnight }
}
}
is ActivateKnight, is ChaseRobber, is PromoteKnight -> {
val vertex = when (move) {
is ActivateKnight -> move.vertex
is ChaseRobber -> move.vertex
else -> (move as PromoteKnight).vertex
}
val smith = legal.any { it is EndSmith }
if (move is PromoteKnight && smith) {
val targets = CatanInteraction.targets(state, legal, null, null)
"smith".takeIf {
vertex in targets.vertices && tapVertex(vertex) == TapOutcome.Submit(move)
}
} else {
val targets = CatanInteraction.targets(state, legal, null, null)
"knightMenu".takeIf {
vertex in targets.knights && tapVertex(vertex) == TapOutcome.PickKnight(vertex) &&
move in CatanInteraction.knightActions(legal, vertex)
}
}
}
is PillageCity -> viaChore(state, legal, move.vertex, move)
is PlaceMetropolis -> viaChore(state, legal, move.vertex, move)
is RelocateKnight -> viaChore(state, legal, move.to, move)
is RemoveKnight -> viaChore(state, legal, move.vertex, move)
is PlaceKnight -> viaChore(state, legal, move.vertex, move)
is PlayProgress -> viaCard(state, legal, move)
is BuyImprovement -> "improve".takeIf { move.track in CatanInteraction.improvable(legal) }
is GoodsBankTrade ->
"bankTrade".takeIf { move.get in CatanInteraction.goodsBankTrades(legal)[move.give].orEmpty() }
RollDice(), is RollDice -> "roll".takeIf { CatanInteraction.canRoll(legal) }
EndTurn -> "endTurn".takeIf { CatanInteraction.canEndTurn(legal) }
EndFreeRoads -> "endRoads".takeIf { CatanInteraction.canEndFreeRoads(legal) }
EndSmith -> "endSmith".takeIf { CatanInteraction.canEndSmith(legal) }
AcceptTrade, DeclineTrade -> "tradeReply".takeIf { CatanInteraction.tradeResponses(legal) != (false to false) }
// Answered in a dialog that offers exactly the moves in the legal list.
is PickDeck, is DropProgress, is TakeAqueduct, is SpyTake -> "choreDialog"
// Structural: the discard and give dialogs build these and ask the engine.
is DiscardGoods, is GiveGoods -> "goodsDialog"
is OfferGoods -> "tradeDialog"
else -> null
}
}
private fun viaBuild(state: CatanState, legal: List<CatanMove>, kind: BuildKind, vertex: Int, move: CatanMove): String? {
val targets = CatanInteraction.targets(state, legal, kind, null)
return "build $kind".takeIf {
vertex in targets.vertices &&
CatanInteraction.onVertexTap(vertex, legal, kind, null) == TapOutcome.Submit(move) &&
kind in CatanInteraction.canBuild(legal)
}
}
private fun viaChore(state: CatanState, legal: List<CatanMove>, vertex: Int, move: CatanMove): String? {
val targets = CatanInteraction.targets(state, legal, null, null)
return "chore".takeIf {
vertex in targets.vertices &&
CatanInteraction.onVertexTap(vertex, legal, null, null) == TapOutcome.Submit(move)
}
}
private fun viaCard(state: CatanState, legal: List<CatanMove>, move: PlayProgress): String? {
val seat = CatanRules.currentSeat(state) ?: return null
val entry = CatanInteraction.progressHand(state, seat, legal).firstOrNull { it.card.id == move.card }
?: return null
val kind = entry.card.kind
if (move !in entry.plays) return null
if (kind !in CatanInteraction.BOARD_CARDS) return "cardDialog"
val armed = ArmedCard(kind, entry.plays)
val targets = CatanInteraction.targets(state, legal, null, null, armed = armed)
return when (kind) {
org.prolibertate.games.game.catan.ProgressType.DIPLOMAT ->
"armed".takeIf {
move.edge in targets.edges &&
CatanInteraction.onEdgeTap(move.edge!!, legal, null, null, armed) == TapOutcome.Submit(move)
}
org.prolibertate.games.game.catan.ProgressType.MERCHANT ->
"armed".takeIf {
move.hexA in targets.hexes &&
CatanInteraction.onHexTap(move.hexA!!, legal, armed) == TapOutcome.Submit(move)
}
org.prolibertate.games.game.catan.ProgressType.INVENTOR -> {
val first = CatanInteraction.onHexTap(move.hexA!!, legal, armed)
if (first != TapOutcome.PickFirstHex(move.hexA!!)) return null
val second = armed.copy(firstHex = move.hexA)
val partners = CatanInteraction.targets(state, legal, null, null, armed = second).hexes
"armed".takeIf {
move.hexB in partners &&
CatanInteraction.onHexTap(move.hexB!!, legal, second) == TapOutcome.Submit(move)
}
}
else ->
"armed".takeIf {
move.vertex in targets.vertices &&
CatanInteraction.onVertexTap(move.vertex!!, legal, null, null, null, armed) == TapOutcome.Submit(move)
}
}
}
private fun playAndCheck(players: Int, seed: Long): Map<String, Int> {
val ai = CatanAi()
var state = CatanRules.initialState(config(players, seed))
val seen = mutableMapOf<String, Int>()
var moves = 0
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
val seat = CatanRules.currentSeat(state) ?: break
val legal = CatanRules.legalMoves(state, seat)
for (move in legal) {
val how = reachable(state, legal, move)
assertTrue(
"seed $seed: no control produces $move in ${state.phase} " +
"(chore ${state.ck?.chores?.firstOrNull()}); log tail: ${state.log.takeLast(3)}",
how != null,
)
seen.merge(how!!, 1, Int::plus)
}
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
return seen
}
@Test
fun `every legal move in a full game has a control that produces it`() {
val total = mutableMapOf<String, Int>()
listOf(3 to 21L, 4 to 22L, 3 to 23L, 4 to 24L).forEach { (players, seed) ->
playAndCheck(players, seed).forEach { (k, v) -> total.merge(k, v, Int::plus) }
}
println("CK UI coverage: ${total.toSortedMap()}")
// The ways the screen builds, moves and answers all got exercised.
listOf("setup", "road", "build SETTLEMENT", "build CITY", "knightMenu", "improve", "bankTrade", "roll", "endTurn")
.forEach { assertTrue("never exercised: $it in $total", (total[it] ?: 0) > 0) }
}
}
@@ -0,0 +1,75 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.ProgressType
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.ui.catanDefaultPoints
import org.prolibertate.games.ui.catanPointsChoices
import org.prolibertate.games.ui.catanSwitchRules
class CatanCkTextTest {
private val json = Json { encodeDefaults = true }
@Test
fun `every position of a Cities and Knights game has a prompt for every seat`() {
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(
CatanOptions(3, 13, CatanRuleSet.CITIES_AND_KNIGHTS),
),
seed = 31L,
)
var state = CatanRules.initialState(config)
val ai = CatanAi()
var moves = 0
while (!CatanRules.isFinished(state) && moves++ < 20_000) {
for (me in 0 until 3) {
val seats = CatanSeats(me, listOf("A", "B", "C"))
val text = CatanText.prompt(state, seats, setupVertexChosen = false, building = null)
assertTrue("blank prompt in ${state.phase} for seat $me", text.isNotBlank())
}
val seat = CatanRules.currentSeat(state) ?: break
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, CatanRules.legalMoves(state, seat)))
}
}
@Test
fun `every progress card has a name and a description`() {
ProgressType.entries.forEach {
assertTrue(CatanText.progress(it).isNotBlank())
assertTrue("no help for $it", CatanText.progressHelp(it).length > 10)
}
}
@Test
fun `the setup screen offers each rule set its own targets and switches sensibly`() {
assertEquals(listOf(5, 8, 10, 12), catanPointsChoices(CatanRuleSet.BASE))
assertEquals(listOf(8, 10, 13, 15), catanPointsChoices(CatanRuleSet.CITIES_AND_KNIGHTS))
assertEquals(10, catanDefaultPoints(CatanRuleSet.BASE))
assertEquals(13, catanDefaultPoints(CatanRuleSet.CITIES_AND_KNIGHTS))
val base = CatanOptions()
val ck = catanSwitchRules(base, CatanRuleSet.CITIES_AND_KNIGHTS)
assertEquals(CatanRuleSet.CITIES_AND_KNIGHTS, ck.ruleSet)
assertEquals("the published target for the new rules", 13, ck.pointsToWin)
assertEquals(10, catanSwitchRules(ck, CatanRuleSet.BASE).pointsToWin)
// A short game picked on purpose is kept when both menus have it.
val short = catanSwitchRules(base.copy(pointsToWin = 8), CatanRuleSet.CITIES_AND_KNIGHTS)
assertEquals(8, short.pointsToWin)
assertEquals(3, ck.playerCount)
}
}