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 teamBased: Boolean,
val blurb: String, val blurb: String,
val available: Boolean, 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. * The one menu this game is listed under.
* *
@@ -348,10 +361,12 @@ object GameCatalog {
maxPlayers = 4, maxPlayers = 4,
teamBased = false, teamBased = false,
blurb = "Trade, build, and settle the island.", blurb = "Trade, build, and settle the island.",
// Listed, not playable: the engine and screen land with the Katan // Engine and screen exist but have not been verified on two real
// game task. Dev builds show the entry as *Coming soon*; a // devices yet. Dev builds can open it as a *Preview*; a production
// production build lists only what can actually be played. // build does not list it. Flip `available` on, and `devPreview`
// off, once two-device play has been verified.
available = false, available = false,
devPreview = true,
), ),
) )
@@ -364,7 +364,11 @@ fun SectionScreen(
} }
} }
items(games) { game -> 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) {
Card( Card(
onClick = onClick, onClick = onClick,
enabled = game.available, enabled = playable,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (game.available) { containerColor = if (playable) {
MaterialTheme.colorScheme.surfaceVariant MaterialTheme.colorScheme.surfaceVariant
} else { } else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
@@ -427,13 +431,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp), 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(
text = when { text = when {
!game.available -> "Coming soon" !playable -> "Coming soon"
// A puzzle for one, rather than "1 players". // Playable on this dev build only; production hides it.
game.maxPlayers == 1 -> "On your own" !game.available -> "Preview · $players"
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" else -> players
else -> "${game.minPlayers}–${game.maxPlayers} players"
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 8.dp), 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.PiratesLevel
import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions import org.prolibertate.games.game.tayu.TayuOptions
@@ -104,6 +105,7 @@ fun GameSetupScreen(
var freecell by remember { mutableStateOf(FreeCellOptions()) } var freecell by remember { mutableStateOf(FreeCellOptions()) }
var spider by remember { mutableStateOf(SpiderOptions()) } var spider by remember { mutableStateOf(SpiderOptions()) }
var pyramid by remember { mutableStateOf(PyramidOptions()) } 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 // Not an engine option: which seat the local player takes. Seat 0 is
// always White, so choosing Black means sitting in seat 1. // always White, so choosing Black means sitting in seat 1.
var playWhite by remember { mutableStateOf(true) } var playWhite by remember { mutableStateOf(true) }
@@ -134,6 +136,7 @@ fun GameSetupScreen(
GameCatalog.FREECELL -> setupJson.encodeToString(freecell) GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
GameCatalog.SPIDER -> setupJson.encodeToString(spider) GameCatalog.SPIDER -> setupJson.encodeToString(spider)
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
GameCatalog.CATAN -> setupJson.encodeToString(catan)
else -> "{}" else -> "{}"
} }
val seatCount = seatCountFor(descriptor.id, optionsJson) val seatCount = seatCountFor(descriptor.id, optionsJson)
@@ -199,6 +202,8 @@ fun GameSetupScreen(
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
else -> Text("No options yet for this game.") else -> Text("No options yet for this game.")
} }
@@ -303,6 +308,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
GameCatalog.TAYU -> GameCatalog.TAYU ->
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
GameCatalog.CATAN ->
setupJson.decodeFromString<CatanOptions>(optionsJson).playerCount
else -> fallback else -> fallback
} }
}.getOrDefault(fallback) }.getOrDefault(fallback)
@@ -326,6 +334,27 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
else -> seat 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 @Composable
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) { private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -13,6 +13,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import org.prolibertate.games.game.GameCatalog 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.ChessAi
import org.prolibertate.games.game.chess.ChessMove import org.prolibertate.games.game.chess.ChessMove
import org.prolibertate.games.game.chess.ChessRules 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.net.MatchController
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.BackgammonScreen 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.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CrazyEightsScreen
@@ -668,6 +673,30 @@ fun PlayScreen(
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) 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.") 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"
}
}
@@ -121,6 +121,20 @@ class CatalogTest {
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction) assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
} }
@Test
fun `a preview game opens on a dev build and nowhere else`() {
val preview = GameCatalog.all.filter { it.devPreview }
assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN))
preview.forEach { game ->
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
}
// An ordinary finished game opens everywhere.
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
}
@Test @Test
fun `hiding unfinished games still leaves something to play in every menu`() { fun `hiding unfinished games still leaves something to play in every menu`() {
// A menu filtered down to nothing would leave a stranded heading, and a // A menu filtered down to nothing would leave a stranded heading, and a
@@ -0,0 +1,53 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanBuilding
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.catan.Resource
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** Shared fixtures for the Katan UI-logic tests: real islands, bent to taste. */
internal object CatanFixtures {
private val json = Json { encodeDefaults = true }
fun config(players: Int = 3, seed: Long = 7L) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(CatanOptions(playerCount = players)),
seed = seed,
)
/** A fresh table, on the first setup placement. */
fun start(players: Int = 3, seed: Long = 7L): CatanState =
CatanRules.initialState(config(players, seed))
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
fun playing(
turn: Int = 0,
players: Int = 3,
adjust: CatanState.() -> CatanState = { this },
): CatanState = start(players)
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
.adjust()
fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
fun CatanState.withHand(seat: Int, hand: Map<Resource, Int>): CatanState =
copy(resources = resources.toMutableList().also { it[seat] = hand })
fun CatanState.withBuilding(vertex: Int, type: BuildingType, owner: Int): CatanState =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
copy(roads = roads.toMutableList().also { it[edge] = owner })
}
@@ -0,0 +1,117 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.catan.CatanBoard
import kotlin.math.abs
import kotlin.math.sqrt
class CatanGeometryTest {
private val sizes = listOf(360f to 800f, 800f to 360f, 500f to 500f, 1000f to 1000f / CatanGeometry.ASPECT)
private fun dist(a: Pt, b: Pt): Float = sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
@Test
fun `the whole island fits inside the box at every shape`() {
for ((w, h) in sizes) {
val layout = CatanGeometry.fit(w, h)
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val p = layout.vertex(v)
assertTrue("vertex $v x=${p.x} outside 0..$w", p.x in 0f..w)
assertTrue("vertex $v y=${p.y} outside 0..$h", p.y in 0f..h)
}
for (e in CatanBoard.PORT_POSITIONS) {
val p = layout.portAnchor(e)
assertTrue("harbour marker x=${p.x} outside 0..$w at ${w}x$h", p.x in 0f..w)
assertTrue("harbour marker y=${p.y} outside 0..$h at ${w}x$h", p.y in 0f..h)
}
}
}
@Test
fun `the layout is as large as the box allows`() {
// Tall and narrow is limited by width, short and wide by height; either
// way the island should use most of the limiting side.
val tall = CatanGeometry.fit(360f, 800f)
val wide = CatanGeometry.fit(800f, 360f)
assertTrue(tall.radius > 20f)
assertTrue(wide.radius > 20f)
val xs = (0 until CatanBoard.VERTEX_COUNT).map { tall.vertex(it).x }
assertTrue("island should span most of the width", xs.max() - xs.min() > 360f * 0.7f)
}
@Test
fun `every edge is one hex radius long and every hex corner is one radius from its centre`() {
val layout = CatanGeometry.fit(500f, 500f)
for (e in 0 until CatanBoard.EDGE_COUNT) {
val (a, b) = layout.edgeEnds(e)
assertEquals("edge $e length", layout.radius, dist(a, b), layout.radius * 0.01f)
}
for (h in 0 until CatanBoard.HEX_COUNT) {
val centre = layout.hexCenter(h)
for (corner in layout.hexCorners(h)) {
assertEquals("hex $h corner", layout.radius, dist(centre, corner), layout.radius * 0.01f)
}
}
}
@Test
fun `all fifty-four vertices sit at distinct places`() {
val layout = CatanGeometry.fit(500f, 500f)
val places = (0 until CatanBoard.VERTEX_COUNT).map {
val p = layout.vertex(it)
Math.round(p.x) to Math.round(p.y)
}.toSet()
assertEquals(CatanBoard.VERTEX_COUNT, places.size)
}
@Test
fun `a tap on a vertex finds that vertex, and a tap far away finds nothing`() {
val layout = CatanGeometry.fit(500f, 500f)
val all = (0 until CatanBoard.VERTEX_COUNT).toList()
for (v in all) {
val p = layout.vertex(v)
assertEquals(v, layout.nearestVertex(p, layout.radius * 0.5f, all))
// A slightly clumsy tap still lands on it.
assertEquals(v, layout.nearestVertex(Pt(p.x + layout.radius * 0.2f, p.y - layout.radius * 0.15f), layout.radius * 0.55f, all))
}
assertNull(layout.nearestVertex(Pt(-100f, -100f), layout.radius * 0.5f, all))
}
@Test
fun `hit testing only considers the candidates it is given`() {
val layout = CatanGeometry.fit(500f, 500f)
val at = layout.vertex(5)
assertNull(layout.nearestVertex(at, layout.radius * 0.3f, listOf(6)))
assertEquals(6, layout.nearestVertex(layout.vertex(6), layout.radius * 0.3f, listOf(6)))
assertEquals(3, layout.nearestEdge(layout.edgeMidpoint(3), 5f, listOf(3, 4)))
assertEquals(7, layout.nearestHex(layout.hexCenter(7), 5f, listOf(7, 8)))
}
@Test
fun `the nearest candidate wins when several are in reach`() {
val layout = CatanGeometry.fit(500f, 500f)
val a = 10
val b = CatanBoard.VERTEX_NEIGHBORS[a].first()
val pa = layout.vertex(a)
val pb = layout.vertex(b)
// A point two-thirds of the way toward b is nearer b, though both are in reach.
val at = Pt(pa.x + (pb.x - pa.x) * 0.66f, pa.y + (pb.y - pa.y) * 0.66f)
assertEquals(b, layout.nearestVertex(at, layout.radius * 2f, listOf(a, b)))
}
@Test
fun `harbour markers sit outside the coast`() {
val layout = CatanGeometry.fit(500f, 500f)
val centre = Pt(500f / 2f, 500f / 2f)
for (e in CatanBoard.PORT_POSITIONS) {
val anchor = layout.portAnchor(e)
val mid = layout.edgeMidpoint(e)
assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre))
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * 0.62f) < 1f)
}
}
}
@@ -0,0 +1,325 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
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.BuildingType
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
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.MaritimeTrade
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayYearOfPlenty
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
import org.prolibertate.games.ui.game.catan.CatanFixtures.withBuilding
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
import org.prolibertate.games.ui.game.catan.CatanFixtures.withRoad
class CatanInteractionTest {
private val rich = hand(
Resource.LUMBER to 5, Resource.BRICK to 5, Resource.WOOL to 5,
Resource.GRAIN to 5, Resource.ORE to 5,
)
// --- Setup ---------------------------------------------------------------
@Test
fun `setup first offers every settlement spot, then only the roads beside the chosen one`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val setup = legal.filterIsInstance<SetupPlace>()
val first = CatanInteraction.targets(state, legal, null, null)
assertEquals(setup.map { it.vertex }.toSet(), first.vertices)
assertTrue(first.edges.isEmpty() && first.hexes.isEmpty())
val vertex = first.vertices.first()
val second = CatanInteraction.targets(state, legal, null, vertex)
assertTrue(second.vertices.isEmpty())
assertEquals(setup.filter { it.vertex == vertex }.map { it.edge }.toSet(), second.edges)
}
@Test
fun `a setup placement is two taps - the settlement, then its road`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
val edge = CatanInteraction.targets(state, legal, null, vertex).edges.first()
assertEquals(TapOutcome.PickSetupVertex(vertex), CatanInteraction.onVertexTap(vertex, legal, null, null))
assertEquals(TapOutcome.Submit(SetupPlace(vertex, edge)), CatanInteraction.onEdgeTap(edge, legal, null, vertex))
// A road tapped before any settlement is chosen means nothing.
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, null, null))
}
@Test
fun `a road that does not touch the chosen settlement is refused`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
val beside = CatanBoard.VERTEX_EDGES[vertex].toSet()
val far = (0 until CatanBoard.EDGE_COUNT).first { it !in beside }
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(far, legal, null, vertex))
}
// --- Building -------------------------------------------------------------
/** Seat 0 owns a settlement at vertex 0 and a road two edges long leading away from it. */
private fun established(): Triple<org.prolibertate.games.game.catan.CatanState, Int, Int> {
val e1 = CatanBoard.VERTEX_EDGES[0][0]
val w = CatanBoard.otherEnd(e1, 0)
val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 }
val x = CatanBoard.otherEnd(e2, w)
val state = playing {
withHand(0, rich).withBuilding(0, BuildingType.SETTLEMENT, 0).withRoad(e1, 0).withRoad(e2, 0)
}
return Triple(state, x, e2)
}
@Test
fun `nothing on the board is tappable until a build mode is chosen`() {
val (state, _, _) = established()
val legal = CatanRules.legalMoves(state, 0)
assertTrue(CatanInteraction.targets(state, legal, null, null).isEmpty)
}
@Test
fun `build modes target exactly what the engine allows`() {
val (state, x, _) = established()
val legal = CatanRules.legalMoves(state, 0)
assertEquals(
legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet(),
CatanInteraction.targets(state, legal, BuildKind.ROAD, null).edges,
)
assertTrue(x in CatanInteraction.targets(state, legal, BuildKind.SETTLEMENT, null).vertices)
assertEquals(setOf(0), CatanInteraction.targets(state, legal, BuildKind.CITY, null).vertices)
assertEquals(setOf(BuildKind.ROAD, BuildKind.SETTLEMENT, BuildKind.CITY), CatanInteraction.canBuild(legal))
}
@Test
fun `a tap builds only when the matching mode is armed and the spot is legal`() {
val (state, x, _) = established()
val legal = CatanRules.legalMoves(state, 0)
val road = legal.filterIsInstance<BuildRoad>().first().edge
assertEquals(TapOutcome.Submit(BuildSettlement(x)), CatanInteraction.onVertexTap(x, legal, BuildKind.SETTLEMENT, null))
assertEquals(TapOutcome.Submit(BuildCity(0)), CatanInteraction.onVertexTap(0, legal, BuildKind.CITY, null))
assertEquals(TapOutcome.Submit(BuildRoad(road)), CatanInteraction.onEdgeTap(road, legal, BuildKind.ROAD, null))
// No mode armed: nothing happens.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, null, null))
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(road, legal, null, null))
// A settlement on an occupied crossing is not offered.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(0, legal, BuildKind.SETTLEMENT, null))
// The wrong mode for the spot.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, BuildKind.CITY, null))
}
@Test
fun `without the cards, nothing can be built`() {
val (state, _, _) = established()
val broke = state.withHand(0, hand())
val legal = CatanRules.legalMoves(broke, 0)
assertTrue(CatanInteraction.canBuild(legal).isEmpty())
assertFalse(CatanInteraction.canBuyDevCard(legal))
assertTrue(CatanInteraction.canEndTurn(legal))
}
@Test
fun `free roads from a road building card need no build mode`() {
val (state, _, _) = established()
val free = state.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
val legal = CatanRules.legalMoves(free, 0)
val targets = CatanInteraction.targets(free, legal, null, null)
assertTrue(targets.edges.isNotEmpty())
val edge = targets.edges.first()
assertEquals(TapOutcome.Submit(BuildRoad(edge)), CatanInteraction.onEdgeTap(edge, legal, null, null))
assertTrue(CatanInteraction.canEndFreeRoads(legal))
}
// --- The robber ---------------------------------------------------------------
@Test
fun `the robber may go to any hex but the one it is on`() {
val state = playing { copy(phase = CatanPhase.ROBBER) }
val legal = CatanRules.legalMoves(state, 0)
val targets = CatanInteraction.targets(state, legal, null, null)
assertEquals((0 until CatanBoard.HEX_COUNT).filter { it != state.robber }.toSet(), targets.hexes)
val hex = targets.hexes.first()
// Nobody has built anything, so there is nobody to rob: the move goes straight through.
assertEquals(
TapOutcome.Submit(MoveRobber(CatanBoard.HEXES[hex], null)),
CatanInteraction.onHexTap(hex, legal),
)
assertEquals(TapOutcome.Ignored, CatanInteraction.onHexTap(state.robber, legal))
}
@Test
fun `a hex with two possible victims asks which`() {
val base = playing { copy(phase = CatanPhase.ROBBER) }
val hex = (0 until CatanBoard.HEX_COUNT).first { it != base.robber }
val corners = CatanBoard.HEX_CORNERS[hex]
val state = base
.withBuilding(corners[0], BuildingType.SETTLEMENT, 1)
.withBuilding(corners[3], BuildingType.SETTLEMENT, 2)
.withHand(1, hand(Resource.ORE to 1))
.withHand(2, hand(Resource.GRAIN to 1))
val legal = CatanRules.legalMoves(state, 0)
val outcome = CatanInteraction.onHexTap(hex, legal)
assertTrue(outcome is TapOutcome.PickRobberVictim)
outcome as TapOutcome.PickRobberVictim
assertEquals(CatanBoard.HEXES[hex], outcome.hex)
assertEquals(setOf(1, 2), outcome.victims.toSet())
}
// --- Discarding -----------------------------------------------------------------
@Test
fun `a seven costs half the hand, rounded down`() {
assertEquals(4, CatanInteraction.discardQuota(9))
assertEquals(4, CatanInteraction.discardQuota(8))
assertEquals(3, CatanInteraction.discardQuota(7))
}
@Test
fun `a discard is found only when it names exactly an allowed set of cards`() {
val state = playing {
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
.withHand(0, hand(Resource.LUMBER to 3, Resource.BRICK to 3, Resource.WOOL to 3))
}
val legal = CatanRules.legalMoves(state, 0)
assertTrue(legal.all { it is Discard })
val good = listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.WOOL)
assertNotNull(CatanInteraction.discardMove(legal, good))
// Order does not matter.
assertNotNull(CatanInteraction.discardMove(legal, good.reversed()))
// Too few, and more than are held.
assertNull(CatanInteraction.discardMove(legal, good.take(3)))
assertNull(CatanInteraction.discardMove(legal, List(4) { Resource.LUMBER }))
}
// --- Trading ---------------------------------------------------------------------
@Test
fun `bank trades list what can be given and what it can buy`() {
val state = playing { withHand(0, hand(Resource.GRAIN to 4)) }
val legal = CatanRules.legalMoves(state, 0)
assertTrue(legal.any { it is MaritimeTrade })
val trades = CatanInteraction.bankTrades(legal)
assertEquals(setOf(Resource.GRAIN), trades.keys)
assertEquals(Resource.entries.filter { it != Resource.GRAIN }, trades.getValue(Resource.GRAIN))
assertEquals(4, CatanInteraction.bankRatio(state, 0, Resource.GRAIN))
}
@Test
fun `an offer is allowed only to another player, for something different, from cards held`() {
val state = playing { withHand(0, hand(Resource.LUMBER to 2)) }
val lumber = listOf(Resource.LUMBER)
val grain = listOf(Resource.GRAIN)
assertTrue(CatanInteraction.offerAllowed(state, 0, 1, lumber, grain))
assertFalse("to oneself", CatanInteraction.offerAllowed(state, 0, 0, lumber, grain))
assertFalse("nothing offered", CatanInteraction.offerAllowed(state, 0, 1, emptyList(), grain))
assertFalse("nothing asked", CatanInteraction.offerAllowed(state, 0, 1, lumber, emptyList()))
assertFalse("like for like", CatanInteraction.offerAllowed(state, 0, 1, lumber, lumber))
assertFalse("more than held", CatanInteraction.offerAllowed(state, 0, 1, List(3) { Resource.LUMBER }, grain))
assertFalse("not their turn", CatanInteraction.offerAllowed(state, 1, 0, lumber, grain))
}
// --- Development cards -------------------------------------------------------------
@Test
fun `a development card is playable unless it was bought this turn or is a victory point`() {
val state = playing {
copy(
devHands = listOf(
listOf(
DevCard(1, DevCardType.KNIGHT, boughtTurn = 0),
DevCard(2, DevCardType.VICTORY_POINT, boughtTurn = 0),
DevCard(3, DevCardType.KNIGHT, boughtTurn = 1),
),
emptyList(),
emptyList(),
),
)
}
val legal = CatanRules.legalMoves(state, 0)
val hand = CatanInteraction.devHand(state, 0, legal)
// Sorted by type then id: the two knights, then the victory point.
assertEquals(listOf(1, 3, 2), hand.map { it.card.id })
assertEquals(listOf(true, false, false), hand.map { it.playable })
assertEquals(listOf<Any>(PlayKnight(1)), hand.first().plays)
}
@Test
fun `monopoly offers one play per resource`() {
val plays = Resource.entries.map { PlayMonopoly(9, it) }
val choices = CatanInteraction.monopolyChoices(plays)
assertEquals(Resource.entries.toSet(), choices.keys)
assertEquals(PlayMonopoly(9, Resource.ORE), choices.getValue(Resource.ORE))
}
@Test
fun `year of plenty narrows to the cards that still complete a take`() {
val plays = listOf(
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.BRICK)),
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.LUMBER)),
PlayYearOfPlenty(4, listOf(Resource.ORE)),
)
assertEquals(
setOf(Resource.LUMBER, Resource.BRICK, Resource.ORE),
CatanInteraction.plentyAvailable(plays, emptyList()),
)
assertEquals(
setOf(Resource.LUMBER, Resource.BRICK),
CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER)),
)
assertEquals(setOf(Resource.LUMBER), CatanInteraction.plentyAvailable(plays, listOf(Resource.BRICK)))
assertTrue(CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER, Resource.LUMBER)).isEmpty())
assertEquals(plays[0], CatanInteraction.plentyMove(plays, listOf(Resource.BRICK, Resource.LUMBER)))
assertEquals(plays[2], CatanInteraction.plentyMove(plays, listOf(Resource.ORE)))
assertNull(CatanInteraction.plentyMove(plays, listOf(Resource.GRAIN)))
}
// --- Trade replies ------------------------------------------------------------------
@Test
fun `the addressee of an offer is given accept and decline`() {
val base = playing {
withHand(0, hand(Resource.LUMBER to 1)).withHand(1, hand(Resource.GRAIN to 1))
}
val offer = org.prolibertate.games.game.catan.TradeOffer(
by = 0, to = 1, give = listOf(Resource.LUMBER), get = listOf(Resource.GRAIN),
)
val pending = base.copy(phase = CatanPhase.TRADE_PENDING, pendingTrade = offer)
val legal = CatanRules.legalMoves(pending, 1)
assertEquals(true to true, CatanInteraction.tradeResponses(legal))
// If the addressee no longer holds what was asked, only decline remains.
val short = pending.withHand(1, hand())
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
}
}
@@ -0,0 +1,101 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.Port
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
class CatanTextTest {
private val names = listOf("Ann", "Bo", "Cy")
@Test
fun `seats are named, with You for the local player and a number when a name is unknown`() {
val seats = CatanSeats(me = 1, names = names)
assertEquals("You", seats.label(1))
assertEquals("Ann", seats.label(0))
assertEquals("you", seats.lower(1))
assertEquals("Seat 4", seats.label(3))
assertEquals("Seat 1", CatanSeats(me = 1).label(0))
assertEquals("Seat 1", CatanSeats(me = 1, names = listOf(" ")).label(0))
}
@Test
fun `setup prompts say what to place next`() {
val state = start()
val mine = CatanSeats(0, names)
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = false, building = null).contains("Place a settlement"))
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = true, building = null).contains("road"))
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).startsWith("Ann is placing"))
val second = state.copy(phase = CatanPhase.SETUP_SECOND, setupIndex = 3)
assertTrue(CatanText.prompt(second, CatanSeats(2, names), false, null).contains("second settlement"))
}
@Test
fun `the turn prompts follow the phase`() {
val roll = playing().copy(phase = CatanPhase.AWAIT_ROLL)
assertTrue(CatanText.prompt(roll, CatanSeats(0, names), false, null).contains("Roll the dice"))
assertEquals("Ann is rolling…", CatanText.prompt(roll, CatanSeats(1, names), false, null))
val turn = playing()
assertTrue(CatanText.prompt(turn, CatanSeats(0, names), false, null).contains("Your turn"))
assertEquals("Tap an edge to build a road.", CatanText.prompt(turn, CatanSeats(0, names), false, BuildKind.ROAD))
assertEquals("Bo is playing…", CatanText.prompt(playing(turn = 1), CatanSeats(0, names), false, null))
val robber = playing().copy(phase = CatanPhase.ROBBER)
assertTrue(CatanText.prompt(robber, CatanSeats(0, names), false, null).contains("robber"))
}
@Test
fun `the discard prompt names the quota`() {
val state = playing {
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
.withHand(0, hand(Resource.LUMBER to 9))
}
assertTrue(CatanText.prompt(state, CatanSeats(0, names), false, null).contains("discard 4"))
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).contains("Ann must discard half"))
}
@Test
fun `road building counts down in the singular and the plural`() {
val two = playing().copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
val one = two.copy(pendingFreeRoads = 1)
assertTrue(CatanText.prompt(two, CatanSeats(0, names), false, null).endsWith("2 more roads."))
assertTrue(CatanText.prompt(one, CatanSeats(0, names), false, null).endsWith("1 more road."))
}
@Test
fun `the end of the game names the winner`() {
val over = playing().copy(phase = CatanPhase.GAME_OVER, winner = 1)
assertEquals("Bo won the game.", CatanText.prompt(over, CatanSeats(0, names), false, null))
assertEquals("You won!", CatanText.winnerHeadline(over, CatanSeats(1, names)))
assertEquals("Bo won", CatanText.winnerHeadline(over, CatanSeats(0, names)))
}
@Test
fun `cards read as a sorted tally`() {
assertEquals(
"2 Lumber + 1 Grain",
CatanText.cards(listOf(Resource.GRAIN, Resource.LUMBER, Resource.LUMBER)),
)
assertEquals("nothing", CatanText.cards(emptyList()))
assertEquals(
"Ann offers 1 Lumber to you for 2 Ore",
CatanText.tradeSummary("Ann", "you", listOf(Resource.LUMBER), listOf(Resource.ORE, Resource.ORE)),
)
}
@Test
fun `costs and harbours read as the rules state them`() {
assertEquals("1 lumber + 1 brick", CatanText.ROAD_COST)
assertEquals("2 grain + 3 ore", CatanText.CITY_COST)
assertEquals("2:1 ore", CatanText.portName(Port(edge = 0, resource = Resource.ORE, ratio = 2)))
assertEquals("3:1 any", CatanText.portName(Port(edge = 0, resource = null, ratio = 3)))
}
}
+2
View File
@@ -42,6 +42,8 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
- Build, CI, releases and OTA are described in the CI section of the root - Build, CI, releases and OTA are described in the CI section of the root
[README](../README.md). [README](../README.md).
- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle,
re-copy or re-lay-out it without touching the rules.
- `licenses/` — third-party licences that must travel with the app - `licenses/` — third-party licences that must travel with the app
(Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`). (Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`).
- Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root. - Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root.
+4 -6
View File
@@ -19,8 +19,7 @@ behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`.
- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at - **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at
least 2. A lower target makes a test game short. least 2. A lower target makes a test game short.
The setup screen that exposes these lands with the screen task; the engine reads The setup screen exposes both (players: 3 or 4; game to 5, 8, 10 or 12 points).
them from the table configuration today.
## Board ## Board
@@ -291,10 +290,9 @@ timing rules; Longest Road and Largest Army worth five; victory at ten.
- **No player-to-player trade over the wire beyond accept/decline.** The engine - **No player-to-player trade over the wire beyond accept/decline.** The engine
supports it; whether the UI surfaces it is the screen task's concern. supports it; whether the UI surfaces it is the screen task's concern.
- **No trading away a development card.** Not part of the base game. - **No trading away a development card.** Not part of the base game.
- **The setup screen is not part of this task.** `CatanOptions` (players, target - **The screen is built, not yet verified on two devices.** The Katan screen and
points) is read from the table configuration; the screen that sets it, and the setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a
catalogue descriptor's `available` flag, land with the screen and verification dev-channel preview until two-device play has been verified.
tasks.
- **The AI does not look ahead.** It scores one move deep with a fixed resource - **The AI does not look ahead.** It scores one move deep with a fixed resource
valuation. It plays legally and plausibly; it is not a strong opponent, and no valuation. It plays legally and plausibly; it is not a strong opponent, and no
strength claim is made. strength claim is made.
+134
View File
@@ -0,0 +1,134 @@
# Katan UI — how it is built, and how to change it
The Katan screen (`app/src/main/java/org/prolibertate/games/ui/game/catan/`) is
built in layers so that **look, wording, layout and interaction can each be
changed on their own**. If you are an agent asked to restyle, retheme,
re-copy or re-lay-out Katan, start here and touch only the layer you were asked
about.
The rules live elsewhere and are not part of the UI: `game/catan/` is a pure
state machine (see [RULES-catan.md](RULES-catan.md)). **Nothing in `ui/game/catan/`
decides what is legal.** The engine's legal-move list is the single source of
truth, so the UI can only ever offer a move the rules would accept.
## The layers
| Layer | File | Owns | Change it to… |
| --- | --- | --- | --- |
| **Style** | `CatanStyle.kt` | Every colour, proportion and the light/dark palettes | Re-skin: new terrain colours, seat colours, piece sizes, token look |
| **Copy** | `CatanText.kt` | Every sentence, button label and name shown | Reword, localise, rename resources |
| **Geometry** | `CatanGeometry.kt` | Engine lattice → pixels, and pixel → nearest vertex/edge/hex | Change board scale, margin, harbour placement, tap reach (rarely) |
| **Interaction** | `CatanInteraction.kt` | What is tappable, what a tap means, which buttons are live | Change *how* something is selected (never *whether* it is legal) |
| **Board drawing** | `CatanBoardView.kt` | The `Canvas`: tiles, tokens, ports, robber, roads, pieces, highlights | Change how a piece is drawn (art, shapes, animation) |
| **Panels** | `CatanPanels.kt` | Resource tiles, hand, player cards, dice, action bar, log | Restyle or rearrange widgets |
| **Dialogs** | `CatanDialogs.kt` | Discard, robber victim, play card, trade, trade reply, game over | Change a dialog's flow or look |
| **Screen** | `CatanScreen.kt` | Wiring to `MatchController`, portrait/landscape layout, which dialog shows | Change the layout or add a new element |
| **Previews** | `CatanPreviews.kt` | Android Studio previews of real positions | Iterate on the look without a match |
Everything outside this folder that mentions Katan is one-line wiring:
`PlayScreen.kt` (builds the `MatchController`), `GameSetupScreen.kt` (players and
points-to-win options), `Catalog.kt` (the catalogue entry).
## Recipes
**Change colours or sizes.** Edit `CatanStyle.kt`. `LightStyle` is the reference
palette; `DarkStyle` is derived from it. Metrics (`roadWidth`, `settlementSize`,
`tokenRadius`, …) are fractions of the hex radius, so they scale with the screen.
To try a whole new theme, provide it at the root:
`CompositionLocalProvider(LocalCatanStyle provides myStyle) { … }`.
**Change wording or translate.** Edit `CatanText.kt`. Composables never build a
sentence themselves. `prompt(...)` is the one-line "what is happening / what do I
do" text; it is a pure function of the state, with tests in `CatanTextTest`.
**Redraw a piece** (settlement, road, robber, port, token). Edit the matching
`draw…` function in `CatanBoardView.kt`. Positions come from `BoardLayout`, so you
only decide the shape. Keep the hit target generous (see Accessibility).
**Rearrange the screen.** Edit `CatanScreen.kt`. It builds four independent
pieces (`board`, `players`, `promptRow`, `handAndActions`) and arranges them
differently in portrait and landscape. Moving or dropping a piece does not affect
the others.
**Add a widget.** Make it a stateless composable in `CatanPanels.kt` (values in,
lambdas out). Do not give it the `MatchController`.
**Change how a selection works** (for example a one-tap build instead of a
build-mode toggle). Edit `CatanInteraction.kt` and its tests, then adjust the
screen's UI state. The legal-move list stays the gate.
## Rules of the road
1. **Never put a rule in a composable.** If a button's enabled state or a tap's
result depends on game logic, it goes in `CatanInteraction` (pure, tested) and
is derived from the engine's `legal` list or `CatanRules`.
2. **Never hard-code a colour or a string** outside `CatanStyle` / `CatanText`.
3. **Stateless by default.** Panels and dialogs take values and report events. The
only state they may own is the transient selection they are helping the player
make (cards picked in a trade, for example).
4. **Hidden information.** The local view comes from `CatanRules.viewFor`: other
players' development cards and the deck are empty on a client. Do not show the
size of the deck; it is not known to a client. Card *counts* (`resourceCount`,
`devHandCounts`) are public.
5. **The state is immutable.** Never mutate it; submit a move.
## Interaction model (what the player actually does)
| Phase | The player… |
| --- | --- |
| Setup | taps a highlighted crossing (settlement), then a highlighted edge beside it (road). *Cancel* re-picks the settlement |
| Roll | presses **Roll dice** |
| Seven | **Discard** dialog (forced); then taps a hex to move the robber; a victim dialog appears only if more than one player can be robbed |
| Main turn | arms **Road / Settlement / City** and taps a highlighted spot; **Buy card**; **Play card**; **Trade**; **End turn** |
| Road Building | taps highlighted edges, or **Done with roads** |
| Offered a trade | **Accept / Decline** dialog (forced) |
| Game over | result dialog |
Trade offers are free-form (two card multisets), so they are validated with
`CatanRules.isLegalMove`, not looked up in the legal list.
## Testing
Gradle does not run in the agent sandbox; **CI is the compile and test check**
(dispatch `ci.yml` on your branch). What is covered:
- `CatanGeometryTest` — the island fits any box, edges and corners are one radius,
hit-testing finds the right vertex/edge/hex.
- `CatanInteractionTest` — targets and tap outcomes for setup, building, free
roads, the robber (including the victim choice), discards, bank trades, offers,
development cards and trade replies, all against real engine states.
- `CatanTextTest` — the prompts and tallies.
Drawing and dialogs are not unit-tested; use the previews in `CatanPreviews.kt`
and play a game on a dev build.
## Accessibility notes
- Tap targets: vertices and edges accept a tap within `tapReach` (0.7) hex radii,
about 23 dp on a phone. If you shrink the board, keep that physical size.
- Seat colours are blue, orange, white and purple, each outlined in near-black so
they read on any terrain. Colour is the only piece identity today; adding a
shape or letter per seat would help colour-blind players (a good first task).
- The board has a content description but no per-hex semantics; a screen-reader
mode would need one.
## Status and known gaps
The screen is **playable and feature-complete for the engine's rules**, but is
**unverified on two real devices**, so the catalogue entry is `devPreview`
(openable on a dev build, hidden from production). To release it: verify
two-device play, then in `Catalog.kt` set `available = true` and remove
`devPreview = true`, and update `CatalogTest`.
Not done yet, in rough priority order:
1. Two-device verification (host + guest over the LAN) and a full game on a phone
and a tablet.
2. Feedback on events: dice-roll animation, a highlight on hexes that produced,
resource-gain flourishes, haptics. The state carries the dice and a text log;
it does not say which hexes paid out, so that needs either a diff of two states
or an engine addition.
3. Art: terrain textures, resource and harbour icons, a real robber pawn.
4. Open trade to the whole table and counter-offers (an engine limitation too).
5. A board that shows the *last* move (a faint highlight of what just changed).
6. Landscape polish on large tablets; the right-hand panel scrolls but is plain.