From e344d1d817c28286702cfa705b5c3be60a0a4801 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:28:19 +0000 Subject: [PATCH] Katan: Cities & Knights screen, setup chooser and UI coverage tests The setup screen picks the rule set (base game or Cities & Knights, each with its own point targets). The board draws knights, walls, metropolis pennants and the merchant; the screen adds the barbarian strip, event die, commodity hand, improve and progress dialogs, the knight menu, and dialogs for every chore. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ --- .../prolibertate/games/ui/GameSetupScreen.kt | 50 +- .../games/ui/game/catan/CatanBoardView.kt | 151 +++- .../games/ui/game/catan/CatanCkDialogs.kt | 728 ++++++++++++++++++ .../games/ui/game/catan/CatanInteraction.kt | 258 ++++++- .../games/ui/game/catan/CatanPanels.kt | 225 +++++- .../games/ui/game/catan/CatanScreen.kt | 202 ++++- .../games/ui/game/catan/CatanStyle.kt | 34 + .../games/ui/game/catan/CatanText.kt | 226 ++++++ .../ui/game/catan/CatanCkInteractionTest.kt | 264 +++++++ .../games/ui/game/catan/CatanCkTextTest.kt | 75 ++ 10 files changed, 2177 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt create mode 100644 app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index eca5a11..4363df7 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -58,6 +58,7 @@ import org.prolibertate.games.game.pirates.PiratesLevel import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.president.PresidentOptions 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.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuOptions @@ -334,9 +335,55 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId) else -> seat } +/** The targets the setup screen offers for a Katan rule set. */ +internal fun catanPointsChoices(ruleSet: CatanRuleSet): List = 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 private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) { 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( label = "Players", values = listOf(3, 4), @@ -346,8 +393,7 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) - ) ChipRow( label = "Game to", - // Five is a short game for trying things out; ten is the published target. - values = listOf(5, 8, 10, 12), + values = catanPointsChoices(options.ruleSet), selected = options.pointsToWin, display = { "$it points" }, onSelect = { onChange(options.copy(pointsToWin = it)) }, diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt index 69df57b..b9f4c78 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke @@ -58,6 +59,8 @@ fun CatanBoardView( style: CatanStyle = LocalCatanStyle.current, /** Hexes to light up as having produced on the latest roll. */ rolledHexes: Set = emptySet(), + /** Cities & Knights: the knight whose menu is open or that is about to move. */ + selectedKnight: Int? = null, ) { val measurer = rememberTextMeasurer() var size by remember { mutableStateOf(IntSize.Zero) } @@ -86,8 +89,8 @@ fun CatanBoardView( val at = Pt(tap.x, tap.y) val reach = board.radius * style.tapReach when { - targets.vertices.isNotEmpty() -> - board.nearestVertex(at, reach, targets.vertices)?.let { vertexTap(it) } + targets.tappableVertices.isNotEmpty() -> + board.nearestVertex(at, reach, targets.tappableVertices)?.let { vertexTap(it) } targets.edges.isNotEmpty() -> board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) } @@ -105,10 +108,15 @@ fun CatanBoardView( drawHexes(state, board, style, measurer) drawRolled(rolledHexes, board, style) drawRobber(state, board, style) + drawMerchant(state, board, style, measurer) drawRoads(state, board, style) drawEdgeTargets(targets, 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) } } @@ -185,6 +193,10 @@ private fun DrawScope.drawToken( } 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) // Beside the number token rather than on it, so the number stays readable. 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) close() } - drawPath(pawn, color = style.robber) - drawCircle(color = style.robber, 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 = tint) + drawCircle(color = tint, radius = s * 0.38f, center = Offset(x, y - s * 0.42f)) + drawPath(pawn, color = rim, style = Stroke(width = s * 0.07f)) drawCircle( - color = Color.White.copy(alpha = 0.7f), + color = rim, radius = s * 0.38f, center = Offset(x, y - s * 0.42f), style = Stroke(width = s * 0.07f), @@ -409,6 +421,131 @@ private fun cityPath(at: Pt, s: Float): Path = Path().apply { 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 // --------------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt new file mode 100644 index 0000000..83c0d95 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt @@ -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.asGoods(): List = 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, + quota: Int, + confirmLabel: String, + selectable: (Good) -> Boolean, + allowed: (List) -> Boolean, + onConfirm: (List) -> Unit, +) { + var picked by remember { mutableStateOf(emptyMap()) } + 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, + 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, + seats: CatanSeats, + onPlay: (CatanMove) -> Unit, + onArm: (ArmedCard) -> Unit, + onCancel: () -> Unit, +) { + var chosen by remember { mutableStateOf(null) } + var target by remember { mutableStateOf(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, + 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, onPick: (PickDeck) -> Unit) { + val open = legal.filterIsInstance() + 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, 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, 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().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, legal: List, 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, + onBank: (GoodsBankTrade) -> Unit, + onOffer: (OfferGoods) -> Unit, + onCancel: () -> Unit, +) { + var tab by remember { mutableStateOf(GoodsTab.BANK) } + var bankGive by remember { mutableStateOf(null) } + var partner by remember { mutableStateOf(null) } + var give by remember { mutableStateOf(emptyMap()) } + var get by remember { mutableStateOf(emptyMap()) } + 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, limit: (Good) -> Int, onChange: (Map) -> 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) } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt index 9a9ca7d..f347f92 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -1,43 +1,93 @@ package org.prolibertate.games.ui.game.catan 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.BuyDevCard +import org.prolibertate.games.game.catan.BuyImprovement import org.prolibertate.games.game.catan.CatanBoard import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanPhase 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.Chore import org.prolibertate.games.game.catan.DeclineTrade import org.prolibertate.games.game.catan.DevCard import org.prolibertate.games.game.catan.DevCardType import org.prolibertate.games.game.catan.Discard 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.GoodsBankTrade +import org.prolibertate.games.game.catan.Good 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.MoveKnight 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.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.PlayMonopoly +import org.prolibertate.games.game.catan.PlayProgress import org.prolibertate.games.game.catan.PlayRoadBuilding 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.RollDice 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 = 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( val vertices: Set = emptySet(), val edges: Set = emptySet(), val hexes: Set = emptySet(), + val knights: Set = 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 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, 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) { + val playable: Boolean get() = plays.isNotEmpty() } /** 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. */ data class PickRobberVictim(val hex: HexCoord, val victims: List) : 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, building: BuildKind?, setupVertex: Int?, + movingKnight: Int? = null, + armed: ArmedCard? = null, ): BoardTargets { val setup = legal.filterIsInstance() if (setup.isNotEmpty()) { @@ -100,9 +158,21 @@ object CatanInteraction { 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().map { it.edge }.toSet() if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads) + if (armed != null) return armedTargets(armed) + + if (movingKnight != null) { + return BoardTargets( + vertices = legal.filterIsInstance().filter { it.from == movingKnight }.map { it.to }.toSet(), + ) + } + return when (building) { BuildKind.ROAD -> BoardTargets(edges = roads) BuildKind.SETTLEMENT -> @@ -111,15 +181,148 @@ object CatanInteraction { BuildKind.CITY -> BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) - null -> BoardTargets() + BuildKind.WALL -> + BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) + + BuildKind.KNIGHT -> + BoardTargets(vertices = legal.filterIsInstance().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): Set = 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().forEach { add(it.vertex) } + } + } + + private fun choreMove(legal: List, 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): Set = 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, vertex: Int): List = + 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): 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): List = + state.ck?.progressHands?.getOrNull(seat).orEmpty().map { card -> + ProgressInHand(card, legal.filterIsInstance().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 = 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): Set = + legal.filterIsInstance().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): Map> = + legal.filterIsInstance() + .groupBy({ it.give }, { it.get }) + .mapValues { (_, gets) -> gets.sortedBy { it.ordinal } } + .toSortedMap(compareBy { it.ordinal }) + + fun goodsOfferAllowed( + state: CatanState, + seat: Int, + to: Int, + give: List, + get: List, + ): 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( vertex: Int, legal: List, building: BuildKind?, setupVertex: Int?, + movingKnight: Int? = null, + armed: ArmedCard? = null, ): TapOutcome { val setup = legal.filterIsInstance() if (setup.isNotEmpty()) { @@ -131,10 +334,27 @@ object CatanInteraction { 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) { BuildKind.SETTLEMENT -> BuildSettlement(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 } @@ -144,12 +364,17 @@ object CatanInteraction { legal: List, building: BuildKind?, setupVertex: Int?, + armed: ArmedCard? = null, ): TapOutcome { if (legal.any { it is SetupPlace }) { if (setupVertex == null) return TapOutcome.Ignored val move = SetupPlace(setupVertex, edge) 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. val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads } if (!canBuild) return TapOutcome.Ignored @@ -157,7 +382,8 @@ object CatanInteraction { return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored } - fun onHexTap(hex: Int, legal: List): TapOutcome { + fun onHexTap(hex: Int, legal: List, armed: ArmedCard? = null): TapOutcome { + if (armed != null) return onArmedHexTap(hex, legal, armed) val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored val moves = legal.filterIsInstance().filter { it.hex == coord } return when { @@ -167,6 +393,22 @@ object CatanInteraction { } } + private fun onArmedHexTap(hex: Int, legal: List, 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 ------------------------------------------------------------ fun canRoll(legal: List): 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 BuildSettlement }) add(BuildKind.SETTLEMENT) 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): Pair = diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt index 30317cd..59aaa10 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -40,10 +40,15 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp 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.CatanRules 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.Track /* * 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) @Composable fun CatanHand( @@ -115,8 +165,13 @@ fun CatanHand( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Resource.entries.forEach { resource -> - ResourceTile(resource, state.resources[seat][resource] ?: 0) + if (state.isCk) { + val held = CatanCk.goodsOf(state, seat) + Good.entries.forEach { good -> GoodTile(good, held[good] ?: 0) } + } else { + Resource.entries.forEach { resource -> + ResourceTile(resource, state.resources[seat][resource] ?: 0) + } } } } @@ -175,6 +230,41 @@ fun CatanPlayerCard( CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold) } CardLine("$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) { CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall) } else { @@ -226,21 +316,36 @@ private fun CardLine( // 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 -fun CatanDice(dice: List, modifier: Modifier = Modifier) { +fun CatanDice( + dice: List, + modifier: Modifier = Modifier, + ck: Boolean = false, + event: EventFace? = null, + style: CatanStyle = LocalCatanStyle.current, +) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { 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( modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(8.dp)) - .background(Color.White) + .background(face) .border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center, ) { @@ -253,6 +358,31 @@ fun CatanDice(dice: List, 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. Text( text = if (dice.size == 2) "= ${dice.sum()}" else "= –", @@ -262,6 +392,48 @@ fun CatanDice(dice: List, 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 // --------------------------------------------------------------------------- @@ -278,6 +450,14 @@ data class CatanActions( val canEndFreeRoads: Boolean = false, /** A setup settlement is chosen and a different one may still be picked. */ val canRepickSetup: Boolean = false, + /** Which kinds the Build menu lists: three in the base game, five in Cities & Knights. */ + val buildKinds: List = 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, /** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */ compact: Boolean = false, + onImprove: () -> Unit = {}, + onProgress: () -> Unit = {}, + onEndSmith: () -> Unit = {}, ) { Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( @@ -315,6 +498,7 @@ fun CatanActionBar( if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) } if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) } 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) } } @@ -323,18 +507,27 @@ fun CatanActionBar( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (compact) { + if (compact || actions.ck) { BuildMenuButton(actions, onBuild) } else { BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild) BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild) BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild) } - OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) { - LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) - } - OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { - LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST) + 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) { + LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST) + } + OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { + LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST) + } } OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { 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() } } DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - for (kind in BuildKind.entries) { + for (kind in actions.buildKinds) { DropdownMenuItem( text = { LabelWithCost(buildLabel(kind), buildCost(kind)) }, enabled = kind in actions.canBuild, @@ -402,12 +595,16 @@ private fun buildLabel(kind: BuildKind): String = when (kind) { BuildKind.ROAD -> CatanText.ROAD BuildKind.SETTLEMENT -> CatanText.SETTLEMENT BuildKind.CITY -> CatanText.CITY + BuildKind.WALL -> CatanText.WALL + BuildKind.KNIGHT -> CatanText.KNIGHT } private fun buildCost(kind: BuildKind): String = when (kind) { BuildKind.ROAD -> CatanText.ROAD_COST BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST BuildKind.CITY -> CatanText.CITY_COST + BuildKind.WALL -> CatanText.WALL_COST + BuildKind.KNIGHT -> CatanText.KNIGHT_COST } @Composable diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 9818719..cff6723 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -28,17 +28,29 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp 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.CatanCk import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanPhase import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState 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.EndSmith 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.PickDeckChore import org.prolibertate.games.game.catan.RollDice import org.prolibertate.games.game.catan.SetupPlace +import org.prolibertate.games.game.catan.SpyChore import org.prolibertate.games.net.MatchController import org.prolibertate.games.ui.ScreenScaffold @@ -84,6 +96,13 @@ fun CatanScreen( var showTrade by remember { mutableStateOf(false) } var confirmingEnd 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(null) } + var movingKnight by remember { mutableStateOf(null) } + var armed by remember { mutableStateOf(null) } + var showImprove by remember { mutableStateOf(false) } + var showProgress by remember { mutableStateOf(false) } val dark = isSystemInDarkTheme() val style = remember(dark) { catanStyle(dark) } @@ -97,10 +116,18 @@ fun CatanScreen( if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) { setupVertex = null } + if (movingKnight != null && legal.none { it is MoveKnight && it.from == movingKnight }) movingKnight = null + if (knightMenu != null && CatanInteraction.knightActions(legal, knightMenu!!).isEmpty()) knightMenu = null + armed?.let { card -> if (card.plays.none { it in legal }) armed = null } if (legal.isEmpty()) { showCards = false showTrade = false victimChoice = null + showImprove = false + showProgress = false + knightMenu = null + movingKnight = null + armed = null } } @@ -111,6 +138,11 @@ fun CatanScreen( victimChoice = null showCards = false showTrade = false + showImprove = false + showProgress = false + knightMenu = null + movingKnight = null + armed = null } val endGameAction: @Composable () -> Unit = { @@ -127,10 +159,17 @@ fun CatanScreen( 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 progressHand = CatanInteraction.progressHand(current, localSeat, legal) val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty() 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), canBuild = CatanInteraction.canBuild(legal), building = building, @@ -142,7 +181,14 @@ fun CatanScreen( canRepickSetup = setupVertex != null, ) 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 -> when (outcome) { @@ -150,6 +196,8 @@ fun CatanScreen( is TapOutcome.Submit -> submit(outcome.move) is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex 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, targets = targets, selectedVertex = setupVertex, - onVertexTap = { handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex)) }, - onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex)) }, - onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) }, + onVertexTap = { + handle(CatanInteraction.onVertexTap(it, legal, building, setupVertex, movingKnight, armed)) + }, + onEdgeTap = { handle(CatanInteraction.onEdgeTap(it, legal, building, setupVertex, armed)) }, + onHexTap = { handle(CatanInteraction.onHexTap(it, legal, armed)) }, modifier = boardModifier, style = style, rolledHexes = rolledHexes, + selectedKnight = movingKnight ?: knightMenu, ) } val players: @Composable (Boolean) -> Unit = { grid -> @@ -200,6 +251,9 @@ fun CatanScreen( }, onRepickSetup = { setupVertex = null }, 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), ) { players(true) + if (current.isCk) CatanBarbarians(current, style = style) promptRow() handAndActions() CatanLog(current, Modifier.fillMaxWidth().height(120.dp)) @@ -222,6 +277,7 @@ fun CatanScreen( } else { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { players(false) + if (current.isCk) CatanBarbarians(current, style = style) promptRow() Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { board(Modifier) @@ -243,6 +299,21 @@ fun CatanScreen( showCards = showCards, showTrade = showTrade, 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, onSubmit = submit, onStay = controller::dismissAbandoned, @@ -315,7 +386,7 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) { overflow = TextOverflow.Ellipsis, 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, showTrade: Boolean, devHand: List, + knightMenu: Int?, + showImprove: Boolean, + showProgress: Boolean, + progressHand: List, + onMoveKnight: (Int) -> Unit, + onArmCard: (ArmedCard) -> Unit, + onCancelKnight: () -> Unit, + onCancelImprove: () -> Unit, + onCancelProgress: () -> Unit, onExit: () -> Unit, onSubmit: (CatanMove) -> Unit, onStay: () -> Unit, @@ -347,6 +427,7 @@ private fun CatanDialogHost( onCancelTrade: () -> Unit, ) { val offer = state.pendingTrade + val goodsOffer = state.ck?.goodsOffer when { abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay) @@ -363,6 +444,18 @@ private fun CatanDialogHost( 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 -> CatanTradeResponseDialog( offer = offer, @@ -380,8 +473,47 @@ private fun CatanDialogHost( 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) + showTrade && state.isCk -> + CatanGoodsTradeDialog( + state = state, + seat = localSeat, + seats = seats, + legal = legal, + onBank = onSubmit, + onOffer = onSubmit, + onCancel = onCancelTrade, + ) + showTrade -> CatanTradeDialog( 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, + onSubmit: (CatanMove) -> Unit, +) { + val ck = state.ck ?: return + val check: (List, (List) -> 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 + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt index 256065e..90db565 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt @@ -3,8 +3,10 @@ package org.prolibertate.games.ui.game.catan import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf 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.Terrain +import org.prolibertate.games.game.catan.Track /** * Every visual decision for the Katan screen, in one place. @@ -46,6 +48,19 @@ data class CatanStyle( val resource: Map, val resourceInk: Color, + // --- Cities & Knights --------------------------------------------------- + /** Commodity cards: cloth, coin and paper. */ + val commodity: Map = LightCommodities, + /** Ink on a commodity tile (paper is pale, so it is not white). */ + val commodityInk: Map = LightCommodityInk, + /** The colour of each improvement track, its city gate, its deck and its metropolis. */ + val track: Map = 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 ------------------------------------------------------------ /** One colour per seat, in seat order. Roads, settlements, cities, player strip. */ val seats: List, @@ -72,6 +87,25 @@ data class CatanStyle( val tapReach: Float = 0.55f, val tokenText: Float = 0.34f, 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. */ diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt index a8b2984..8e47b77 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -1,12 +1,29 @@ 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.CatanRules import org.prolibertate.games.game.catan.CatanState 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.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.SmithChore +import org.prolibertate.games.game.catan.SpyChore 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 @@ -136,7 +153,12 @@ object CatanText { seats: CatanSeats, setupVertexChosen: Boolean, 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 { + if (state.isCk) return ckPrompt(state, seats, setupVertexChosen, building, movingKnight, armed) val me = seats.me val onClock = CatanRules.currentSeat(state) val mine = onClock == me @@ -218,4 +240,208 @@ object CatanText { val winner = state.winner ?: return "Game over" 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): 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" } diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt new file mode 100644 index 0000000..4b9fd6f --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt @@ -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, 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, 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, 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, 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 { + val ai = CatanAi() + var state = CatanRules.initialState(config(players, seed)) + val seen = mutableMapOf() + 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() + 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) } + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt new file mode 100644 index 0000000..8a8d6f7 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkTextTest.kt @@ -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) + } +}