Katan: playable screen, setup options, dev-preview catalogue gate
CI / Build and check (pull_request) Successful in 20m42s
CI / Deploy to Cloudflare Pages (pull_request) Skipped

Adds the Katan screen on top of the WALA-119 engine, built in separable layers
so the look, wording, layout and interaction can each be changed alone:

- CatanStyle: every colour and proportion (light/dark), via LocalCatanStyle
- CatanText: every word on screen (pure functions of state)
- CatanGeometry: lattice -> pixels and tap hit-testing (pure, unit-tested)
- CatanInteraction: what is tappable and what a tap means, derived only from the
  engine's legal-move list (pure, unit-tested)
- CatanBoardView / CatanPanels / CatanDialogs: stateless composables
- CatanScreen: wiring to MatchController, portrait and landscape layouts
- CatanPreviews: Android Studio previews of real positions
- docs/UI-catan.md: how to adapt each layer, rules of the road, known gaps

Wiring: PlayScreen branch, GameSetupScreen options (3-4 players, game to
5/8/10/12), and a GameDescriptor.devPreview flag so dev builds can open Katan
while production still hides it until two-device play is verified.

Tests: CatanGeometryTest, CatanInteractionTest, CatanTextTest, CatalogTest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
This commit is contained in:
Claude
2026-10-14 22:24:45 +00:00
parent a778e4732f
commit 30dcf026bd
21 changed files with 3366 additions and 18 deletions
@@ -42,7 +42,20 @@ data class GameDescriptor(
val teamBased: Boolean,
val blurb: String,
val available: Boolean,
/**
* Playable on a dev build while still hidden from production.
*
* [available] is the release gate: it is what a production build lists and
* lets people open. A game that has an engine and a screen but has not yet
* been verified on real devices sets this instead, so it can be played and
* tested from the dev channel without being offered to everyone. Flip
* [available] on (and this off) once it has been verified.
*/
val devPreview: Boolean = false,
) {
/** Whether the menu should let this game be opened in the given kind of build. */
fun playableIn(devBuild: Boolean): Boolean = available || (devBuild && devPreview)
/**
* The one menu this game is listed under.
*
@@ -348,10 +361,12 @@ object GameCatalog {
maxPlayers = 4,
teamBased = false,
blurb = "Trade, build, and settle the island.",
// Listed, not playable: the engine and screen land with the Katan
// game task. Dev builds show the entry as *Coming soon*; a
// production build lists only what can actually be played.
// Engine and screen exist but have not been verified on two real
// devices yet. Dev builds can open it as a *Preview*; a production
// build does not list it. Flip `available` on, and `devPreview`
// off, once two-device play has been verified.
available = false,
devPreview = true,
),
)
@@ -364,7 +364,11 @@ fun SectionScreen(
}
}
items(games) { game ->
GameTile(game = game, onClick = { onPickGame(game) })
GameTile(
game = game,
playable = game.playableIn(devBuild = showComingSoon),
onClick = { onPickGame(game) },
)
}
}
}
@@ -403,12 +407,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) {
Card(
onClick = onClick,
enabled = game.available,
enabled = playable,
colors = CardDefaults.cardColors(
containerColor = if (game.available) {
containerColor = if (playable) {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.surface
@@ -427,13 +431,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
val players = when {
// A puzzle for one, rather than "1 players".
game.maxPlayers == 1 -> "On your own"
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
else -> "${game.minPlayers}–${game.maxPlayers} players"
}
Text(
text = when {
!game.available -> "Coming soon"
// A puzzle for one, rather than "1 players".
game.maxPlayers == 1 -> "On your own"
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
else -> "${game.minPlayers}–${game.maxPlayers} players"
!playable -> "Coming soon"
// Playable on this dev build only; production hides it.
!game.available -> "Preview · $players"
else -> players
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 8.dp),
@@ -57,6 +57,7 @@ import org.prolibertate.games.game.morris.MorrisOptions
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.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions
@@ -104,6 +105,7 @@ fun GameSetupScreen(
var freecell by remember { mutableStateOf(FreeCellOptions()) }
var spider by remember { mutableStateOf(SpiderOptions()) }
var pyramid by remember { mutableStateOf(PyramidOptions()) }
var catan by remember { mutableStateOf(CatanOptions()) }
// Not an engine option: which seat the local player takes. Seat 0 is
// always White, so choosing Black means sitting in seat 1.
var playWhite by remember { mutableStateOf(true) }
@@ -134,6 +136,7 @@ fun GameSetupScreen(
GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
GameCatalog.SPIDER -> setupJson.encodeToString(spider)
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
GameCatalog.CATAN -> setupJson.encodeToString(catan)
else -> "{}"
}
val seatCount = seatCountFor(descriptor.id, optionsJson)
@@ -199,6 +202,8 @@ fun GameSetupScreen(
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
else -> Text("No options yet for this game.")
}
@@ -303,6 +308,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
GameCatalog.TAYU ->
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
GameCatalog.CATAN ->
setupJson.decodeFromString<CatanOptions>(optionsJson).playerCount
else -> fallback
}
}.getOrDefault(fallback)
@@ -326,6 +334,27 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
else -> seat
}
@Composable
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Players",
values = listOf(3, 4),
selected = options.playerCount,
display = { "$it players" },
onSelect = { onChange(options.copy(playerCount = it)) },
)
ChipRow(
label = "Game to",
// Five is a short game for trying things out; ten is the published target.
values = listOf(5, 8, 10, 12),
selected = options.pointsToWin,
display = { "$it points" },
onSelect = { onChange(options.copy(pointsToWin = it)) },
)
}
}
@Composable
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -13,6 +13,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.chess.ChessAi
import org.prolibertate.games.game.chess.ChessMove
import org.prolibertate.games.game.chess.ChessRules
@@ -110,6 +114,7 @@ import org.prolibertate.games.game.wizard.WizardState
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.BackgammonScreen
import org.prolibertate.games.ui.game.catan.CatanScreen
import org.prolibertate.games.ui.game.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen
@@ -668,6 +673,30 @@ fun PlayScreen(
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
}
GameCatalog.CATAN -> {
val controller = remember(route) {
MatchController<CatanState, CatanMove>(
rules = CatanRules,
ai = CatanAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
// A computer's turn is several small moves (roll, build,
// trade, end), so the pause is per move, not per turn.
aiThinkingMillis = { settings.scaled(450L) },
)
}
StartMatch(env, controller, route)
CatanScreen(
controller = controller,
localSeat = route.localSeat,
seatNames = route.config.seats.sortedBy { it.seat }.map { it.name },
onExit = onExit,
)
}
else -> Text("That game isn't playable yet.")
}
}
@@ -0,0 +1,400 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.IntSize
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.NO_PLAYER
/**
* The island, drawn and tappable.
*
* A stateless view: it is handed the state, the parts of the board that are
* currently tappable ([targets]) and the one setup settlement that is waiting
* for its road ([selectedVertex]), and it reports taps. It knows no rules; what
* a tap *means* is decided by [CatanInteraction]. Where things are is decided by
* [BoardLayout], and what they look like by [CatanStyle], so this file is only
* the glue between the three.
*
* To change how a piece is drawn, edit the matching `draw…` function below. To
* change a colour or a proportion, edit [CatanStyle] instead.
*/
@Composable
fun CatanBoardView(
state: CatanState,
targets: BoardTargets,
selectedVertex: Int?,
onVertexTap: (Int) -> Unit,
onEdgeTap: (Int) -> Unit,
onHexTap: (Int) -> Unit,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
) {
val measurer = rememberTextMeasurer()
var size by remember { mutableStateOf(IntSize.Zero) }
val layout = remember(size) {
if (size.width > 0 && size.height > 0) {
CatanGeometry.fit(size.width.toFloat(), size.height.toFloat())
} else {
null
}
}
// Held in State so the gesture detector, which is only restarted when the
// layout or targets change, always calls the latest callbacks.
val vertexTap by rememberUpdatedState(onVertexTap)
val edgeTap by rememberUpdatedState(onEdgeTap)
val hexTap by rememberUpdatedState(onHexTap)
Canvas(
modifier = modifier
.aspectRatio(CatanGeometry.ASPECT)
.onSizeChanged { size = it }
.semantics { contentDescription = "Katan island board" }
.pointerInput(layout, targets, style) {
detectTapGestures { tap ->
val board = layout ?: return@detectTapGestures
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.edges.isNotEmpty() ->
board.nearestEdge(at, reach, targets.edges)?.let { edgeTap(it) }
targets.hexes.isNotEmpty() ->
// A hex is big: accept a tap anywhere near its middle.
board.nearestHex(at, board.radius * 0.95f, targets.hexes)?.let { hexTap(it) }
}
}
},
) {
val board = layout ?: return@Canvas
drawRect(color = style.sea)
drawPorts(state, board, style, measurer)
drawHexes(state, board, style, measurer)
drawRobber(state, board, style)
drawRoads(state, board, style)
drawEdgeTargets(targets, board, style)
drawBuildings(state, board, style)
drawVertexTargets(targets, selectedVertex, board, style)
drawHexTargets(targets, board, style)
}
}
// ---------------------------------------------------------------------------
// Terrain and tokens
// ---------------------------------------------------------------------------
private fun DrawScope.drawHexes(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
for (hex in 0 until CatanBoard.HEX_COUNT) {
val centre = board.hexCenter(hex)
val corners = board.hexCorners(hex).map { insetToward(it, centre, style.hexInset) }
val path = polygon(corners)
drawPath(path, color = style.terrain.getValue(state.terrains[hex]))
drawPath(path, color = style.hexEdge, style = Stroke(width = board.radius * 0.03f))
val number = state.numbers[hex]
if (number != 0) drawToken(number, centre, board, style, measurer)
}
}
private fun DrawScope.drawToken(
number: Int,
at: Pt,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
val radius = board.radius * style.tokenRadius
drawCircle(color = style.token, radius = radius, center = at.offset())
drawCircle(
color = style.hexEdge,
radius = radius,
center = at.offset(),
style = Stroke(width = board.radius * 0.02f),
)
val hot = number == 6 || number == 8
drawCentered(
measurer = measurer,
text = number.toString(),
at = Pt(at.x, at.y - radius * 0.12f),
fontPx = board.radius * style.tokenText,
color = if (hot) style.tokenHotInk else style.tokenInk,
weight = if (hot) FontWeight.ExtraBold else FontWeight.Bold,
)
// One pip per thirty-sixth of the odds, so the likelier numbers read at a glance.
val pips = CatanBoard.numberOdds(number)
val pipRadius = radius * 0.075f
val gap = pipRadius * 2.6f
val startX = at.x - gap * (pips - 1) / 2f
for (i in 0 until pips) {
drawCircle(
color = if (hot) style.tokenHotInk else style.tokenInk,
radius = pipRadius,
center = Offset(startX + i * gap, at.y + radius * 0.52f),
)
}
}
private fun DrawScope.drawRobber(state: CatanState, board: BoardLayout, style: CatanStyle) {
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
val y = hex.y + board.radius * 0.3f
val s = board.radius * style.robberSize
val pawn = Path().apply {
moveTo(x - s * 0.55f, y + s * 0.9f)
lineTo(x - s * 0.25f, y - s * 0.15f)
lineTo(x + s * 0.25f, y - s * 0.15f)
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))
drawCircle(
color = Color.White.copy(alpha = 0.7f),
radius = s * 0.38f,
center = Offset(x, y - s * 0.42f),
style = Stroke(width = s * 0.07f),
)
}
// ---------------------------------------------------------------------------
// Harbours
// ---------------------------------------------------------------------------
private fun DrawScope.drawPorts(
state: CatanState,
board: BoardLayout,
style: CatanStyle,
measurer: TextMeasurer,
) {
for (port in state.ports) {
val anchor = board.portAnchor(port.edge)
val (a, b) = board.edgeEnds(port.edge)
val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk
// Two short piers from the marker to the vertices that can use the harbour.
for (end in listOf(a, b)) {
drawLine(
color = style.portMarker.copy(alpha = 0.9f),
start = anchor.offset(),
end = end.offset(),
strokeWidth = board.radius * 0.07f,
cap = StrokeCap.Round,
)
}
val radius = board.radius * style.portRadius
drawCircle(color = style.portMarker, radius = radius, center = anchor.offset())
drawCircle(
color = tint,
radius = radius,
center = anchor.offset(),
style = Stroke(width = board.radius * 0.07f),
)
drawCentered(
measurer = measurer,
text = "${port.ratio}:1",
at = anchor,
fontPx = board.radius * style.portText,
color = style.portInk,
weight = FontWeight.Bold,
)
}
}
// ---------------------------------------------------------------------------
// Pieces
// ---------------------------------------------------------------------------
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (edge in 0 until CatanBoard.EDGE_COUNT) {
val owner = state.roads[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
// Pulled in from the ends so adjacent roads do not fuse into a blob.
val from = lerp(a, b, 0.12f)
val to = lerp(a, b, 0.88f)
val width = board.radius * style.roadWidth
drawLine(
color = style.pieceOutline,
start = from.offset(),
end = to.offset(),
strokeWidth = width * 1.45f,
cap = StrokeCap.Round,
)
drawLine(
color = style.seats[owner % style.seats.size],
start = from.offset(),
end = to.offset(),
strokeWidth = width,
cap = StrokeCap.Round,
)
}
}
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
val building = state.buildings[vertex] ?: continue
val at = board.vertex(vertex)
val fill = style.seats[building.owner % style.seats.size]
val path = when (building.type) {
BuildingType.SETTLEMENT -> housePath(at, board.radius * style.settlementSize)
BuildingType.CITY -> cityPath(at, board.radius * style.citySize * 0.8f)
}
drawPath(path, color = fill)
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.045f))
}
}
/** One gable: a settlement. [s] is its half-width. */
private fun housePath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s, at.y + s * 0.7f)
lineTo(at.x - s, at.y - s * 0.1f)
lineTo(at.x, at.y - s)
lineTo(at.x + s, at.y - s * 0.1f)
lineTo(at.x + s, at.y + s * 0.7f)
close()
}
/** Two gables, the taller on the left: a city. [s] is the unit its outline is scaled by. */
private fun cityPath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s, at.y + s * 0.7f)
lineTo(at.x - s, at.y - s * 0.5f)
lineTo(at.x - s * 0.45f, at.y - s * 1.1f)
lineTo(at.x + s * 0.1f, at.y - s * 0.5f)
lineTo(at.x + s * 0.1f, at.y - s * 0.1f)
lineTo(at.x + s * 0.55f, at.y - s * 0.55f)
lineTo(at.x + s * 1.1f, at.y - s * 0.1f)
lineTo(at.x + s * 1.1f, at.y + s * 0.7f)
close()
}
// ---------------------------------------------------------------------------
// Tap targets
// ---------------------------------------------------------------------------
private fun DrawScope.drawVertexTargets(
targets: BoardTargets,
selected: Int?,
board: BoardLayout,
style: CatanStyle,
) {
val radius = board.radius * style.targetRadius
for (vertex in targets.vertices) {
val at = board.vertex(vertex).offset()
drawCircle(color = style.target.copy(alpha = 0.85f), radius = radius, center = at)
drawCircle(
color = style.pieceOutline,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.04f),
)
}
if (selected != null) {
val at = board.vertex(selected).offset()
drawCircle(
color = style.targetSelected,
radius = radius * 1.6f,
center = at,
style = Stroke(width = board.radius * 0.09f),
)
}
}
private fun DrawScope.drawEdgeTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (edge in targets.edges) {
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.15f)
val to = lerp(a, b, 0.85f)
drawLine(
color = style.pieceOutline,
start = from.offset(),
end = to.offset(),
strokeWidth = board.radius * style.roadWidth * 1.5f,
cap = StrokeCap.Round,
)
drawLine(
color = style.target.copy(alpha = 0.9f),
start = from.offset(),
end = to.offset(),
strokeWidth = board.radius * style.roadWidth,
cap = StrokeCap.Round,
)
}
}
private fun DrawScope.drawHexTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (hex in targets.hexes) {
val centre = board.hexCenter(hex)
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.08f) })
drawPath(path, color = style.target.copy(alpha = 0.38f))
drawPath(path, color = style.target, style = Stroke(width = board.radius * 0.07f))
}
}
// ---------------------------------------------------------------------------
// Drawing helpers
// ---------------------------------------------------------------------------
private fun Pt.offset(): Offset = Offset(x, y)
private fun lerp(a: Pt, b: Pt, t: Float): Pt = Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
/** [p] moved [fraction] of the way toward [centre]. */
private fun insetToward(p: Pt, centre: Pt, fraction: Float): Pt = lerp(p, centre, fraction)
private fun polygon(points: List<Pt>): Path = Path().apply {
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
close()
}
private fun DrawScope.drawCentered(
measurer: TextMeasurer,
text: String,
at: Pt,
fontPx: Float,
color: Color,
weight: FontWeight,
) {
val layout = measurer.measure(
text = text,
style = TextStyle(fontSize = fontPx.toSp(), fontWeight = weight, color = color),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
)
}
@@ -0,0 +1,531 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.foundation.rememberScrollState
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.CatanMove
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.Discard
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.TradeOffer
/*
* Every Katan dialog. Each is a stateless composable apart from the selection it
* is helping the player make (cards picked, a partner chosen); the move it ends
* in is reported through a lambda and sent by the screen. What is *allowed* is
* always asked of [CatanInteraction], never decided here.
*/
// ---------------------------------------------------------------------------
// Shared pieces
// ---------------------------------------------------------------------------
/** A resource tile with a minus and a plus, for choosing how many cards. */
@Composable
private fun ResourceCounter(
resource: Resource,
count: Int,
max: Int,
onChange: (Int) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
ResourceTile(resource, 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<Resource, Int>.asCards(): List<Resource> =
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
// ---------------------------------------------------------------------------
// Discarding on a seven
// ---------------------------------------------------------------------------
/**
* A seven was rolled and this player holds more than seven cards: choose which
* half to give up. Not dismissable — the game is waiting on it.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanDiscardDialog(
held: Map<Resource, Int>,
quota: Int,
legal: List<CatanMove>,
onSubmit: (Discard) -> Unit,
) {
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
val selection = picked.asCards()
val move = CatanInteraction.discardMove(legal, selection)
AlertDialog(
onDismissRequest = {},
title = { Text("Discard $quota cards") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"A seven was rolled. Choose $quota cards to give up " +
"(${selection.size} of $quota chosen).",
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
val have = held[resource] ?: 0
if (have > 0) {
ResourceCounter(
resource = resource,
count = picked[resource] ?: 0,
max = if (selection.size < quota) have else (picked[resource] ?: 0),
onChange = { n -> picked = picked + (resource to n.coerceIn(0, have)) },
)
}
}
}
}
},
confirmButton = {
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text("Discard") }
},
)
}
// ---------------------------------------------------------------------------
// The robber
// ---------------------------------------------------------------------------
/** More than one player can be robbed from this hex: choose whom. */
@Composable
fun CatanVictimDialog(
victims: List<Int>,
seats: CatanSeats,
onPick: (Int) -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Steal from…") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
victims.forEach { seat ->
OutlinedButton(onClick = { onPick(seat) }, modifier = Modifier.fillMaxWidth()) {
Text(seats.label(seat))
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
// ---------------------------------------------------------------------------
// Development cards
// ---------------------------------------------------------------------------
/**
* The player's development cards. A Knight or Road Building plays at once; a
* Monopoly or Year of Plenty asks for its resource choice in a second step.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanPlayCardDialog(
hand: List<DevCardInHand>,
onPlay: (CatanMove) -> Unit,
onCancel: () -> Unit,
) {
var chosen by remember { mutableStateOf<DevCardInHand?>(null) }
var picked by remember { mutableStateOf(emptyList<Resource>()) }
val card = chosen
AlertDialog(
onDismissRequest = onCancel,
title = { Text(card?.let { CatanText.devCard(it.card.type) } ?: "Development cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
when {
card == null -> {
if (hand.isEmpty()) Text("You have no development cards.")
hand.forEach { entry -> DevCardRow(entry, onPlay) { chosen = it } }
}
card.card.type == DevCardType.MONOPOLY -> {
Text("Name a resource. Every other player hands over all of it.")
val choices = CatanInteraction.monopolyChoices(card.plays)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
val move = choices[resource]
ResourceTile(
resource = resource,
count = 0,
enabled = move != null,
onClick = { move?.let(onPlay) },
)
}
}
}
card.card.type == DevCardType.YEAR_OF_PLENTY -> {
Text("Take two resource cards from the bank (${picked.size} of 2 chosen).")
val available = CatanInteraction.plentyAvailable(card.plays, picked)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
ResourceTile(
resource = resource,
count = picked.count { it == resource },
selected = resource in picked,
enabled = resource in available,
onClick = { picked = picked + resource },
)
}
}
if (picked.isNotEmpty()) {
TextButton(onClick = { picked = emptyList() }) { Text("Clear") }
}
}
}
}
},
confirmButton = {
if (card != null && card.card.type == DevCardType.YEAR_OF_PLENTY) {
val move = CatanInteraction.plentyMove(card.plays, picked)
Button(onClick = { move?.let(onPlay) }, enabled = move != null) { Text("Take") }
}
},
dismissButton = {
TextButton(
onClick = {
if (card == null) {
onCancel()
} else {
chosen = null
picked = emptyList()
}
},
) { Text(if (card == null) CatanText.CANCEL else "Back") }
},
)
}
@Composable
private fun DevCardRow(
entry: DevCardInHand,
onPlay: (CatanMove) -> Unit,
onChoose: (DevCardInHand) -> Unit,
) {
val type = entry.card.type
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(CatanText.devCard(type), fontWeight = FontWeight.Bold)
Text(CatanText.devCardHelp(type), style = MaterialTheme.typography.bodySmall)
}
when {
type == DevCardType.VICTORY_POINT -> Text("Hidden", style = MaterialTheme.typography.labelSmall)
!entry.playable -> Text("Not now", style = MaterialTheme.typography.labelSmall)
entry.plays.size == 1 && (entry.plays[0] is PlayKnight || entry.plays[0] is PlayRoadBuilding) ->
Button(onClick = { onPlay(entry.plays[0]) }) { Text("Play") }
else -> Button(onClick = { onChoose(entry) }) { Text("Play…") }
}
}
}
// ---------------------------------------------------------------------------
// Trading
// ---------------------------------------------------------------------------
private enum class TradeTab { BANK, PLAYER }
/**
* Trade with the bank at the best ratio the player has, or offer cards to one
* other player. Offers are free-form, so they are checked with the engine's
* structural rule ([CatanInteraction.offerAllowed]) rather than looked up in
* the legal list.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanTradeDialog(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (MaritimeTrade) -> Unit,
onOffer: (OfferTrade) -> Unit,
onCancel: () -> Unit,
) {
var tab by remember { mutableStateOf(TradeTab.BANK) }
var bankGive by remember { mutableStateOf<Resource?>(null) }
var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
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)) {
TabButton(CatanText.TRADE_WITH_BANK, tab == TradeTab.BANK) { tab = TradeTab.BANK }
TabButton(CatanText.TRADE_WITH_PLAYER, tab == TradeTab.PLAYER) { tab = TradeTab.PLAYER }
}
if (tab == TradeTab.BANK) {
val trades = CatanInteraction.bankTrades(legal)
if (trades.isEmpty()) {
Text("You do not hold enough of any one resource 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 { resource ->
val ratio = CatanInteraction.bankRatio(state, seat, resource)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ResourceTile(
resource = resource,
count = ratio,
selected = bankGive == resource,
onClick = { bankGive = resource },
)
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 { resource ->
ResourceTile(
resource = resource,
count = 1,
onClick = { onBank(MaritimeTrade(chosen, resource)) },
)
}
}
}
}
} else {
Text("Offer to:", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(0 until state.playerCount).filter { it != seat }.forEach { other ->
TabButton(seats.label(other), partner == other) { partner = other }
}
}
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
CounterRow(
counts = give,
limit = { state.resources[seat][it] ?: 0 },
onChange = { give = it },
)
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
CounterRow(counts = get, limit = { 9 }, onChange = { get = it })
}
}
},
confirmButton = {
if (tab == TradeTab.PLAYER) {
val to = partner
val giveCards = give.asCards()
val getCards = get.asCards()
val allowed = to != null &&
CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
Button(
onClick = { if (to != null) onOffer(OfferTrade(to, giveCards, getCards)) },
enabled = allowed,
) { Text(CatanText.SEND_OFFER) }
}
},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CounterRow(
counts: Map<Resource, Int>,
limit: (Resource) -> Int,
onChange: (Map<Resource, Int>) -> Unit,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
ResourceCounter(
resource = resource,
count = counts[resource] ?: 0,
max = limit(resource),
onChange = { n -> onChange(counts + (resource to n.coerceIn(0, limit(resource)))) },
)
}
}
}
@Composable
private fun TabButton(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. */
@Composable
fun CatanTradeResponseDialog(
offer: TradeOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("Trade offer") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
CatanText.tradeSummary(
by = seats.label(offer.by),
to = "you",
give = offer.give,
get = 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) } },
)
}
// ---------------------------------------------------------------------------
// Ending
// ---------------------------------------------------------------------------
/** The game is over: who won, and what everyone finished with. */
@Composable
fun CatanGameOverDialog(
state: CatanState,
seats: CatanSeats,
onExit: () -> Unit,
onLook: () -> Unit,
) {
val points = (0 until state.playerCount).map { seat ->
// The winner's hidden victory-point cards are revealed by winning; the
// host's copy of the state has them, a client's has only its own.
seat to if (seat == seats.me) CatanRules.victoryPoints(state, seat)
else CatanRules.publicVictoryPoints(state)[seat]
}
AlertDialog(
onDismissRequest = onLook,
title = { Text(CatanText.winnerHeadline(state, seats)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
points.sortedByDescending { it.second }.forEach { (seat, vp) ->
Text("${seats.label(seat)}: $vp points")
}
}
},
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
dismissButton = { TextButton(onClick = onLook) { Text(CatanText.LOOK_AT_BOARD) } },
)
}
@Composable
fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
AlertDialog(
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = {
Text(
"This game ends here and the position is not saved. Anyone else " +
"at the table is told you have left.",
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@Composable
fun CatanAbandonedDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
AlertDialog(
onDismissRequest = onStay,
title = { Text("The game has ended") },
text = { Text("$notice There are no more moves to play.") },
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
dismissButton = { TextButton(onClick = onStay) { Text(CatanText.LOOK_AT_BOARD) } },
)
}
@@ -0,0 +1,123 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanBoard
import kotlin.math.sqrt
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
data class Pt(val x: Float, val y: Float)
/**
* Where the island sits on screen: the one place the engine's integer lattice
* (see [CatanBoard]) becomes pixels, and pixels become "which vertex / edge /
* hex did the player tap".
*
* Pure and Android-free, so it is unit-tested (`CatanGeometryTest`). The drawing
* code ([CatanBoardView]) and the tap handling both go through this, which is
* what keeps what is drawn and what is tappable from ever disagreeing.
*
* Lattice to pixel: a hex of circumradius `R` is pointy-top, so one lattice x-unit
* is `R·√3/2` and one lattice y-unit is `R/2`, and lattice +Y points up the board
* while pixel +y points down.
*/
class BoardLayout(
val width: Float,
val height: Float,
/** Circumradius of one hex, in pixels. */
val radius: Float,
) {
private val centreX = width / 2f
private val centreY = height / 2f
private val unitX = radius * SQRT3 / 2f
private val unitY = radius / 2f
fun point(latticeX: Int, latticeY: Int): Pt =
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
fun hexCenter(hex: Int): Pt {
val (lx, ly) = CatanBoard.hexCenter(hex)
return point(lx, ly)
}
fun vertex(vertex: Int): Pt {
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
return point(lx, ly)
}
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) }
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return vertex(a) to vertex(b)
}
fun edgeMidpoint(edge: Int): Pt {
val (a, b) = edgeEnds(edge)
return Pt((a.x + b.x) / 2f, (a.y + b.y) / 2f)
}
/**
* Where a harbour's marker is drawn: just off the coast, straight out from
* the middle of its edge. The marker is joined to the edge's two vertices by
* the board view, which is what shows which settlements can use it.
*/
fun portAnchor(edge: Int): Pt {
val mid = edgeMidpoint(edge)
val dx = mid.x - centreX
val dy = mid.y - centreY
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
}
/** The nearest of [among] within [reach] pixels of [at], or null. */
fun nearestVertex(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { vertex(it) }
fun nearestEdge(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { edgeMidpoint(it) }
fun nearestHex(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { hexCenter(it) }
private fun nearest(at: Pt, reach: Float, among: Collection<Int>, position: (Int) -> Pt): Int? {
var best: Int? = null
var bestDistance = reach * reach
for (candidate in among) {
val p = position(candidate)
val dx = p.x - at.x
val dy = p.y - at.y
val d = dx * dx + dy * dy
if (d <= bestDistance) {
best = candidate
bestDistance = d
}
}
return best
}
}
object CatanGeometry {
/** Lattice extents of the island: x spans -5..5, y spans -8..8. */
private const val LATTICE_X_UNITS = 10
private const val LATTICE_Y_UNITS = 16
/** Empty space round the island, in hex radii, for the sea and the harbours. */
const val MARGIN = 1.15f
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
/** Width ÷ height of the board including its margin, for sizing its box. */
val ASPECT: Float = boardWidthInRadii / boardHeightInRadii
/** The largest layout that fits [width] × [height] pixels. */
fun fit(width: Float, height: Float): BoardLayout {
val radius = minOf(width / boardWidthInRadii, height / boardHeightInRadii)
return BoardLayout(width, height, radius)
}
}
private const val SQRT3 = 1.7320508f
/** How far past the coast a harbour marker sits, in hex radii. */
private const val PORT_OFFSET = 0.62f
@@ -0,0 +1,273 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AcceptTrade
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.BuyDevCard
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.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.EndTurn
import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.PlayYearOfPlenty
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
/** What a player can ask to build by tapping the board. */
enum class BuildKind { ROAD, SETTLEMENT, CITY }
/** The parts of the board the player may tap right now. Everything else is inert. */
data class BoardTargets(
val vertices: Set<Int> = emptySet(),
val edges: Set<Int> = emptySet(),
val hexes: Set<Int> = emptySet(),
) {
val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty()
}
/** What a tap on the board amounts to. */
sealed interface TapOutcome {
/** Nothing: the tap landed somewhere that is not a legal target. */
data object Ignored : TapOutcome
/** Send this move to the table. */
data class Submit(val move: CatanMove) : TapOutcome
/** Setup: remember this settlement; the road that goes with it is tapped next. */
data class PickSetupVertex(val vertex: Int) : TapOutcome
/** The robber's hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
}
/**
* One development card in the local player's hand, and what can be done with it
* right now. [plays] is empty when it cannot be played this turn (bought this
* turn, a card has already been played, a victory-point card, or it is not the
* player's turn).
*/
data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
val playable: Boolean get() = plays.isNotEmpty()
}
/**
* The rules of *tapping*: given the game state, the moves the engine says are
* legal, and what the player has selected, what can be tapped and what a tap does.
*
* Pure functions with no Android and no Compose, so the whole interaction model is
* unit-tested (`CatanInteractionTest`). The screen holds the two bits of UI state
* ([BuildKind] and the chosen setup settlement) and passes them in; it contains no
* game rules of its own. **Anything that decides whether a tap is allowed belongs
* here, never in a composable** — the engine's [legal] list is the single source
* of truth, so the UI can never offer a move the rules would refuse.
*/
object CatanInteraction {
// --- Board ---------------------------------------------------------------
fun targets(
state: CatanState,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
return if (setupVertex == null) {
BoardTargets(vertices = setup.map { it.vertex }.toSet())
} else {
BoardTargets(edges = setup.filter { it.vertex == setupVertex }.map { it.edge }.toSet())
}
}
val robber = legal.filterIsInstance<MoveRobber>()
if (robber.isNotEmpty()) {
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
}
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
BuildKind.CITY ->
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
null -> BoardTargets()
}
}
fun onVertexTap(
vertex: Int,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
): TapOutcome {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
// Re-picking is allowed until the road is chosen, so a misplaced
// tap does not commit the settlement.
return if (setup.any { it.vertex == vertex }) {
TapOutcome.PickSetupVertex(vertex)
} else {
TapOutcome.Ignored
}
}
val move: CatanMove = when (building) {
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
BuildKind.CITY -> BuildCity(vertex)
else -> return TapOutcome.Ignored
}
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onEdgeTap(
edge: Int,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
): 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
}
// 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
val move = BuildRoad(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onHexTap(hex: Int, legal: List<CatanMove>): TapOutcome {
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
return when {
moves.isEmpty() -> TapOutcome.Ignored
moves.size == 1 -> TapOutcome.Submit(moves.first())
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
}
}
// --- Action bar ------------------------------------------------------------
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
fun canBuyDevCard(legal: List<CatanMove>): Boolean = legal.any { it is BuyDevCard }
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
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)
}
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
legal.any { it is AcceptTrade } to legal.any { it is DeclineTrade }
// --- Development cards -----------------------------------------------------
/** [seat]'s development cards, each with the plays the engine currently allows. */
fun devHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<DevCardInHand> =
state.devHands.getOrNull(seat).orEmpty().map { card ->
DevCardInHand(card, legal.filter { playOf(it) == card.id })
}.sortedWith(compareBy({ it.card.type.ordinal }, { it.card.id }))
private fun playOf(move: CatanMove): Int? = when (move) {
is PlayKnight -> move.card
is PlayRoadBuilding -> move.card
is PlayMonopoly -> move.card
is PlayYearOfPlenty -> move.card
else -> null
}
/** The Monopoly moves for one card, by the resource each would name. */
fun monopolyChoices(plays: List<CatanMove>): Map<Resource, PlayMonopoly> =
plays.filterIsInstance<PlayMonopoly>().associateBy { it.resource }
/** The Year of Plenty move that takes exactly [take], if the engine allows it. */
fun plentyMove(plays: List<CatanMove>, take: List<Resource>): PlayYearOfPlenty? {
val want = take.groupingBy { it }.eachCount()
return plays.filterIsInstance<PlayYearOfPlenty>().firstOrNull {
it.take.groupingBy { r -> r }.eachCount() == want
}
}
/**
* Resources a Year of Plenty can still be asked for, given what is already
* [picked]: those that complete some take the engine allows. Empty once the
* two cards are chosen.
*/
fun plentyAvailable(plays: List<CatanMove>, picked: List<Resource>): Set<Resource> {
val chosen = picked.groupingBy { it }.eachCount()
val available = mutableSetOf<Resource>()
for (play in plays.filterIsInstance<PlayYearOfPlenty>()) {
if (!chosen.all { (resource, n) -> play.take.count { it == resource } >= n }) continue
val remaining = play.take.toMutableList()
picked.forEach { remaining.remove(it) }
available += remaining
}
return available
}
// --- Discarding -----------------------------------------------------------
/** How many cards a player holding [held] must discard on a seven. */
fun discardQuota(held: Int): Int = held / 2
/** The discard move that gives up exactly [selection], if the engine allows it. */
fun discardMove(legal: List<CatanMove>, selection: List<Resource>): Discard? {
val want = selection.groupingBy { it }.eachCount()
return legal.filterIsInstance<Discard>().firstOrNull {
it.cards.groupingBy { r -> r }.eachCount() == want
}
}
// --- Trading ---------------------------------------------------------------
/** Bank trades on offer: what can be given, and for what. */
fun bankTrades(legal: List<CatanMove>): Map<Resource, List<Resource>> =
legal.filterIsInstance<MaritimeTrade>()
.groupBy({ it.give }, { it.get })
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
.toSortedMap(compareBy<Resource> { it.ordinal })
/** What it costs [seat] to give one [resource] to the bank: 4, 3 or 2 cards. */
fun bankRatio(state: CatanState, seat: Int, resource: Resource): Int =
CatanRules.bestPortRatio(state, seat, resource)
/**
* Whether [seat] can offer [give] for [get] to [to] right now. Uses the
* engine's own structural check, since offers are not in the enumerated
* move list.
*/
fun offerAllowed(
state: CatanState,
seat: Int,
to: Int,
give: List<Resource>,
get: List<Resource>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get))
}
@@ -0,0 +1,337 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.runtime.Composable
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.Resource
/*
* Stateless building blocks for the Katan screen.
*
* Every composable here takes plain values and reports events through lambdas;
* none of them holds game state or touches the controller, so each can be
* restyled, re-laid-out or replaced on its own. Colours and proportions come
* from [CatanStyle] and wording from [CatanText].
*/
// ---------------------------------------------------------------------------
// Resource tile
// ---------------------------------------------------------------------------
/**
* One resource as a coloured tile with its count. The hand, the discard picker
* and the trade pickers all use this, so a resource looks the same everywhere.
*/
@Composable
fun ResourceTile(
resource: Resource,
count: Int,
modifier: Modifier = Modifier,
selected: Boolean = false,
enabled: Boolean = true,
style: CatanStyle = LocalCatanStyle.current,
onClick: (() -> Unit)? = null,
) {
val shape = RoundedCornerShape(10.dp)
Column(
modifier = modifier
.defaultMinSize(minWidth = 56.dp)
.clip(shape)
.background(style.resource.getValue(resource).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 = style.resourceInk,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Text(
text = CatanText.resource(resource),
color = style.resourceInk,
fontSize = 11.sp,
maxLines = 1,
)
}
}
/** The local player's cards: one tile per resource, in a row that wraps. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanHand(
state: CatanState,
seat: Int,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Resource.entries.forEach { resource ->
ResourceTile(resource, state.resources[seat][resource] ?: 0)
}
}
}
// ---------------------------------------------------------------------------
// Players
// ---------------------------------------------------------------------------
/**
* One player's public standing: colour, name, points, how many cards they hold
* (not which), development cards, knights and their longest road.
*
* [points] is passed in rather than computed here because the local player's
* own count includes hidden victory-point cards and everyone else's does not;
* the screen decides which.
*/
@Composable
fun CatanPlayerCard(
state: CatanState,
seat: Int,
seats: CatanSeats,
points: Int,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
) {
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
val shape = RoundedCornerShape(12.dp)
Column(
modifier = modifier
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.border(
width = if (onTurn) 3.dp else 1.dp,
color = if (onTurn) MaterialTheme.colorScheme.primary else Color(0x22000000),
shape = shape,
)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(
modifier = Modifier
.size(14.dp)
.clip(CircleShape)
.background(style.seats[seat % style.seats.size])
.border(1.dp, style.pieceOutline, CircleShape),
)
Text(
text = seats.label(seat),
fontWeight = FontWeight.Bold,
maxLines = 1,
style = MaterialTheme.typography.labelLarge,
)
}
Text(
text = "$points VP",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
style = MaterialTheme.typography.bodySmall,
)
val awards = buildList {
if (state.longestRoadOwner == seat) add("Longest road")
if (state.largestArmyOwner == seat) add("Largest army")
}
if (awards.isNotEmpty()) {
Text(
text = awards.joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
}
}
// ---------------------------------------------------------------------------
// Dice
// ---------------------------------------------------------------------------
/** The two dice as last rolled, with their total. Blank until the first roll. */
@Composable
fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
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 ->
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = value?.toString() ?: "–",
color = Color(0xFF1C1B1F),
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
textAlign = TextAlign.Center,
)
}
}
if (dice.size == 2) {
Text(
text = "= ${dice.sum()}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
data class CatanActions(
val canRoll: Boolean = false,
val showTurnActions: Boolean = false,
val canBuild: Set<BuildKind> = emptySet(),
val building: BuildKind? = null,
val canBuyCard: Boolean = false,
val canPlayCard: Boolean = false,
val canTrade: Boolean = false,
val canEndTurn: Boolean = false,
val canEndFreeRoads: Boolean = false,
/** A setup settlement is chosen and a different one may still be picked. */
val canRepickSetup: Boolean = false,
)
/** The buttons for whatever the player can do now. Pure display: every press is a lambda. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanActionBar(
actions: CatanActions,
onRoll: () -> Unit,
onBuild: (BuildKind?) -> Unit,
onBuyCard: () -> Unit,
onPlayCard: () -> Unit,
onTrade: () -> Unit,
onEndTurn: () -> Unit,
onEndFreeRoads: () -> Unit,
onRepickSetup: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
if (actions.canRepickSetup) {
OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
}
if (actions.showTurnActions) {
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) { Text(CatanText.PLAY_CARD) }
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { Text(CatanText.TRADE) }
}
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
}
}
@Composable
private fun BuildButton(
kind: BuildKind,
label: String,
cost: String,
actions: CatanActions,
onBuild: (BuildKind?) -> Unit,
) {
val enabled = kind in actions.canBuild
val active = actions.building == kind
val onClick = { onBuild(if (active) null else kind) }
if (active) {
FilledTonalButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
} else {
OutlinedButton(onClick = onClick, enabled = enabled) { LabelWithCost(label, cost) }
}
}
@Composable
private fun LabelWithCost(label: String, cost: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(label)
Text(cost, fontSize = 9.sp, maxLines = 1)
}
}
// ---------------------------------------------------------------------------
// Log
// ---------------------------------------------------------------------------
/** The most recent happenings, newest last. Scrolls. */
@Composable
fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
if (state.log.isEmpty()) {
Text(CatanText.NO_LOG, style = MaterialTheme.typography.bodySmall, modifier = modifier)
return
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
state.log.takeLast(40).forEach {
Text(text = it, style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -0,0 +1,133 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
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.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/*
* Android Studio previews for the Katan UI. They exist so the look can be
* iterated on without starting a match: open this file, open the Preview pane,
* and edit CatanStyle / CatanText / the panels. Each preview draws a real
* position (the computer plays itself for a while), not a mock.
*
* Nothing here ships in a behaviour sense: previews are only compiled for tooling.
*/
private val previewJson = Json { encodeDefaults = true }
/** A real game, played forward by the computer for [moves] moves, for previewing. */
private fun sampleState(players: Int = 4, moves: Int = 90, seed: Long = 11L): CatanState {
val config = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
optionsJson = previewJson.encodeToString(CatanOptions(playerCount = players)),
seed = seed,
)
val ai = CatanAi()
var state = CatanRules.initialState(config)
repeat(moves) {
if (state.phase == CatanPhase.GAME_OVER) return state
val seat = CatanRules.currentSeat(state) ?: return state
val legal = CatanRules.legalMoves(state, seat)
if (legal.isEmpty()) return state
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
return state
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardPreview() {
val state = remember { sampleState() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
CatanBoardView(
state = state,
targets = BoardTargets(),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardDarkPreview() {
val state = remember { sampleState() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = true)) {
CatanBoardView(
state = state,
targets = BoardTargets(),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 360)
@Composable
private fun BoardWithTargetsPreview() {
val state = remember { sampleState(moves = 6) }
val legal = remember(state) { CatanRules.currentSeat(state)?.let { CatanRules.legalMoves(state, it) }.orEmpty() }
CompositionLocalProvider(LocalCatanStyle provides catanStyle(dark = false)) {
CatanBoardView(
state = state,
targets = CatanInteraction.targets(state, legal, building = null, setupVertex = null),
selectedVertex = null,
onVertexTap = {},
onEdgeTap = {},
onHexTap = {},
)
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 560)
@Composable
private fun PanelsPreview() {
val state = remember { sampleState() }
val seats = CatanSeats(me = 0, names = listOf("You", "Ann", "Bo", "Cy"))
val points = remember(state) { CatanRules.publicVictoryPoints(state) }
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
(0 until state.playerCount).forEach { seat ->
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f))
}
}
CatanHand(state, seat = 0)
CatanDice(state.lastDice)
CatanActionBar(
actions = CatanActions(
showTurnActions = true,
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
building = BuildKind.ROAD,
canBuyCard = true,
canTrade = true,
canEndTurn = true,
),
onRoll = {}, onBuild = {}, onBuyCard = {}, onPlayCard = {}, onTrade = {},
onEndTurn = {}, onEndFreeRoads = {}, onRepickSetup = {},
)
}
}
@@ -0,0 +1,382 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.unit.dp
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.BuyDevCard
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.EndFreeRoads
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
/**
* Katan, on the island.
*
* This composable is only wiring. It reads the match from the controller, holds
* the handful of purely visual choices (which build mode is armed, which setup
* settlement is waiting for its road, which dialog is open), and hands
* everything else to single-purpose pieces:
*
* | Concern | Where it lives |
* | ------------------------------ | -------------------------- |
* | Colours, sizes, shapes | [CatanStyle] |
* | Every word on screen | [CatanText] |
* | Island → pixels, taps → hexes | [CatanGeometry] |
* | What is tappable, what a tap does | [CatanInteraction] |
* | Drawing the island | [CatanBoardView] |
* | Cards, players, dice, buttons | `CatanPanels.kt` |
* | Every dialog | `CatanDialogs.kt` |
*
* There are no game rules here: the engine's legal-move list is the only source
* of what the player may do, so the screen cannot offer a move the rules refuse.
* See `docs/UI-catan.md` for how to adapt the look and feel.
*/
@Composable
fun CatanScreen(
controller: MatchController<CatanState, CatanMove>,
localSeat: Int,
onExit: () -> Unit,
seatNames: List<String> = emptyList(),
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
val notice by controller.notice.collectAsState()
// Purely visual state. Anything that affects the game goes through submit().
var building by remember { mutableStateOf<BuildKind?>(null) }
var setupVertex by remember { mutableStateOf<Int?>(null) }
var victimChoice by remember { mutableStateOf<TapOutcome.PickRobberVictim?>(null) }
var showCards by remember { mutableStateOf(false) }
var showTrade by remember { mutableStateOf(false) }
var confirmingEnd by remember { mutableStateOf(false) }
var resultDismissed by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
val style = remember(dark) { catanStyle(dark) }
val seats = remember(localSeat, seatNames) { CatanSeats(localSeat, seatNames) }
// The engine's list changes after every move; drop any selection it no
// longer supports (a build mode the player can no longer afford, a setup
// settlement that is no longer pending).
LaunchedEffect(legal) {
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
setupVertex = null
}
if (legal.isEmpty()) {
showCards = false
showTrade = false
victimChoice = null
}
}
val submit: (CatanMove) -> Unit = { move ->
controller.submit(move)
building = null
setupVertex = null
victimChoice = null
showCards = false
showTrade = false
}
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text(CatanText.END_GAME) }
}
val current = state
if (current == null) {
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
abandoned?.let { CatanAbandonedDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) CatanEndGameDialog(onExit) { confirmingEnd = false }
}
return
}
val targets = CatanInteraction.targets(current, legal, building, setupVertex)
val devHand = CatanInteraction.devHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions(
canRoll = CatanInteraction.canRoll(legal),
showTurnActions = playing,
canBuild = CatanInteraction.canBuild(legal),
building = building,
canBuyCard = CatanInteraction.canBuyDevCard(legal),
canPlayCard = devHand.any { it.playable },
canTrade = playing,
canEndTurn = CatanInteraction.canEndTurn(legal),
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
canRepickSetup = setupVertex != null,
)
val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building)
val handle: (TapOutcome) -> Unit = { outcome ->
when (outcome) {
TapOutcome.Ignored -> Unit
is TapOutcome.Submit -> submit(outcome.move)
is TapOutcome.PickSetupVertex -> setupVertex = outcome.vertex
is TapOutcome.PickRobberVictim -> victimChoice = outcome
}
}
val publicPoints = CatanRules.publicVictoryPoints(current)
val pointsOf: (Int) -> Int = { seat ->
if (seat == localSeat) CatanRules.victoryPoints(current, seat) else publicPoints[seat]
}
CompositionLocalProvider(LocalCatanStyle provides style) {
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
BoxWithConstraints(modifier = modifier) {
val wide = maxWidth > maxHeight * 1.1f
val board: @Composable (Modifier) -> Unit = { boardModifier ->
CatanBoardView(
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)) },
modifier = boardModifier,
style = style,
)
}
val players: @Composable (Boolean) -> Unit = { grid ->
PlayerCards(current, seats, pointsOf, style, twoPerRow = grid)
}
val promptRow: @Composable () -> Unit = {
PromptRow(prompt, notice, current)
}
val handAndActions: @Composable () -> Unit = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
CatanHand(current, localSeat)
CatanActionBar(
actions = actions,
onRoll = { submit(RollDice()) },
onBuild = { building = it },
onBuyCard = { legal.firstOrNull { m -> m is BuyDevCard }?.let(submit) },
onPlayCard = { showCards = true },
onTrade = { showTrade = true },
onEndTurn = { legal.firstOrNull { m -> m is EndTurn }?.let(submit) },
onEndFreeRoads = {
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
},
onRepickSetup = { setupVertex = null },
)
}
}
if (wide) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.weight(1.5f).fillMaxSize(), contentAlignment = Alignment.Center) {
board(Modifier)
}
Column(
modifier = Modifier.weight(1f).fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
players(true)
promptRow()
handAndActions()
CatanLog(current, Modifier.fillMaxWidth().height(120.dp))
}
}
} else {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
players(false)
promptRow()
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
board(Modifier)
}
handAndActions()
}
}
}
CatanDialogHost(
state = current,
seats = seats,
localSeat = localSeat,
legal = legal,
abandoned = abandoned,
confirmingEnd = confirmingEnd,
resultDismissed = resultDismissed,
victimChoice = victimChoice,
showCards = showCards,
showTrade = showTrade,
devHand = devHand,
onExit = onExit,
onSubmit = submit,
onStay = controller::dismissAbandoned,
onKeepPlaying = { confirmingEnd = false },
onDismissResult = { resultDismissed = true },
onCancelVictim = { victimChoice = null },
onCancelCards = { showCards = false },
onCancelTrade = { showTrade = false },
)
}
}
}
// ---------------------------------------------------------------------------
// Layout pieces
// ---------------------------------------------------------------------------
@Composable
private fun PlayerCards(
state: CatanState,
seats: CatanSeats,
pointsOf: (Int) -> Int,
style: CatanStyle,
twoPerRow: Boolean,
) {
val order = (0 until state.playerCount).toList()
val rows = if (twoPerRow) order.chunked(2) else listOf(order)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
rows.forEach { row ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
row.forEach { seat ->
CatanPlayerCard(
state = state,
seat = seat,
seats = seats,
points = pointsOf(seat),
modifier = Modifier.weight(1f),
style = style,
)
}
// Keep an odd last row's card the same width as the others.
if (twoPerRow && row.size == 1) Box(Modifier.weight(1f))
}
}
}
}
@Composable
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(prompt, style = MaterialTheme.typography.bodyMedium)
if (notice != null) {
Text(
notice,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
CatanDice(state.lastDice)
}
}
/**
* Chooses which dialog, if any, is showing. Pulled out of [CatanScreen] so the
* priority order — an ended table beats a result beats a forced discard beats
* an optional picker — is in one readable place.
*/
@Composable
private fun CatanDialogHost(
state: CatanState,
seats: CatanSeats,
localSeat: Int,
legal: List<CatanMove>,
abandoned: String?,
confirmingEnd: Boolean,
resultDismissed: Boolean,
victimChoice: TapOutcome.PickRobberVictim?,
showCards: Boolean,
showTrade: Boolean,
devHand: List<DevCardInHand>,
onExit: () -> Unit,
onSubmit: (CatanMove) -> Unit,
onStay: () -> Unit,
onKeepPlaying: () -> Unit,
onDismissResult: () -> Unit,
onCancelVictim: () -> Unit,
onCancelCards: () -> Unit,
onCancelTrade: () -> Unit,
) {
val offer = state.pendingTrade
when {
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
confirmingEnd -> CatanEndGameDialog(onEnd = onExit, onKeepPlaying = onKeepPlaying)
state.phase == CatanPhase.GAME_OVER ->
if (!resultDismissed) CatanGameOverDialog(state, seats, onExit, onDismissResult)
state.phase == CatanPhase.DISCARDING && state.discardSeat == localSeat ->
CatanDiscardDialog(
held = state.resources[localSeat],
quota = CatanInteraction.discardQuota(state.resourceCount(localSeat)),
legal = legal,
onSubmit = onSubmit,
)
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat ->
CatanTradeResponseDialog(
offer = offer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
)
victimChoice != null ->
CatanVictimDialog(
victims = victimChoice.victims,
seats = seats,
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) },
onCancel = onCancelVictim,
)
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
showTrade ->
CatanTradeDialog(
state = state,
seat = localSeat,
seats = seats,
legal = legal,
onBank = onSubmit,
onOffer = onSubmit,
onCancel = onCancelTrade,
)
}
}
@@ -0,0 +1,130 @@
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.Resource
import org.prolibertate.games.game.catan.Terrain
/**
* Every visual decision for the Katan screen, in one place.
*
* **To restyle Katan, edit this file.** Nothing else in `ui/game/catan/` hard-
* codes a colour or a proportion: the board, the hand, the player strip and the
* dialogs all read a [CatanStyle] from [LocalCatanStyle]. A different art
* direction is a different [CatanStyle] value (see [catanStyle]); a new theme
* can be supplied at the screen's root with
* `CompositionLocalProvider(LocalCatanStyle provides myStyle)`.
*
* Wording is the same story in [CatanText]; geometry is in [CatanGeometry].
*
* Proportions are fractions of the *hex radius* (centre of a hex to one of its
* corners), so they scale with the board whatever the screen size.
*/
@Immutable
data class CatanStyle(
// --- Board surfaces -----------------------------------------------------
/** The water around the island. */
val sea: Color,
/** Fill of each hex by terrain. */
val terrain: Map<Terrain, Color>,
/** Outline between hexes. */
val hexEdge: Color,
/** The number-token disc, and the ink for ordinary and hot (6 / 8) numbers. */
val token: Color,
val tokenInk: Color,
val tokenHotInk: Color,
/** The robber's pawn. */
val robber: Color,
/** Harbour marker disc and its ink. */
val portMarker: Color,
val portInk: Color,
// --- Resources (hand tiles, trade pickers, harbour markers) -------------
val resource: Map<Resource, Color>,
val resourceInk: Color,
// --- Players ------------------------------------------------------------
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
val seats: List<Color>,
/** Outline drawn round every piece so any seat colour reads on any terrain. */
val pieceOutline: Color,
// --- Interaction feedback ----------------------------------------------
/** Marks where the player may build or move right now. */
val target: Color,
/** The setup settlement that has been chosen and is waiting for its road. */
val targetSelected: Color,
// --- Metrics (fractions of the hex radius) ------------------------------
/** How far each tile polygon is pulled in from its corners, leaving a gap. */
val hexInset: Float = 0.04f,
val roadWidth: Float = 0.20f,
val settlementSize: Float = 0.30f,
val citySize: Float = 0.42f,
val tokenRadius: Float = 0.36f,
val robberSize: Float = 0.34f,
val portRadius: Float = 0.34f,
val targetRadius: Float = 0.20f,
/** How close a tap must land to a vertex / edge / hex centre to count. */
val tapReach: Float = 0.55f,
val tokenText: Float = 0.34f,
val portText: Float = 0.26f,
)
/** The light palette, which is also the reference for the dark one. */
private val LightStyle = CatanStyle(
sea = Color(0xFF3A7CA5),
terrain = mapOf(
Terrain.FOREST to Color(0xFF2F6B3A),
Terrain.PASTURE to Color(0xFF9CCC65),
Terrain.FIELDS to Color(0xFFE8C84A),
Terrain.HILLS to Color(0xFFB4532F),
Terrain.MOUNTAINS to Color(0xFF7C8794),
Terrain.DESERT to Color(0xFFE6D3A0),
),
hexEdge = Color(0x55000000),
token = Color(0xFFF7EFD9),
tokenInk = Color(0xFF2B2B2B),
tokenHotInk = Color(0xFFC62828),
robber = Color(0xFF263238),
portMarker = Color(0xFFFFF8E1),
portInk = Color(0xFF2B2B2B),
resource = mapOf(
Resource.LUMBER to Color(0xFF2F6B3A),
Resource.BRICK to Color(0xFFB4532F),
Resource.WOOL to Color(0xFF8BC34A),
Resource.GRAIN to Color(0xFFE0B020),
Resource.ORE to Color(0xFF6B7684),
),
resourceInk = Color(0xFFFFFFFF),
seats = listOf(
Color(0xFF1565C0), // blue
Color(0xFFEF6C00), // orange
Color(0xFFF5F5F5), // white
Color(0xFF8E24AA), // purple
),
pieceOutline = Color(0xFF1B1B1B),
target = Color(0xFFFFEB3B),
targetSelected = Color(0xFF00E5FF),
)
/** Deeper sea and slightly dimmer land so the board is not a glare at night. */
private val DarkStyle = LightStyle.copy(
sea = Color(0xFF14354D),
terrain = LightStyle.terrain.mapValues { (_, c) -> c.dim(0.82f) },
token = Color(0xFFE9DFC4),
)
/** The style for the current light / dark setting. */
fun catanStyle(dark: Boolean): CatanStyle = if (dark) DarkStyle else LightStyle
/**
* What the Katan composables read their look from. Defaults to the light style
* so a preview or a test with no provider still draws something sensible;
* [CatanScreen] provides the right one for the current theme.
*/
val LocalCatanStyle = staticCompositionLocalOf { LightStyle }
private fun Color.dim(factor: Float): Color =
Color(red * factor, green * factor, blue * factor, alpha)
@@ -0,0 +1,213 @@
package org.prolibertate.games.ui.game.catan
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.Port
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
/**
* Who is who at the table, for wording. [names] may be empty or short — a
* client device does not always know everyone's name — and the rest fall back
* to "Seat N".
*/
class CatanSeats(val me: Int, private val names: List<String> = emptyList()) {
fun label(seat: Int): String = when {
seat == me -> "You"
else -> names.getOrNull(seat)?.takeIf { it.isNotBlank() } ?: "Seat ${seat + 1}"
}
/** For mid-sentence use: "you" rather than "You". */
fun lower(seat: Int): String = if (seat == me) "you" else label(seat)
}
/**
* Every word the Katan screen shows, in one place.
*
* **To change copy, or to localise, edit this file.** The composables never
* build a sentence themselves; they ask for it here. Keeping the sentences as
* plain functions of state also means they are unit-tested (`CatanTextTest`)
* without a device.
*
* "Katan" is this app's name for the game (see `docs/RULES-catan.md`); the
* resources are lumber, brick, wool, grain and ore.
*/
object CatanText {
const val TITLE = "Katan"
// --- Names ---------------------------------------------------------------
fun resource(resource: Resource): String = when (resource) {
Resource.LUMBER -> "Lumber"
Resource.BRICK -> "Brick"
Resource.WOOL -> "Wool"
Resource.GRAIN -> "Grain"
Resource.ORE -> "Ore"
}
fun terrain(terrain: Terrain): String = when (terrain) {
Terrain.FOREST -> "Forest"
Terrain.PASTURE -> "Pasture"
Terrain.FIELDS -> "Fields"
Terrain.HILLS -> "Hills"
Terrain.MOUNTAINS -> "Mountains"
Terrain.DESERT -> "Desert"
}
fun devCard(type: DevCardType): String = when (type) {
DevCardType.KNIGHT -> "Knight"
DevCardType.ROAD_BUILDING -> "Road building"
DevCardType.MONOPOLY -> "Monopoly"
DevCardType.YEAR_OF_PLENTY -> "Year of plenty"
DevCardType.VICTORY_POINT -> "Victory point"
}
fun devCardHelp(type: DevCardType): String = when (type) {
DevCardType.KNIGHT -> "Move the robber and steal a card. Three played knights take Largest Army."
DevCardType.ROAD_BUILDING -> "Build two roads for free."
DevCardType.MONOPOLY -> "Name a resource and take every card of it from the other players."
DevCardType.YEAR_OF_PLENTY -> "Take any two resource cards from the bank."
DevCardType.VICTORY_POINT -> "One victory point. Kept hidden until you win."
}
/** "3:1" for a generic harbour, "2:1" for a resource one. */
fun portRatio(port: Port): String = "${port.ratio}:1"
fun portName(port: Port): String =
port.resource?.let { "${portRatio(port)} ${resource(it).lowercase()}" } ?: "3:1 any"
// --- Costs ---------------------------------------------------------------
fun cost(cost: Map<Resource, Int>): String =
cost.entries.joinToString(" + ") { (r, n) -> "$n ${resource(r).lowercase()}" }
val ROAD_COST: String get() = cost(CatanRules.ROAD_COST)
val SETTLEMENT_COST: String get() = cost(CatanRules.SETTLEMENT_COST)
val CITY_COST: String get() = cost(CatanRules.CITY_COST)
val DEV_COST: String get() = cost(CatanRules.DEV_COST)
// --- Buttons and headings --------------------------------------------------
const val ROLL = "Roll dice"
const val END_TURN = "End turn"
const val ROAD = "Road"
const val SETTLEMENT = "Settlement"
const val CITY = "City"
const val BUY_CARD = "Buy card"
const val PLAY_CARD = "Play card"
const val TRADE = "Trade"
const val CANCEL = "Cancel"
const val CONFIRM = "Confirm"
const val STOP_ROADS = "Done with roads"
const val BUILD = "Build"
const val YOUR_HAND = "Your hand"
const val CARDS = "cards"
const val ACCEPT = "Accept"
const val DECLINE = "Decline"
const val TRADE_WITH_PLAYER = "Offer to a player"
const val TRADE_WITH_BANK = "Trade with the bank"
const val YOU_GIVE = "You give"
const val YOU_GET = "You get"
const val SEND_OFFER = "Send offer"
const val NO_LOG = "Nothing has happened yet."
const val END_GAME = "End game"
const val LOOK_AT_BOARD = "Look at the board"
const val LEAVE_GAME = "Leave the game"
// --- The prompt line ------------------------------------------------------
/**
* The one line that tells the player what is going on and what to do.
*
* [setupVertexChosen] is the setup settlement already picked and waiting
* for its road; [building] is the build mode the player has entered, if any.
*/
fun prompt(
state: CatanState,
seats: CatanSeats,
setupVertexChosen: Boolean,
building: BuildKind?,
): String {
val me = seats.me
val onClock = CatanRules.currentSeat(state)
val mine = onClock == me
val turn = seats.label(state.turn)
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 settlement. It earns resources from the hexes around it."
else -> "Place a settlement, then a road."
}
CatanPhase.AWAIT_ROLL ->
if (mine) "Your turn. Roll the dice." else "$turn is rolling…"
CatanPhase.DISCARDING -> {
val discarder = state.discardSeat
when {
discarder == me -> "A seven! You hold ${state.resourceCount(me)} cards; " +
"discard ${state.resourceCount(me) / 2}."
discarder != null -> "A seven! ${seats.label(discarder)} must discard half."
else -> "A seven! Discarding…"
}
}
CatanPhase.ROBBER ->
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…"
CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…"
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."
else -> "Your turn. Build, trade, or end the turn."
}
CatanPhase.FREE_ROADS ->
if (mine) {
"Road building: place ${state.pendingFreeRoads} more " +
if (state.pendingFreeRoads == 1) "road." else "roads."
} else {
"$turn is placing free roads…"
}
CatanPhase.TRADE_PENDING -> {
val offer = state.pendingTrade
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."
}
}
}
}
/** "3 Lumber + 1 Brick" for a flat list of cards. */
fun cards(cards: List<Resource>): String =
cards.groupingBy { it }.eachCount().entries
.sortedBy { it.key.ordinal }
.joinToString(" + ") { (r, n) -> "$n ${resource(r)}" }
.ifEmpty { "nothing" }
fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
"$by offers ${cards(give)} to $to for ${cards(get)}"
fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
val winner = state.winner ?: return "Game over"
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
}
}