diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index ca9ae3f..abb732a 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -42,7 +42,20 @@ data class GameDescriptor( val teamBased: Boolean, val blurb: String, val available: Boolean, + /** + * Playable on a dev build while still hidden from production. + * + * [available] is the release gate: it is what a production build lists and + * lets people open. A game that has an engine and a screen but has not yet + * been verified on real devices sets this instead, so it can be played and + * tested from the dev channel without being offered to everyone. Flip + * [available] on (and this off) once it has been verified. + */ + val devPreview: Boolean = false, ) { + /** Whether the menu should let this game be opened in the given kind of build. */ + fun playableIn(devBuild: Boolean): Boolean = available || (devBuild && devPreview) + /** * The one menu this game is listed under. * @@ -348,10 +361,12 @@ object GameCatalog { maxPlayers = 4, teamBased = false, blurb = "Trade, build, and settle the island.", - // Listed, not playable: the engine and screen land with the Katan - // game task. Dev builds show the entry as *Coming soon*; a - // production build lists only what can actually be played. + // Engine and screen exist but have not been verified on two real + // devices yet. Dev builds can open it as a *Preview*; a production + // build does not list it. Flip `available` on, and `devPreview` + // off, once two-device play has been verified. available = false, + devPreview = true, ), ) diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index fd5c69b..b01ffd3 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -364,7 +364,11 @@ fun SectionScreen( } } items(games) { game -> - GameTile(game = game, onClick = { onPickGame(game) }) + GameTile( + game = game, + playable = game.playableIn(devBuild = showComingSoon), + onClick = { onPickGame(game) }, + ) } } } @@ -403,12 +407,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { +private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) { Card( onClick = onClick, - enabled = game.available, + enabled = playable, colors = CardDefaults.cardColors( - containerColor = if (game.available) { + containerColor = if (playable) { MaterialTheme.colorScheme.surfaceVariant } else { MaterialTheme.colorScheme.surface @@ -427,13 +431,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp), ) + val players = when { + // A puzzle for one, rather than "1 players". + game.maxPlayers == 1 -> "On your own" + game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" + else -> "${game.minPlayers}–${game.maxPlayers} players" + } Text( text = when { - !game.available -> "Coming soon" - // A puzzle for one, rather than "1 players". - game.maxPlayers == 1 -> "On your own" - game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" - else -> "${game.minPlayers}–${game.maxPlayers} players" + !playable -> "Coming soon" + // Playable on this dev build only; production hides it. + !game.available -> "Preview · $players" + else -> players }, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp), diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 80632ea..eca5a11 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -57,6 +57,7 @@ import org.prolibertate.games.game.morris.MorrisOptions import org.prolibertate.games.game.pirates.PiratesLevel import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.president.PresidentOptions +import org.prolibertate.games.game.catan.CatanOptions import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuOptions @@ -104,6 +105,7 @@ fun GameSetupScreen( var freecell by remember { mutableStateOf(FreeCellOptions()) } var spider by remember { mutableStateOf(SpiderOptions()) } var pyramid by remember { mutableStateOf(PyramidOptions()) } + var catan by remember { mutableStateOf(CatanOptions()) } // Not an engine option: which seat the local player takes. Seat 0 is // always White, so choosing Black means sitting in seat 1. var playWhite by remember { mutableStateOf(true) } @@ -134,6 +136,7 @@ fun GameSetupScreen( GameCatalog.FREECELL -> setupJson.encodeToString(freecell) GameCatalog.SPIDER -> setupJson.encodeToString(spider) GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) + GameCatalog.CATAN -> setupJson.encodeToString(catan) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -199,6 +202,8 @@ fun GameSetupScreen( GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } + GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it } + else -> Text("No options yet for this game.") } @@ -303,6 +308,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int { GameCatalog.TAYU -> setupJson.decodeFromString(optionsJson).playerCount + GameCatalog.CATAN -> + setupJson.decodeFromString(optionsJson).playerCount + else -> fallback } }.getOrDefault(fallback) @@ -326,6 +334,27 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId) else -> seat } +@Composable +private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Players", + values = listOf(3, 4), + selected = options.playerCount, + display = { "$it players" }, + onSelect = { onChange(options.copy(playerCount = it)) }, + ) + ChipRow( + label = "Game to", + // Five is a short game for trying things out; ten is the published target. + values = listOf(5, 8, 10, 12), + selected = options.pointsToWin, + display = { "$it points" }, + onSelect = { onChange(options.copy(pointsToWin = it)) }, + ) + } +} + @Composable private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 4235d36..4dda622 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -13,6 +13,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.catan.CatanAi +import org.prolibertate.games.game.catan.CatanMove +import org.prolibertate.games.game.catan.CatanRules +import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.chess.ChessAi import org.prolibertate.games.game.chess.ChessMove import org.prolibertate.games.game.chess.ChessRules @@ -110,6 +114,7 @@ import org.prolibertate.games.game.wizard.WizardState import org.prolibertate.games.net.MatchController import org.prolibertate.games.settings.Settings import org.prolibertate.games.ui.game.BackgammonScreen +import org.prolibertate.games.ui.game.catan.CatanScreen import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.CrazyEightsScreen @@ -668,6 +673,30 @@ fun PlayScreen( ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.CATAN -> { + val controller = remember(route) { + MatchController( + 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.") } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt new file mode 100644 index 0000000..c4cfbfa --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanBoardView.kt @@ -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): 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), + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt new file mode 100644 index 0000000..d475779 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt @@ -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.asCards(): List = + 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, + quota: Int, + legal: List, + onSubmit: (Discard) -> Unit, +) { + var picked by remember { mutableStateOf(emptyMap()) } + 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, + 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, + onPlay: (CatanMove) -> Unit, + onCancel: () -> Unit, +) { + var chosen by remember { mutableStateOf(null) } + var picked by remember { mutableStateOf(emptyList()) } + 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, + onBank: (MaritimeTrade) -> Unit, + onOffer: (OfferTrade) -> Unit, + onCancel: () -> Unit, +) { + var tab by remember { mutableStateOf(TradeTab.BANK) } + var bankGive by remember { mutableStateOf(null) } + var partner by remember { mutableStateOf(null) } + var give by remember { mutableStateOf(emptyMap()) } + var get by remember { mutableStateOf(emptyMap()) } + + 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, + limit: (Resource) -> Int, + onChange: (Map) -> 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) } }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt new file mode 100644 index 0000000..3c33f01 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanGeometry.kt @@ -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 = CatanBoard.HEX_CORNERS[hex].map { vertex(it) } + + fun edgeEnds(edge: Int): Pair { + 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? = + nearest(at, reach, among) { vertex(it) } + + fun nearestEdge(at: Pt, reach: Float, among: Collection): Int? = + nearest(at, reach, among) { edgeMidpoint(it) } + + fun nearestHex(at: Pt, reach: Float, among: Collection): Int? = + nearest(at, reach, among) { hexCenter(it) } + + private fun nearest(at: Pt, reach: Float, among: Collection, 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 diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt new file mode 100644 index 0000000..c7481d0 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -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 = emptySet(), + val edges: Set = emptySet(), + val hexes: Set = 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) : 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) { + 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, + building: BuildKind?, + setupVertex: Int?, + ): BoardTargets { + val setup = legal.filterIsInstance() + 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() + if (robber.isNotEmpty()) { + return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet()) + } + + val roads = legal.filterIsInstance().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().map { it.vertex }.toSet()) + + BuildKind.CITY -> + BoardTargets(vertices = legal.filterIsInstance().map { it.vertex }.toSet()) + + null -> BoardTargets() + } + } + + fun onVertexTap( + vertex: Int, + legal: List, + building: BuildKind?, + setupVertex: Int?, + ): TapOutcome { + val setup = legal.filterIsInstance() + 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, + 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): TapOutcome { + val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored + val moves = legal.filterIsInstance().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): Boolean = legal.any { it is RollDice } + + fun canEndTurn(legal: List): Boolean = legal.any { it is EndTurn } + + fun canEndFreeRoads(legal: List): Boolean = legal.any { it is EndFreeRoads } + + fun canBuyDevCard(legal: List): Boolean = legal.any { it is BuyDevCard } + + fun canBuild(legal: List): Set = 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): Pair = + 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): List = + 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): Map = + plays.filterIsInstance().associateBy { it.resource } + + /** The Year of Plenty move that takes exactly [take], if the engine allows it. */ + fun plentyMove(plays: List, take: List): PlayYearOfPlenty? { + val want = take.groupingBy { it }.eachCount() + return plays.filterIsInstance().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, picked: List): Set { + val chosen = picked.groupingBy { it }.eachCount() + val available = mutableSetOf() + for (play in plays.filterIsInstance()) { + 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, selection: List): Discard? { + val want = selection.groupingBy { it }.eachCount() + return legal.filterIsInstance().firstOrNull { + it.cards.groupingBy { r -> r }.eachCount() == want + } + } + + // --- Trading --------------------------------------------------------------- + + /** Bank trades on offer: what can be given, and for what. */ + fun bankTrades(legal: List): Map> = + legal.filterIsInstance() + .groupBy({ it.give }, { it.get }) + .mapValues { (_, gets) -> gets.sortedBy { it.ordinal } } + .toSortedMap(compareBy { 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, + get: List, + ): Boolean = give.isNotEmpty() && get.isNotEmpty() && + CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get)) +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt new file mode 100644 index 0000000..65fd78d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPanels.kt @@ -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, 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 = 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) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt new file mode 100644 index 0000000..898a03f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanPreviews.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt new file mode 100644 index 0000000..7c58230 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -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, + localSeat: Int, + onExit: () -> Unit, + seatNames: List = 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(null) } + var setupVertex by remember { mutableStateOf(null) } + var victimChoice by remember { mutableStateOf(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, + abandoned: String?, + confirmingEnd: Boolean, + resultDismissed: Boolean, + victimChoice: TapOutcome.PickRobberVictim?, + showCards: Boolean, + showTrade: Boolean, + devHand: List, + 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, + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt new file mode 100644 index 0000000..d7c96e8 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanStyle.kt @@ -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, + /** 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, + val resourceInk: Color, + + // --- Players ------------------------------------------------------------ + /** One colour per seat, in seat order. Roads, settlements, cities, player strip. */ + val seats: List, + /** 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) diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt new file mode 100644 index 0000000..bd40f76 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -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 = 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): 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): 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, get: List): 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" + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index 465e065..4e30831 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -121,6 +121,20 @@ class CatalogTest { 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 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 diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt new file mode 100644 index 0000000..af25535 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt @@ -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): Map = + Resource.entries.associateWith { cards.toMap()[it] ?: 0 } + + fun CatanState.withHand(seat: Int, hand: Map): 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 }) +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt new file mode 100644 index 0000000..69362b2 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanGeometryTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt new file mode 100644 index 0000000..61fb44e --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanInteractionTest.kt @@ -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() + + 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 { + 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().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().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(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))) + } +} diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanTextTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanTextTest.kt new file mode 100644 index 0000000..302019e --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanTextTest.kt @@ -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))) + } +} diff --git a/docs/README.md b/docs/README.md index 5fb3d80..d6ef5a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 [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 (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. diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index c1db5fc..429c117 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -19,8 +19,7 @@ behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`. - **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at least 2. A lower target makes a test game short. -The setup screen that exposes these lands with the screen task; the engine reads -them from the table configuration today. +The setup screen exposes both (players: 3 or 4; game to 5, 8, 10 or 12 points). ## 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 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. -- **The setup screen is not part of this task.** `CatanOptions` (players, target - points) is read from the table configuration; the screen that sets it, and the - catalogue descriptor's `available` flag, land with the screen and verification - tasks. +- **The screen is built, not yet verified on two devices.** The Katan screen and + setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a + dev-channel preview until two-device play has been verified. - **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 strength claim is made. diff --git a/docs/UI-catan.md b/docs/UI-catan.md new file mode 100644 index 0000000..f9ee225 --- /dev/null +++ b/docs/UI-catan.md @@ -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.