Author SHA1 Message Date
Claude 5e84119670 Katan: mark build-menu label lambda @Composable
CI / Build and check (pull_request) Successful in 7m29s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
2026-10-15 18:11:16 +00:00
Claude b90058afda Katan: highlight rolled hexes, one-button Build menu on small screens, clearer harbours
CI / Build and check (pull_request) Failing after 2m39s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
- Hexes that paid out for the turn's roll are lit (not on a 7, not the robber's hex, not before the roll).
- Compact layouts fold Road/Settlement/City into one Build button with a menu.
- Harbour markers are drawn with a resource pictogram (? for 3:1), larger, and sit a full hex radius off the coast so the piers show which vertices they serve.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
2026-10-15 17:40:48 +00:00
devadminandClaude Sonnet 5.5 ef69704e00 Merge pull request 'Katan: no roads past an opponent's settlement, piece limits, stable layout' (#4) from fix/catan-road-rule-and-ui-stability into dev
CI / Build and check (push) Successful in 7m35s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
Katan: no roads past an opponent's settlement, piece limits, stable layout

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-15 08:03:25 -06:00
Claude 3b0b307997 Katan: no roads past an opponent's settlement, piece limits, stable layout
CI / Build and check (pull_request) Successful in 7m22s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Rules
- A road may only start from a crossing where the player has a building, or
  where one of their roads ends AND no opponent's settlement or city stands.
  roadConnects ignored opponents' buildings, so a road (the computer's included)
  could be laid straight past a settlement. Also applies to Road Building.
- Enforce the piece supply: 15 roads, 5 settlements, 4 cities. It was
  unlimited.
- Tests: building past a settlement and a city, free roads, own settlement not
  blocking, and running out of each piece.

UI
- The layout rescaled the board whenever it became the player's turn, because
  the action buttons only existed then. Everything outside the board now has a
  fixed height: the action bar always shows every button (disabled, not absent),
  the prompt is pinned to two lines, player cards always draw every line, and
  the dice total is always present. Rule recorded in docs/UI-catan.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-15 13:55:03 +00:00
devadminandClaude Sonnet 5.5 87b00ad84f Merge pull request 'Katan: playable screen, setup options, dev-preview gate' (#3) from feature/catan-ui into dev
CI / Build and check (push) Successful in 19m17s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
Katan: playable screen, setup options, dev-preview gate

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 20:52:24 -06:00
Claude 30dcf026bd Katan: playable screen, setup options, dev-preview catalogue gate
CI / Build and check (pull_request) Successful in 20m42s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Adds the Katan screen on top of the WALA-119 engine, built in separable layers
so the look, wording, layout and interaction can each be changed alone:

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

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

Tests: CatanGeometryTest, CatanInteractionTest, CatanTextTest, CatalogTest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-14 22:24:45 +00:00
24 changed files with 3791 additions and 33 deletions
@@ -42,7 +42,20 @@ data class GameDescriptor(
val teamBased: Boolean, val teamBased: Boolean,
val blurb: String, val blurb: String,
val available: Boolean, val available: Boolean,
/**
* Playable on a dev build while still hidden from production.
*
* [available] is the release gate: it is what a production build lists and
* lets people open. A game that has an engine and a screen but has not yet
* been verified on real devices sets this instead, so it can be played and
* tested from the dev channel without being offered to everyone. Flip
* [available] on (and this off) once it has been verified.
*/
val devPreview: Boolean = false,
) { ) {
/** Whether the menu should let this game be opened in the given kind of build. */
fun playableIn(devBuild: Boolean): Boolean = available || (devBuild && devPreview)
/** /**
* The one menu this game is listed under. * The one menu this game is listed under.
* *
@@ -348,10 +361,12 @@ object GameCatalog {
maxPlayers = 4, maxPlayers = 4,
teamBased = false, teamBased = false,
blurb = "Trade, build, and settle the island.", blurb = "Trade, build, and settle the island.",
// Listed, not playable: the engine and screen land with the Katan // Engine and screen exist but have not been verified on two real
// game task. Dev builds show the entry as *Coming soon*; a // devices yet. Dev builds can open it as a *Preview*; a production
// production build lists only what can actually be played. // build does not list it. Flip `available` on, and `devPreview`
// off, once two-device play has been verified.
available = false, available = false,
devPreview = true,
), ),
) )
@@ -12,6 +12,11 @@ const val LONGEST_ROAD_MIN = 5
/** Knights required to take Largest Army. */ /** Knights required to take Largest Army. */
const val LARGEST_ARMY_MIN = 3 const val LARGEST_ARMY_MIN = 3
/** Pieces each player has: a player who has laid them all cannot build another. */
const val MAX_ROADS = 15
const val MAX_SETTLEMENTS = 5
const val MAX_CITIES = 4
/** Resource cards of each type the bank starts with. */ /** Resource cards of each type the bank starts with. */
const val BANK_PER_RESOURCE = 19 const val BANK_PER_RESOURCE = 19
@@ -272,11 +272,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return takes return takes
} }
/** Road placements that connect to [seat]'s network, cost aside. */ /**
* Road placements that connect to [seat]'s network, cost aside. A player
* with all fifteen roads laid has none.
*/
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> = private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList()
} else {
(0 until CatanBoard.EDGE_COUNT) (0 until CatanBoard.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) } .filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
.map { BuildRoad(it) } .map { BuildRoad(it) }
}
/** /**
* Settlement placements: an open vertex at least two edges from every * Settlement placements: an open vertex at least two edges from every
@@ -285,15 +292,27 @@ object CatanRules : GameRules<CatanState, CatanMove> {
* own move.) * own move.)
*/ */
private fun settlementVertices(state: CatanState, seat: Int): List<Int> = private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until CatanBoard.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) && vertexOpenForSettlement(state, v) &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
} }
}
private fun cityVertices(state: CatanState, seat: Int): List<Int> = private fun cityVertices(state: CatanState, seat: Int): List<Int> =
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until CatanBoard.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
} }
}
/** How many settlements or cities [seat] currently has standing. */
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
state.buildings.count { it?.owner == seat && it.type == type }
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> { private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
val offer = state.pendingTrade ?: return emptyList() val offer = state.pendingTrade ?: return emptyList()
@@ -310,13 +329,25 @@ object CatanRules : GameRules<CatanState, CatanMove> {
state.buildings[v] == null && state.buildings[v] == null &&
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null } CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
/** A road on [edge] would join [seat]'s network of roads and buildings. */ /**
* A road on [edge] would join [seat]'s network of roads and buildings.
*
* It must start from a crossing the network actually reaches. An opponent's
* settlement or city on a crossing breaks the network there: roads on either
* side of it do not join, so a road cannot be laid *past* it, even with one
* of your own roads ending at the same crossing.
*/
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean { private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
val (a, b) = CatanBoard.EDGE_VERTICES[edge] val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } || return networkReaches(state, seat, a) || networkReaches(state, seat, b)
CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } || }
state.buildings[a]?.owner == seat ||
state.buildings[b]?.owner == seat /** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
val building = state.buildings[vertex]
// Your own building is always a way in; anyone else's is a wall.
if (building != null) return building.owner == seat
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
} }
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean { private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
@@ -364,7 +364,11 @@ fun SectionScreen(
} }
} }
items(games) { game -> items(games) { game ->
GameTile(game = game, onClick = { onPickGame(game) }) GameTile(
game = game,
playable = game.playableIn(devBuild = showComingSoon),
onClick = { onPickGame(game) },
)
} }
} }
} }
@@ -403,12 +407,12 @@ private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { private fun GameTile(game: GameDescriptor, playable: Boolean, onClick: () -> Unit) {
Card( Card(
onClick = onClick, onClick = onClick,
enabled = game.available, enabled = playable,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (game.available) { containerColor = if (playable) {
MaterialTheme.colorScheme.surfaceVariant MaterialTheme.colorScheme.surfaceVariant
} else { } else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
@@ -427,13 +431,18 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
Text( val players = when {
text = when {
!game.available -> "Coming soon"
// A puzzle for one, rather than "1 players". // A puzzle for one, rather than "1 players".
game.maxPlayers == 1 -> "On your own" game.maxPlayers == 1 -> "On your own"
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players" game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
else -> "${game.minPlayers}–${game.maxPlayers} players" else -> "${game.minPlayers}–${game.maxPlayers} players"
}
Text(
text = when {
!playable -> "Coming soon"
// Playable on this dev build only; production hides it.
!game.available -> "Preview · $players"
else -> players
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
@@ -57,6 +57,7 @@ import org.prolibertate.games.game.morris.MorrisOptions
import org.prolibertate.games.game.pirates.PiratesLevel import org.prolibertate.games.game.pirates.PiratesLevel
import org.prolibertate.games.game.pirates.PiratesOptions import org.prolibertate.games.game.pirates.PiratesOptions
import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.sequence.SequenceOptions import org.prolibertate.games.game.sequence.SequenceOptions
import org.prolibertate.games.game.tayu.TayuLevel import org.prolibertate.games.game.tayu.TayuLevel
import org.prolibertate.games.game.tayu.TayuOptions import org.prolibertate.games.game.tayu.TayuOptions
@@ -104,6 +105,7 @@ fun GameSetupScreen(
var freecell by remember { mutableStateOf(FreeCellOptions()) } var freecell by remember { mutableStateOf(FreeCellOptions()) }
var spider by remember { mutableStateOf(SpiderOptions()) } var spider by remember { mutableStateOf(SpiderOptions()) }
var pyramid by remember { mutableStateOf(PyramidOptions()) } var pyramid by remember { mutableStateOf(PyramidOptions()) }
var catan by remember { mutableStateOf(CatanOptions()) }
// Not an engine option: which seat the local player takes. Seat 0 is // Not an engine option: which seat the local player takes. Seat 0 is
// always White, so choosing Black means sitting in seat 1. // always White, so choosing Black means sitting in seat 1.
var playWhite by remember { mutableStateOf(true) } var playWhite by remember { mutableStateOf(true) }
@@ -134,6 +136,7 @@ fun GameSetupScreen(
GameCatalog.FREECELL -> setupJson.encodeToString(freecell) GameCatalog.FREECELL -> setupJson.encodeToString(freecell)
GameCatalog.SPIDER -> setupJson.encodeToString(spider) GameCatalog.SPIDER -> setupJson.encodeToString(spider)
GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid) GameCatalog.PYRAMID -> setupJson.encodeToString(pyramid)
GameCatalog.CATAN -> setupJson.encodeToString(catan)
else -> "{}" else -> "{}"
} }
val seatCount = seatCountFor(descriptor.id, optionsJson) val seatCount = seatCountFor(descriptor.id, optionsJson)
@@ -199,6 +202,8 @@ fun GameSetupScreen(
GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it } GameCatalog.PYRAMID -> PyramidOptionsEditor(pyramid) { pyramid = it }
GameCatalog.CATAN -> CatanOptionsEditor(catan) { catan = it }
else -> Text("No options yet for this game.") else -> Text("No options yet for this game.")
} }
@@ -303,6 +308,9 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
GameCatalog.TAYU -> GameCatalog.TAYU ->
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
GameCatalog.CATAN ->
setupJson.decodeFromString<CatanOptions>(optionsJson).playerCount
else -> fallback else -> fallback
} }
}.getOrDefault(fallback) }.getOrDefault(fallback)
@@ -326,6 +334,27 @@ fun teamForSeat(gameId: String, seat: Int, seatCount: Int): Int = when (gameId)
else -> seat else -> seat
} }
@Composable
private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ChipRow(
label = "Players",
values = listOf(3, 4),
selected = options.playerCount,
display = { "$it players" },
onSelect = { onChange(options.copy(playerCount = it)) },
)
ChipRow(
label = "Game to",
// Five is a short game for trying things out; ten is the published target.
values = listOf(5, 8, 10, 12),
selected = options.pointsToWin,
display = { "$it points" },
onSelect = { onChange(options.copy(pointsToWin = it)) },
)
}
}
@Composable @Composable
private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) { private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -13,6 +13,10 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.chess.ChessAi import org.prolibertate.games.game.chess.ChessAi
import org.prolibertate.games.game.chess.ChessMove import org.prolibertate.games.game.chess.ChessMove
import org.prolibertate.games.game.chess.ChessRules import org.prolibertate.games.game.chess.ChessRules
@@ -110,6 +114,7 @@ import org.prolibertate.games.game.wizard.WizardState
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.BackgammonScreen import org.prolibertate.games.ui.game.BackgammonScreen
import org.prolibertate.games.ui.game.catan.CatanScreen
import org.prolibertate.games.ui.game.CheckersScreen import org.prolibertate.games.ui.game.CheckersScreen
import org.prolibertate.games.ui.game.ChessScreen import org.prolibertate.games.ui.game.ChessScreen
import org.prolibertate.games.ui.game.CrazyEightsScreen import org.prolibertate.games.ui.game.CrazyEightsScreen
@@ -668,6 +673,30 @@ fun PlayScreen(
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
} }
GameCatalog.CATAN -> {
val controller = remember(route) {
MatchController<CatanState, CatanMove>(
rules = CatanRules,
ai = CatanAi(),
config = route.config,
scope = scope,
role = role,
localSeats = setOf(route.localSeat),
primarySeat = route.localSeat,
// A computer's turn is several small moves (roll, build,
// trade, end), so the pause is per move, not per turn.
aiThinkingMillis = { settings.scaled(450L) },
)
}
StartMatch(env, controller, route)
CatanScreen(
controller = controller,
localSeat = route.localSeat,
seatNames = route.config.seats.sortedBy { it.seat }.map { it.name },
onExit = onExit,
)
}
else -> Text("That game isn't playable yet.") else -> Text("That game isn't playable yet.")
} }
} }
@@ -0,0 +1,507 @@
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.geometry.Size
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
import org.prolibertate.games.game.catan.Resource
/**
* 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,
/** Hexes to light up as having produced on the latest roll. */
rolledHexes: Set<Int> = emptySet(),
) {
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)
drawRolled(rolledHexes, board, style)
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)
}
}
/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */
private fun DrawScope.drawRolled(hexes: Set<Int>, board: BoardLayout, style: CatanStyle) {
for (hex in hexes) {
val centre = board.hexCenter(hex)
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) })
drawPath(path, color = style.rolled.copy(alpha = 0.28f))
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f))
drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f))
}
}
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 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),
)
// What the harbour trades: a picture of the resource, or "?" for any.
val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f)
val iconSize = radius * 0.5f
val resource = port.resource
if (resource != null) {
drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline)
} else {
drawCentered(
measurer = measurer,
text = "?",
at = iconAt,
fontPx = board.radius * style.portText * 1.5f,
color = style.portInk,
weight = FontWeight.ExtraBold,
)
}
drawCentered(
measurer = measurer,
text = "${port.ratio}:1",
at = Pt(anchor.x, anchor.y + radius * 0.5f),
fontPx = board.radius * style.portText,
color = style.portInk,
weight = FontWeight.Bold,
)
}
}
/**
* A small pictogram of [resource] centred on [at], about 2·[s] across: a tree,
* bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no
* image assets to ship or scale.
*/
private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) {
val outline = Stroke(width = s * 0.12f)
when (resource) {
Resource.LUMBER -> {
val crown = Path().apply {
moveTo(at.x, at.y - s)
lineTo(at.x + s * 0.8f, at.y + s * 0.3f)
lineTo(at.x - s * 0.8f, at.y + s * 0.3f)
close()
}
drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f))
drawPath(crown, color = fill)
drawPath(crown, color = line, style = outline)
}
Resource.BRICK -> {
val w = s * 0.9f
val h = s * 0.42f
for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) {
val top = at.y - h + row * h
val left = at.x - w + shift - w * 0.25f
for (col in 0..1) {
val x = left + col * w
drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h))
drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline)
}
}
}
Resource.WOOL -> {
for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) {
drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s))
}
drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f))
drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
}
Resource.GRAIN -> {
drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f)
for (i in 0..2) {
val y = at.y - s * 0.7f + i * s * 0.5f
for (side in listOf(-1f, 1f)) {
val kernel = Path().apply {
moveTo(at.x, y + s * 0.3f)
lineTo(at.x + side * s * 0.6f, y - s * 0.05f)
lineTo(at.x + side * s * 0.15f, y - s * 0.2f)
close()
}
drawPath(kernel, color = fill)
drawPath(kernel, color = line, style = Stroke(width = s * 0.08f))
}
}
}
Resource.ORE -> {
val peak = Path().apply {
moveTo(at.x - s, at.y + s * 0.8f)
lineTo(at.x - s * 0.3f, at.y - s * 0.6f)
lineTo(at.x + s * 0.1f, at.y + s * 0.05f)
lineTo(at.x + s * 0.45f, at.y - s * 0.3f)
lineTo(at.x + s, at.y + s * 0.8f)
close()
}
drawPath(peak, color = fill)
drawPath(peak, color = line, style = outline)
}
}
}
// ---------------------------------------------------------------------------
// Pieces
// ---------------------------------------------------------------------------
private fun DrawScope.drawRoads(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (edge in 0 until CatanBoard.EDGE_COUNT) {
val owner = state.roads[edge]
if (owner == NO_PLAYER) continue
val (a, b) = board.edgeEnds(edge)
// Pulled in from the ends so adjacent roads do not fuse into a blob.
val from = lerp(a, b, 0.12f)
val to = lerp(a, b, 0.88f)
val width = board.radius * style.roadWidth
drawLine(
color = style.pieceOutline,
start = from.offset(),
end = to.offset(),
strokeWidth = width * 1.45f,
cap = StrokeCap.Round,
)
drawLine(
color = style.seats[owner % style.seats.size],
start = from.offset(),
end = to.offset(),
strokeWidth = width,
cap = StrokeCap.Round,
)
}
}
private fun DrawScope.drawBuildings(state: CatanState, board: BoardLayout, style: CatanStyle) {
for (vertex in 0 until CatanBoard.VERTEX_COUNT) {
val building = state.buildings[vertex] ?: continue
val at = board.vertex(vertex)
val fill = style.seats[building.owner % style.seats.size]
val path = when (building.type) {
BuildingType.SETTLEMENT -> housePath(at, board.radius * style.settlementSize)
BuildingType.CITY -> cityPath(at, board.radius * style.citySize * 0.8f)
}
drawPath(path, color = fill)
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.045f))
}
}
/** One gable: a settlement. [s] is its half-width. */
private fun housePath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s, at.y + s * 0.7f)
lineTo(at.x - s, at.y - s * 0.1f)
lineTo(at.x, at.y - s)
lineTo(at.x + s, at.y - s * 0.1f)
lineTo(at.x + s, at.y + s * 0.7f)
close()
}
/** Two gables, the taller on the left: a city. [s] is the unit its outline is scaled by. */
private fun cityPath(at: Pt, s: Float): Path = Path().apply {
moveTo(at.x - s, at.y + s * 0.7f)
lineTo(at.x - s, at.y - s * 0.5f)
lineTo(at.x - s * 0.45f, at.y - s * 1.1f)
lineTo(at.x + s * 0.1f, at.y - s * 0.5f)
lineTo(at.x + s * 0.1f, at.y - s * 0.1f)
lineTo(at.x + s * 0.55f, at.y - s * 0.55f)
lineTo(at.x + s * 1.1f, at.y - s * 0.1f)
lineTo(at.x + s * 1.1f, at.y + s * 0.7f)
close()
}
// ---------------------------------------------------------------------------
// Tap targets
// ---------------------------------------------------------------------------
private fun DrawScope.drawVertexTargets(
targets: BoardTargets,
selected: Int?,
board: BoardLayout,
style: CatanStyle,
) {
val radius = board.radius * style.targetRadius
for (vertex in targets.vertices) {
val at = board.vertex(vertex).offset()
drawCircle(color = style.target.copy(alpha = 0.85f), radius = radius, center = at)
drawCircle(
color = style.pieceOutline,
radius = radius,
center = at,
style = Stroke(width = board.radius * 0.04f),
)
}
if (selected != null) {
val at = board.vertex(selected).offset()
drawCircle(
color = style.targetSelected,
radius = radius * 1.6f,
center = at,
style = Stroke(width = board.radius * 0.09f),
)
}
}
private fun DrawScope.drawEdgeTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (edge in targets.edges) {
val (a, b) = board.edgeEnds(edge)
val from = lerp(a, b, 0.15f)
val to = lerp(a, b, 0.85f)
drawLine(
color = style.pieceOutline,
start = from.offset(),
end = to.offset(),
strokeWidth = board.radius * style.roadWidth * 1.5f,
cap = StrokeCap.Round,
)
drawLine(
color = style.target.copy(alpha = 0.9f),
start = from.offset(),
end = to.offset(),
strokeWidth = board.radius * style.roadWidth,
cap = StrokeCap.Round,
)
}
}
private fun DrawScope.drawHexTargets(targets: BoardTargets, board: BoardLayout, style: CatanStyle) {
for (hex in targets.hexes) {
val centre = board.hexCenter(hex)
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.08f) })
drawPath(path, color = style.target.copy(alpha = 0.38f))
drawPath(path, color = style.target, style = Stroke(width = board.radius * 0.07f))
}
}
// ---------------------------------------------------------------------------
// Drawing helpers
// ---------------------------------------------------------------------------
private fun Pt.offset(): Offset = Offset(x, y)
private fun lerp(a: Pt, b: Pt, t: Float): Pt = Pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
/** [p] moved [fraction] of the way toward [centre]. */
private fun insetToward(p: Pt, centre: Pt, fraction: Float): Pt = lerp(p, centre, fraction)
private fun polygon(points: List<Pt>): Path = Path().apply {
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
close()
}
private fun DrawScope.drawCentered(
measurer: TextMeasurer,
text: String,
at: Pt,
fontPx: Float,
color: Color,
weight: FontWeight,
) {
val layout = measurer.measure(
text = text,
style = TextStyle(fontSize = fontPx.toSp(), fontWeight = weight, color = color),
)
drawText(
textLayoutResult = layout,
topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f),
)
}
@@ -0,0 +1,531 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.TradeOffer
/*
* Every Katan dialog. Each is a stateless composable apart from the selection it
* is helping the player make (cards picked, a partner chosen); the move it ends
* in is reported through a lambda and sent by the screen. What is *allowed* is
* always asked of [CatanInteraction], never decided here.
*/
// ---------------------------------------------------------------------------
// Shared pieces
// ---------------------------------------------------------------------------
/** A resource tile with a minus and a plus, for choosing how many cards. */
@Composable
private fun ResourceCounter(
resource: Resource,
count: Int,
max: Int,
onChange: (Int) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
ResourceTile(resource, count, enabled = max > 0 || count > 0)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedButton(
onClick = { onChange(count - 1) },
enabled = count > 0,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("−") }
OutlinedButton(
onClick = { onChange(count + 1) },
enabled = count < max,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.width(34.dp).height(32.dp),
) { Text("+") }
}
}
}
private fun Map<Resource, Int>.asCards(): List<Resource> =
Resource.entries.flatMap { r -> List(this[r] ?: 0) { r } }
// ---------------------------------------------------------------------------
// Discarding on a seven
// ---------------------------------------------------------------------------
/**
* A seven was rolled and this player holds more than seven cards: choose which
* half to give up. Not dismissable — the game is waiting on it.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanDiscardDialog(
held: Map<Resource, Int>,
quota: Int,
legal: List<CatanMove>,
onSubmit: (Discard) -> Unit,
) {
var picked by remember { mutableStateOf(emptyMap<Resource, Int>()) }
val selection = picked.asCards()
val move = CatanInteraction.discardMove(legal, selection)
AlertDialog(
onDismissRequest = {},
title = { Text("Discard $quota cards") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"A seven was rolled. Choose $quota cards to give up " +
"(${selection.size} of $quota chosen).",
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
val have = held[resource] ?: 0
if (have > 0) {
ResourceCounter(
resource = resource,
count = picked[resource] ?: 0,
max = if (selection.size < quota) have else (picked[resource] ?: 0),
onChange = { n -> picked = picked + (resource to n.coerceIn(0, have)) },
)
}
}
}
}
},
confirmButton = {
Button(onClick = { move?.let(onSubmit) }, enabled = move != null) { Text("Discard") }
},
)
}
// ---------------------------------------------------------------------------
// The robber
// ---------------------------------------------------------------------------
/** More than one player can be robbed from this hex: choose whom. */
@Composable
fun CatanVictimDialog(
victims: List<Int>,
seats: CatanSeats,
onPick: (Int) -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Steal from…") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
victims.forEach { seat ->
OutlinedButton(onClick = { onPick(seat) }, modifier = Modifier.fillMaxWidth()) {
Text(seats.label(seat))
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
// ---------------------------------------------------------------------------
// Development cards
// ---------------------------------------------------------------------------
/**
* The player's development cards. A Knight or Road Building plays at once; a
* Monopoly or Year of Plenty asks for its resource choice in a second step.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanPlayCardDialog(
hand: List<DevCardInHand>,
onPlay: (CatanMove) -> Unit,
onCancel: () -> Unit,
) {
var chosen by remember { mutableStateOf<DevCardInHand?>(null) }
var picked by remember { mutableStateOf(emptyList<Resource>()) }
val card = chosen
AlertDialog(
onDismissRequest = onCancel,
title = { Text(card?.let { CatanText.devCard(it.card.type) } ?: "Development cards") },
text = {
Column(
modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
when {
card == null -> {
if (hand.isEmpty()) Text("You have no development cards.")
hand.forEach { entry -> DevCardRow(entry, onPlay) { chosen = it } }
}
card.card.type == DevCardType.MONOPOLY -> {
Text("Name a resource. Every other player hands over all of it.")
val choices = CatanInteraction.monopolyChoices(card.plays)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
val move = choices[resource]
ResourceTile(
resource = resource,
count = 0,
enabled = move != null,
onClick = { move?.let(onPlay) },
)
}
}
}
card.card.type == DevCardType.YEAR_OF_PLENTY -> {
Text("Take two resource cards from the bank (${picked.size} of 2 chosen).")
val available = CatanInteraction.plentyAvailable(card.plays, picked)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
ResourceTile(
resource = resource,
count = picked.count { it == resource },
selected = resource in picked,
enabled = resource in available,
onClick = { picked = picked + resource },
)
}
}
if (picked.isNotEmpty()) {
TextButton(onClick = { picked = emptyList() }) { Text("Clear") }
}
}
}
}
},
confirmButton = {
if (card != null && card.card.type == DevCardType.YEAR_OF_PLENTY) {
val move = CatanInteraction.plentyMove(card.plays, picked)
Button(onClick = { move?.let(onPlay) }, enabled = move != null) { Text("Take") }
}
},
dismissButton = {
TextButton(
onClick = {
if (card == null) {
onCancel()
} else {
chosen = null
picked = emptyList()
}
},
) { Text(if (card == null) CatanText.CANCEL else "Back") }
},
)
}
@Composable
private fun DevCardRow(
entry: DevCardInHand,
onPlay: (CatanMove) -> Unit,
onChoose: (DevCardInHand) -> Unit,
) {
val type = entry.card.type
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(CatanText.devCard(type), fontWeight = FontWeight.Bold)
Text(CatanText.devCardHelp(type), style = MaterialTheme.typography.bodySmall)
}
when {
type == DevCardType.VICTORY_POINT -> Text("Hidden", style = MaterialTheme.typography.labelSmall)
!entry.playable -> Text("Not now", style = MaterialTheme.typography.labelSmall)
entry.plays.size == 1 && (entry.plays[0] is PlayKnight || entry.plays[0] is PlayRoadBuilding) ->
Button(onClick = { onPlay(entry.plays[0]) }) { Text("Play") }
else -> Button(onClick = { onChoose(entry) }) { Text("Play…") }
}
}
}
// ---------------------------------------------------------------------------
// Trading
// ---------------------------------------------------------------------------
private enum class TradeTab { BANK, PLAYER }
/**
* Trade with the bank at the best ratio the player has, or offer cards to one
* other player. Offers are free-form, so they are checked with the engine's
* structural rule ([CatanInteraction.offerAllowed]) rather than looked up in
* the legal list.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanTradeDialog(
state: CatanState,
seat: Int,
seats: CatanSeats,
legal: List<CatanMove>,
onBank: (MaritimeTrade) -> Unit,
onOffer: (OfferTrade) -> Unit,
onCancel: () -> Unit,
) {
var tab by remember { mutableStateOf(TradeTab.BANK) }
var bankGive by remember { mutableStateOf<Resource?>(null) }
var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
AlertDialog(
onDismissRequest = onCancel,
title = { Text(CatanText.TRADE) },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TabButton(CatanText.TRADE_WITH_BANK, tab == TradeTab.BANK) { tab = TradeTab.BANK }
TabButton(CatanText.TRADE_WITH_PLAYER, tab == TradeTab.PLAYER) { tab = TradeTab.PLAYER }
}
if (tab == TradeTab.BANK) {
val trades = CatanInteraction.bankTrades(legal)
if (trades.isEmpty()) {
Text("You do not hold enough of any one resource to trade with the bank.")
} else {
Text("Give:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.keys.forEach { resource ->
val ratio = CatanInteraction.bankRatio(state, seat, resource)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ResourceTile(
resource = resource,
count = ratio,
selected = bankGive == resource,
onClick = { bankGive = resource },
)
Text("$ratio : 1", style = MaterialTheme.typography.labelSmall)
}
}
}
val chosen = bankGive
if (chosen != null && chosen in trades) {
Text("Get one:", fontWeight = FontWeight.Bold)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
trades.getValue(chosen).forEach { resource ->
ResourceTile(
resource = resource,
count = 1,
onClick = { onBank(MaritimeTrade(chosen, resource)) },
)
}
}
}
}
} else {
Text("Offer to:", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(0 until state.playerCount).filter { it != seat }.forEach { other ->
TabButton(seats.label(other), partner == other) { partner = other }
}
}
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
CounterRow(
counts = give,
limit = { state.resources[seat][it] ?: 0 },
onChange = { give = it },
)
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
CounterRow(counts = get, limit = { 9 }, onChange = { get = it })
}
}
},
confirmButton = {
if (tab == TradeTab.PLAYER) {
val to = partner
val giveCards = give.asCards()
val getCards = get.asCards()
val allowed = to != null &&
CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
Button(
onClick = { if (to != null) onOffer(OfferTrade(to, giveCards, getCards)) },
enabled = allowed,
) { Text(CatanText.SEND_OFFER) }
}
},
dismissButton = { TextButton(onClick = onCancel) { Text(CatanText.CANCEL) } },
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CounterRow(
counts: Map<Resource, Int>,
limit: (Resource) -> Int,
onChange: (Map<Resource, Int>) -> Unit,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Resource.entries.forEach { resource ->
ResourceCounter(
resource = resource,
count = counts[resource] ?: 0,
max = limit(resource),
onChange = { n -> onChange(counts + (resource to n.coerceIn(0, limit(resource)))) },
)
}
}
}
@Composable
private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) {
if (selected) {
FilledTonalButton(onClick = onClick) { Text(label) }
} else {
OutlinedButton(onClick = onClick) { Text(label) }
}
}
/** Another player has offered the local player a trade. */
@Composable
fun CatanTradeResponseDialog(
offer: TradeOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("Trade offer") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
CatanText.tradeSummary(
by = seats.label(offer.by),
to = "you",
give = offer.give,
get = offer.get,
),
)
if (!canAccept) {
Text(
"You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } },
dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } },
)
}
// ---------------------------------------------------------------------------
// Ending
// ---------------------------------------------------------------------------
/** The game is over: who won, and what everyone finished with. */
@Composable
fun CatanGameOverDialog(
state: CatanState,
seats: CatanSeats,
onExit: () -> Unit,
onLook: () -> Unit,
) {
val points = (0 until state.playerCount).map { seat ->
// The winner's hidden victory-point cards are revealed by winning; the
// host's copy of the state has them, a client's has only its own.
seat to if (seat == seats.me) CatanRules.victoryPoints(state, seat)
else CatanRules.publicVictoryPoints(state)[seat]
}
AlertDialog(
onDismissRequest = onLook,
title = { Text(CatanText.winnerHeadline(state, seats)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
points.sortedByDescending { it.second }.forEach { (seat, vp) ->
Text("${seats.label(seat)}: $vp points")
}
}
},
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
dismissButton = { TextButton(onClick = onLook) { Text(CatanText.LOOK_AT_BOARD) } },
)
}
@Composable
fun CatanEndGameDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
AlertDialog(
onDismissRequest = onKeepPlaying,
title = { Text("End this game?") },
text = {
Text(
"This game ends here and the position is not saved. Anyone else " +
"at the table is told you have left.",
)
},
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
)
}
@Composable
fun CatanAbandonedDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
AlertDialog(
onDismissRequest = onStay,
title = { Text("The game has ended") },
text = { Text("$notice There are no more moves to play.") },
confirmButton = { TextButton(onClick = onExit) { Text(CatanText.LEAVE_GAME) } },
dismissButton = { TextButton(onClick = onStay) { Text(CatanText.LOOK_AT_BOARD) } },
)
}
@@ -0,0 +1,123 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanBoard
import kotlin.math.sqrt
/** A point in board pixels. Plain floats, so this file needs no Compose types. */
data class Pt(val x: Float, val y: Float)
/**
* Where the island sits on screen: the one place the engine's integer lattice
* (see [CatanBoard]) becomes pixels, and pixels become "which vertex / edge /
* hex did the player tap".
*
* Pure and Android-free, so it is unit-tested (`CatanGeometryTest`). The drawing
* code ([CatanBoardView]) and the tap handling both go through this, which is
* what keeps what is drawn and what is tappable from ever disagreeing.
*
* Lattice to pixel: a hex of circumradius `R` is pointy-top, so one lattice x-unit
* is `R·√3/2` and one lattice y-unit is `R/2`, and lattice +Y points up the board
* while pixel +y points down.
*/
class BoardLayout(
val width: Float,
val height: Float,
/** Circumradius of one hex, in pixels. */
val radius: Float,
) {
private val centreX = width / 2f
private val centreY = height / 2f
private val unitX = radius * SQRT3 / 2f
private val unitY = radius / 2f
fun point(latticeX: Int, latticeY: Int): Pt =
Pt(centreX + latticeX * unitX, centreY - latticeY * unitY)
fun hexCenter(hex: Int): Pt {
val (lx, ly) = CatanBoard.hexCenter(hex)
return point(lx, ly)
}
fun vertex(vertex: Int): Pt {
val (lx, ly) = CatanBoard.VERTEX_POS[vertex]
return point(lx, ly)
}
fun hexCorners(hex: Int): List<Pt> = CatanBoard.HEX_CORNERS[hex].map { vertex(it) }
fun edgeEnds(edge: Int): Pair<Pt, Pt> {
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
return vertex(a) to vertex(b)
}
fun edgeMidpoint(edge: Int): Pt {
val (a, b) = edgeEnds(edge)
return Pt((a.x + b.x) / 2f, (a.y + b.y) / 2f)
}
/**
* Where a harbour's marker is drawn: just off the coast, straight out from
* the middle of its edge. The marker is joined to the edge's two vertices by
* the board view, which is what shows which settlements can use it.
*/
fun portAnchor(edge: Int): Pt {
val mid = edgeMidpoint(edge)
val dx = mid.x - centreX
val dy = mid.y - centreY
val length = sqrt(dx * dx + dy * dy).coerceAtLeast(1f)
return Pt(mid.x + dx / length * radius * PORT_OFFSET, mid.y + dy / length * radius * PORT_OFFSET)
}
/** The nearest of [among] within [reach] pixels of [at], or null. */
fun nearestVertex(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { vertex(it) }
fun nearestEdge(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { edgeMidpoint(it) }
fun nearestHex(at: Pt, reach: Float, among: Collection<Int>): Int? =
nearest(at, reach, among) { hexCenter(it) }
private fun nearest(at: Pt, reach: Float, among: Collection<Int>, position: (Int) -> Pt): Int? {
var best: Int? = null
var bestDistance = reach * reach
for (candidate in among) {
val p = position(candidate)
val dx = p.x - at.x
val dy = p.y - at.y
val d = dx * dx + dy * dy
if (d <= bestDistance) {
best = candidate
bestDistance = d
}
}
return best
}
}
object CatanGeometry {
/** Lattice extents of the island: x spans -5..5, y spans -8..8. */
private const val LATTICE_X_UNITS = 10
private const val LATTICE_Y_UNITS = 16
/** Empty space round the island, in hex radii, for the sea and the harbours. */
const val MARGIN = 1.75f
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. Far enough that its piers are clearly visible. */
internal const val PORT_OFFSET = 1.0f
@@ -0,0 +1,292 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DevCard
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.PlayYearOfPlenty
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
/** What a player can ask to build by tapping the board. */
enum class BuildKind { ROAD, SETTLEMENT, CITY }
/** The parts of the board the player may tap right now. Everything else is inert. */
data class BoardTargets(
val vertices: Set<Int> = emptySet(),
val edges: Set<Int> = emptySet(),
val hexes: Set<Int> = emptySet(),
) {
val isEmpty: Boolean get() = vertices.isEmpty() && edges.isEmpty() && hexes.isEmpty()
}
/** What a tap on the board amounts to. */
sealed interface TapOutcome {
/** Nothing: the tap landed somewhere that is not a legal target. */
data object Ignored : TapOutcome
/** Send this move to the table. */
data class Submit(val move: CatanMove) : TapOutcome
/** Setup: remember this settlement; the road that goes with it is tapped next. */
data class PickSetupVertex(val vertex: Int) : TapOutcome
/** The robber's hex has more than one possible victim: ask which. */
data class PickRobberVictim(val hex: HexCoord, val victims: List<Int>) : TapOutcome
}
/**
* One development card in the local player's hand, and what can be done with it
* right now. [plays] is empty when it cannot be played this turn (bought this
* turn, a card has already been played, a victory-point card, or it is not the
* player's turn).
*/
data class DevCardInHand(val card: DevCard, val plays: List<CatanMove>) {
val playable: Boolean get() = plays.isNotEmpty()
}
/**
* The rules of *tapping*: given the game state, the moves the engine says are
* legal, and what the player has selected, what can be tapped and what a tap does.
*
* Pure functions with no Android and no Compose, so the whole interaction model is
* unit-tested (`CatanInteractionTest`). The screen holds the two bits of UI state
* ([BuildKind] and the chosen setup settlement) and passes them in; it contains no
* game rules of its own. **Anything that decides whether a tap is allowed belongs
* here, never in a composable** — the engine's [legal] list is the single source
* of truth, so the UI can never offer a move the rules would refuse.
*/
object CatanInteraction {
// --- Board ---------------------------------------------------------------
fun targets(
state: CatanState,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
): BoardTargets {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
return if (setupVertex == null) {
BoardTargets(vertices = setup.map { it.vertex }.toSet())
} else {
BoardTargets(edges = setup.filter { it.vertex == setupVertex }.map { it.edge }.toSet())
}
}
val robber = legal.filterIsInstance<MoveRobber>()
if (robber.isNotEmpty()) {
return BoardTargets(hexes = robber.mapNotNull { CatanBoard.hexIndex[it.hex] }.toSet())
}
val roads = legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet()
if (state.phase == CatanPhase.FREE_ROADS) return BoardTargets(edges = roads)
return when (building) {
BuildKind.ROAD -> BoardTargets(edges = roads)
BuildKind.SETTLEMENT ->
BoardTargets(vertices = legal.filterIsInstance<BuildSettlement>().map { it.vertex }.toSet())
BuildKind.CITY ->
BoardTargets(vertices = legal.filterIsInstance<BuildCity>().map { it.vertex }.toSet())
null -> BoardTargets()
}
}
fun onVertexTap(
vertex: Int,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
): TapOutcome {
val setup = legal.filterIsInstance<SetupPlace>()
if (setup.isNotEmpty()) {
// Re-picking is allowed until the road is chosen, so a misplaced
// tap does not commit the settlement.
return if (setup.any { it.vertex == vertex }) {
TapOutcome.PickSetupVertex(vertex)
} else {
TapOutcome.Ignored
}
}
val move: CatanMove = when (building) {
BuildKind.SETTLEMENT -> BuildSettlement(vertex)
BuildKind.CITY -> BuildCity(vertex)
else -> return TapOutcome.Ignored
}
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onEdgeTap(
edge: Int,
legal: List<CatanMove>,
building: BuildKind?,
setupVertex: Int?,
): TapOutcome {
if (legal.any { it is SetupPlace }) {
if (setupVertex == null) return TapOutcome.Ignored
val move = SetupPlace(setupVertex, edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
// Free roads need no build mode; a paid road does.
val canBuild = building == BuildKind.ROAD || legal.any { it is EndFreeRoads }
if (!canBuild) return TapOutcome.Ignored
val move = BuildRoad(edge)
return if (move in legal) TapOutcome.Submit(move) else TapOutcome.Ignored
}
fun onHexTap(hex: Int, legal: List<CatanMove>): TapOutcome {
val coord = CatanBoard.HEXES.getOrNull(hex) ?: return TapOutcome.Ignored
val moves = legal.filterIsInstance<MoveRobber>().filter { it.hex == coord }
return when {
moves.isEmpty() -> TapOutcome.Ignored
moves.size == 1 -> TapOutcome.Submit(moves.first())
else -> TapOutcome.PickRobberVictim(coord, moves.mapNotNull { it.victim })
}
}
// --- Action bar ------------------------------------------------------------
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
/**
* The hexes that paid out for this turn's roll, so the board can light them.
* Empty until the turn's roll has happened (the dice left over from the last
* turn are not this turn's), on a seven, and for the hex the robber sits on.
*/
fun rolledHexes(state: CatanState): Set<Int> {
if (state.lastDice.size != 2) return emptySet()
if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST ||
state.phase == CatanPhase.SETUP_SECOND
) {
return emptySet()
}
val total = state.lastDice.sum()
if (total == 7) return emptySet()
return (0 until CatanBoard.HEX_COUNT)
.filter { state.numbers[it] == total && it != state.robber }
.toSet()
}
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
fun canBuyDevCard(legal: List<CatanMove>): Boolean = legal.any { it is BuyDevCard }
fun canBuild(legal: List<CatanMove>): Set<BuildKind> = buildSet {
if (legal.any { it is BuildRoad }) add(BuildKind.ROAD)
if (legal.any { it is BuildSettlement }) add(BuildKind.SETTLEMENT)
if (legal.any { it is BuildCity }) add(BuildKind.CITY)
}
fun tradeResponses(legal: List<CatanMove>): Pair<Boolean, Boolean> =
legal.any { it is AcceptTrade } to legal.any { it is DeclineTrade }
// --- Development cards -----------------------------------------------------
/** [seat]'s development cards, each with the plays the engine currently allows. */
fun devHand(state: CatanState, seat: Int, legal: List<CatanMove>): List<DevCardInHand> =
state.devHands.getOrNull(seat).orEmpty().map { card ->
DevCardInHand(card, legal.filter { playOf(it) == card.id })
}.sortedWith(compareBy({ it.card.type.ordinal }, { it.card.id }))
private fun playOf(move: CatanMove): Int? = when (move) {
is PlayKnight -> move.card
is PlayRoadBuilding -> move.card
is PlayMonopoly -> move.card
is PlayYearOfPlenty -> move.card
else -> null
}
/** The Monopoly moves for one card, by the resource each would name. */
fun monopolyChoices(plays: List<CatanMove>): Map<Resource, PlayMonopoly> =
plays.filterIsInstance<PlayMonopoly>().associateBy { it.resource }
/** The Year of Plenty move that takes exactly [take], if the engine allows it. */
fun plentyMove(plays: List<CatanMove>, take: List<Resource>): PlayYearOfPlenty? {
val want = take.groupingBy { it }.eachCount()
return plays.filterIsInstance<PlayYearOfPlenty>().firstOrNull {
it.take.groupingBy { r -> r }.eachCount() == want
}
}
/**
* Resources a Year of Plenty can still be asked for, given what is already
* [picked]: those that complete some take the engine allows. Empty once the
* two cards are chosen.
*/
fun plentyAvailable(plays: List<CatanMove>, picked: List<Resource>): Set<Resource> {
val chosen = picked.groupingBy { it }.eachCount()
val available = mutableSetOf<Resource>()
for (play in plays.filterIsInstance<PlayYearOfPlenty>()) {
if (!chosen.all { (resource, n) -> play.take.count { it == resource } >= n }) continue
val remaining = play.take.toMutableList()
picked.forEach { remaining.remove(it) }
available += remaining
}
return available
}
// --- Discarding -----------------------------------------------------------
/** How many cards a player holding [held] must discard on a seven. */
fun discardQuota(held: Int): Int = held / 2
/** The discard move that gives up exactly [selection], if the engine allows it. */
fun discardMove(legal: List<CatanMove>, selection: List<Resource>): Discard? {
val want = selection.groupingBy { it }.eachCount()
return legal.filterIsInstance<Discard>().firstOrNull {
it.cards.groupingBy { r -> r }.eachCount() == want
}
}
// --- Trading ---------------------------------------------------------------
/** Bank trades on offer: what can be given, and for what. */
fun bankTrades(legal: List<CatanMove>): Map<Resource, List<Resource>> =
legal.filterIsInstance<MaritimeTrade>()
.groupBy({ it.give }, { it.get })
.mapValues { (_, gets) -> gets.sortedBy { it.ordinal } }
.toSortedMap(compareBy<Resource> { it.ordinal })
/** What it costs [seat] to give one [resource] to the bank: 4, 3 or 2 cards. */
fun bankRatio(state: CatanState, seat: Int, resource: Resource): Int =
CatanRules.bestPortRatio(state, seat, resource)
/**
* Whether [seat] can offer [give] for [get] to [to] right now. Uses the
* engine's own structural check, since offers are not in the enumerated
* move list.
*/
fun offerAllowed(
state: CatanState,
seat: Int,
to: Int,
give: List<Resource>,
get: List<Resource>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get))
}
@@ -0,0 +1,437 @@
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.height
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.catan.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), and — in the roomier layout — development cards, knights and
* their longest road.
*
* **Fixed height.** Every line is always present (blank when there is nothing to
* say) and never wraps, so the card is the same height whatever the game is
* doing. The board is given whatever room is left, so anything that made this
* card taller or shorter would rescale the island under the player's thumb.
*
* [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. [compact] is the narrow portrait strip (four cards
* across a phone); the default has room for the detail lines.
*/
@Composable
fun CatanPlayerCard(
state: CatanState,
seat: Int,
seats: CatanSeats,
points: Int,
modifier: Modifier = Modifier,
style: CatanStyle = LocalCatanStyle.current,
compact: Boolean = false,
) {
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),
)
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
}
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
if (compact) {
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
} else {
CardLine(
"${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
MaterialTheme.typography.bodySmall,
)
CardLine(
"Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
MaterialTheme.typography.bodySmall,
)
}
val awards = buildList {
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
}
// Always drawn, so the card does not grow when somebody takes an award.
CardLine(
text = awards.joinToString(" · ").ifEmpty { BLANK },
style = MaterialTheme.typography.labelSmall,
weight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
/** A non-breaking space: a line of text with nothing in it, still one line tall. */
private const val BLANK = "\u00A0"
/** One line of a player card: never wraps, so the card's height cannot change. */
@Composable
private fun CardLine(
text: String,
style: TextStyle,
weight: FontWeight? = null,
color: Color = Color.Unspecified,
) {
Text(
text = text,
style = style,
fontWeight = weight,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// ---------------------------------------------------------------------------
// Dice
// ---------------------------------------------------------------------------
/** The two dice as last rolled, with their total. Blank until the first roll. */
@Composable
fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val shown = if (dice.size == 2) dice else listOf(null, null)
shown.forEach { value ->
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.border(1.dp, Color(0x55000000), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = value?.toString() ?: "–",
color = Color(0xFF1C1B1F),
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
textAlign = TextAlign.Center,
)
}
}
// Always drawn ("= –" before the first roll) so the row never changes width.
Text(
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
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 canBuild: Set<BuildKind> = emptySet(),
val building: BuildKind? = null,
val canBuyCard: Boolean = false,
val canPlayCard: Boolean = false,
val canTrade: Boolean = false,
val canEndTurn: Boolean = false,
val canEndFreeRoads: Boolean = false,
/** A setup settlement is chosen and a different one may still be picked. */
val canRepickSetup: Boolean = false,
)
/**
* The buttons for whatever the player can do now. Pure display: every press is a lambda.
*
* **The bar never changes size.** The top row holds the one "primary" action
* (roll, end turn, done with roads, cancel) and keeps its height when it is
* empty; the second row is always all six buttons, greyed out when they do not
* apply. Buttons appearing and disappearing used to resize this area and, with
* it, the board above — so nothing here may be conditionally *present*, only
* conditionally *enabled*.
*/
@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,
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
compact: Boolean = false,
) {
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (compact) {
BuildMenuButton(actions, onBuild)
} else {
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
}
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
}
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
}
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
}
}
}
}
/** Room for one button, held whether or not there is one to show. */
private val PRIMARY_ROW_HEIGHT = 48.dp
@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) }
}
}
/**
* The small-screen stand-in for the three build buttons: one "Build" button
* whose menu lists Road, Settlement and City with their costs. While a build
* mode is armed the button shows which, and choosing that entry again cancels it.
*/
@Composable
private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) {
var open by remember { mutableStateOf(false) }
val armed = actions.building
val anyEnabled = actions.canBuild.isNotEmpty() || armed != null
val label: @Composable () -> Unit = {
LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST)
}
Box {
if (armed != null) {
FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() }
} else {
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
for (kind in BuildKind.entries) {
DropdownMenuItem(
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
enabled = kind in actions.canBuild,
onClick = {
open = false
onBuild(if (armed == kind) null else kind)
},
)
}
}
}
}
private fun buildLabel(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
BuildKind.CITY -> CatanText.CITY
}
private fun buildCost(kind: BuildKind): String = when (kind) {
BuildKind.ROAD -> CatanText.ROAD_COST
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
BuildKind.CITY -> CatanText.CITY_COST
}
@Composable
private fun LabelWithCost(label: String, cost: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(label)
Text(cost, fontSize = 9.sp, maxLines = 1)
}
}
// ---------------------------------------------------------------------------
// Log
// ---------------------------------------------------------------------------
/** The most recent happenings, newest last. Scrolls. */
@Composable
fun CatanLog(state: CatanState, modifier: Modifier = Modifier) {
if (state.log.isEmpty()) {
Text(CatanText.NO_LOG, style = MaterialTheme.typography.bodySmall, modifier = modifier)
return
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
state.log.takeLast(40).forEach {
Text(text = it, style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -0,0 +1,132 @@
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), compact = true)
}
}
CatanHand(state, seat = 0)
CatanDice(state.lastDice)
CatanActionBar(
actions = CatanActions(
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
building = BuildKind.ROAD,
canBuyCard = true,
canTrade = true,
canEndTurn = true,
),
onRoll = {}, onBuild = {}, onBuyCard = {}, onPlayCard = {}, onTrade = {},
onEndTurn = {}, onEndFreeRoads = {}, onRepickSetup = {},
)
}
}
@@ -0,0 +1,396 @@
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.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.BuyDevCard
import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.RollDice
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.ui.ScreenScaffold
/**
* Katan, on the island.
*
* This composable is only wiring. It reads the match from the controller, holds
* the handful of purely visual choices (which build mode is armed, which setup
* settlement is waiting for its road, which dialog is open), and hands
* everything else to single-purpose pieces:
*
* | Concern | Where it lives |
* | ------------------------------ | -------------------------- |
* | Colours, sizes, shapes | [CatanStyle] |
* | Every word on screen | [CatanText] |
* | Island → pixels, taps → hexes | [CatanGeometry] |
* | What is tappable, what a tap does | [CatanInteraction] |
* | Drawing the island | [CatanBoardView] |
* | Cards, players, dice, buttons | `CatanPanels.kt` |
* | Every dialog | `CatanDialogs.kt` |
*
* There are no game rules here: the engine's legal-move list is the only source
* of what the player may do, so the screen cannot offer a move the rules refuse.
* See `docs/UI-catan.md` for how to adapt the look and feel.
*/
@Composable
fun CatanScreen(
controller: MatchController<CatanState, CatanMove>,
localSeat: Int,
onExit: () -> Unit,
seatNames: List<String> = emptyList(),
) {
val state by controller.state.collectAsState()
val legal by controller.legalMoves.collectAsState()
val abandoned by controller.abandoned.collectAsState()
val notice by controller.notice.collectAsState()
// Purely visual state. Anything that affects the game goes through submit().
var building by remember { mutableStateOf<BuildKind?>(null) }
var setupVertex by remember { mutableStateOf<Int?>(null) }
var victimChoice by remember { mutableStateOf<TapOutcome.PickRobberVictim?>(null) }
var showCards by remember { mutableStateOf(false) }
var showTrade by remember { mutableStateOf(false) }
var confirmingEnd by remember { mutableStateOf(false) }
var resultDismissed by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme()
val style = remember(dark) { catanStyle(dark) }
val seats = remember(localSeat, seatNames) { CatanSeats(localSeat, seatNames) }
// The engine's list changes after every move; drop any selection it no
// longer supports (a build mode the player can no longer afford, a setup
// settlement that is no longer pending).
LaunchedEffect(legal) {
if (building != null && building !in CatanInteraction.canBuild(legal)) building = null
if (setupVertex != null && legal.none { it is SetupPlace && it.vertex == setupVertex }) {
setupVertex = null
}
if (legal.isEmpty()) {
showCards = false
showTrade = false
victimChoice = null
}
}
val submit: (CatanMove) -> Unit = { move ->
controller.submit(move)
building = null
setupVertex = null
victimChoice = null
showCards = false
showTrade = false
}
val endGameAction: @Composable () -> Unit = {
TextButton(onClick = { confirmingEnd = true }) { Text(CatanText.END_GAME) }
}
val current = state
if (current == null) {
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
abandoned?.let { CatanAbandonedDialog(it, onExit, controller::dismissAbandoned) }
if (confirmingEnd) CatanEndGameDialog(onExit) { confirmingEnd = false }
}
return
}
val targets = CatanInteraction.targets(current, legal, building, setupVertex)
val devHand = CatanInteraction.devHand(current, localSeat, legal)
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
val actions = CatanActions(
canRoll = CatanInteraction.canRoll(legal),
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 rolledHexes = CatanInteraction.rolledHexes(current)
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
// Phones (either way up) get the one-button build menu.
val compactBuild = !wide || maxWidth < 600.dp
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,
rolledHexes = rolledHexes,
)
}
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 },
compact = compactBuild,
)
}
}
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,
compact = !twoPerRow,
)
}
// Keep an odd last row's card the same width as the others.
if (twoPerRow && row.size == 1) Box(Modifier.weight(1f))
}
}
}
}
/**
* The one line telling the player what is going on, and the dice.
*
* **Fixed height.** The text always occupies exactly two lines (padded when it is
* shorter, clipped when it is longer) and a refused move replaces the prompt
* rather than adding to it, so this row is the same height whatever is said and
* the board below it never moves.
*/
@Composable
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = notice ?: prompt,
style = MaterialTheme.typography.bodyMedium,
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
CatanDice(state.lastDice)
}
}
/**
* Chooses which dialog, if any, is showing. Pulled out of [CatanScreen] so the
* priority order — an ended table beats a result beats a forced discard beats
* an optional picker — is in one readable place.
*/
@Composable
private fun CatanDialogHost(
state: CatanState,
seats: CatanSeats,
localSeat: Int,
legal: List<CatanMove>,
abandoned: String?,
confirmingEnd: Boolean,
resultDismissed: Boolean,
victimChoice: TapOutcome.PickRobberVictim?,
showCards: Boolean,
showTrade: Boolean,
devHand: List<DevCardInHand>,
onExit: () -> Unit,
onSubmit: (CatanMove) -> Unit,
onStay: () -> Unit,
onKeepPlaying: () -> Unit,
onDismissResult: () -> Unit,
onCancelVictim: () -> Unit,
onCancelCards: () -> Unit,
onCancelTrade: () -> Unit,
) {
val offer = state.pendingTrade
when {
abandoned != null -> CatanAbandonedDialog(abandoned, onExit, onStay)
confirmingEnd -> CatanEndGameDialog(onEnd = onExit, onKeepPlaying = onKeepPlaying)
state.phase == CatanPhase.GAME_OVER ->
if (!resultDismissed) CatanGameOverDialog(state, seats, onExit, onDismissResult)
state.phase == CatanPhase.DISCARDING && state.discardSeat == localSeat ->
CatanDiscardDialog(
held = state.resources[localSeat],
quota = CatanInteraction.discardQuota(state.resourceCount(localSeat)),
legal = legal,
onSubmit = onSubmit,
)
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat ->
CatanTradeResponseDialog(
offer = offer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
)
victimChoice != null ->
CatanVictimDialog(
victims = victimChoice.victims,
seats = seats,
onPick = { victim -> onSubmit(MoveRobber(victimChoice.hex, victim)) },
onCancel = onCancelVictim,
)
showCards -> CatanPlayCardDialog(devHand, onPlay = onSubmit, onCancel = onCancelCards)
showTrade ->
CatanTradeDialog(
state = state,
seat = localSeat,
seats = seats,
legal = legal,
onBank = onSubmit,
onOffer = onSubmit,
onCancel = onCancelTrade,
)
}
}
@@ -0,0 +1,133 @@
package org.prolibertate.games.ui.game.catan
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
/**
* Every visual decision for the Katan screen, in one place.
*
* **To restyle Katan, edit this file.** Nothing else in `ui/game/catan/` hard-
* codes a colour or a proportion: the board, the hand, the player strip and the
* dialogs all read a [CatanStyle] from [LocalCatanStyle]. A different art
* direction is a different [CatanStyle] value (see [catanStyle]); a new theme
* can be supplied at the screen's root with
* `CompositionLocalProvider(LocalCatanStyle provides myStyle)`.
*
* Wording is the same story in [CatanText]; geometry is in [CatanGeometry].
*
* Proportions are fractions of the *hex radius* (centre of a hex to one of its
* corners), so they scale with the board whatever the screen size.
*/
@Immutable
data class CatanStyle(
// --- Board surfaces -----------------------------------------------------
/** The water around the island. */
val sea: Color,
/** Fill of each hex by terrain. */
val terrain: Map<Terrain, Color>,
/** Outline between hexes. */
val hexEdge: Color,
/** The number-token disc, and the ink for ordinary and hot (6 / 8) numbers. */
val token: Color,
val tokenInk: Color,
val tokenHotInk: Color,
/** The robber's pawn. */
val robber: Color,
/** Outline and wash on hexes that produced for the latest dice roll. */
val rolled: Color,
/** Harbour marker disc and its ink. */
val portMarker: Color,
val portInk: Color,
// --- Resources (hand tiles, trade pickers, harbour markers) -------------
val resource: Map<Resource, Color>,
val resourceInk: Color,
// --- Players ------------------------------------------------------------
/** One colour per seat, in seat order. Roads, settlements, cities, player strip. */
val seats: List<Color>,
/** Outline drawn round every piece so any seat colour reads on any terrain. */
val pieceOutline: Color,
// --- Interaction feedback ----------------------------------------------
/** Marks where the player may build or move right now. */
val target: Color,
/** The setup settlement that has been chosen and is waiting for its road. */
val targetSelected: Color,
// --- Metrics (fractions of the hex radius) ------------------------------
/** How far each tile polygon is pulled in from its corners, leaving a gap. */
val hexInset: Float = 0.04f,
val roadWidth: Float = 0.20f,
val settlementSize: Float = 0.30f,
val citySize: Float = 0.42f,
val tokenRadius: Float = 0.36f,
val robberSize: Float = 0.34f,
val portRadius: Float = 0.46f,
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.27f,
)
/** 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),
rolled = Color(0xFFFFFFFF),
portMarker = Color(0xFFFFF8E1),
portInk = Color(0xFF2B2B2B),
resource = mapOf(
Resource.LUMBER to Color(0xFF2F6B3A),
Resource.BRICK to Color(0xFFB4532F),
Resource.WOOL to Color(0xFF8BC34A),
Resource.GRAIN to Color(0xFFE0B020),
Resource.ORE to Color(0xFF6B7684),
),
resourceInk = Color(0xFFFFFFFF),
seats = listOf(
Color(0xFF1565C0), // blue
Color(0xFFEF6C00), // orange
Color(0xFFF5F5F5), // white
Color(0xFF8E24AA), // purple
),
pieceOutline = Color(0xFF1B1B1B),
target = Color(0xFFFFEB3B),
targetSelected = Color(0xFF00E5FF),
)
/** Deeper sea and slightly dimmer land so the board is not a glare at night. */
private val DarkStyle = LightStyle.copy(
sea = Color(0xFF14354D),
terrain = LightStyle.terrain.mapValues { (_, c) -> c.dim(0.82f) },
token = Color(0xFFE9DFC4),
)
/** The style for the current light / dark setting. */
fun catanStyle(dark: Boolean): CatanStyle = if (dark) DarkStyle else LightStyle
/**
* What the Katan composables read their look from. Defaults to the light style
* so a preview or a test with no provider still draws something sensible;
* [CatanScreen] provides the right one for the current theme.
*/
val LocalCatanStyle = staticCompositionLocalOf { LightStyle }
private fun Color.dim(factor: Float): Color =
Color(red * factor, green * factor, blue * factor, alpha)
@@ -0,0 +1,219 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Port
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
/**
* Who is who at the table, for wording. [names] may be empty or short — a
* client device does not always know everyone's name — and the rest fall back
* to "Seat N".
*/
class CatanSeats(val me: Int, private val names: List<String> = emptyList()) {
fun label(seat: Int): String = when {
seat == me -> "You"
else -> names.getOrNull(seat)?.takeIf { it.isNotBlank() } ?: "Seat ${seat + 1}"
}
/** For mid-sentence use: "you" rather than "You". */
fun lower(seat: Int): String = if (seat == me) "you" else label(seat)
}
/**
* Every word the Katan screen shows, in one place.
*
* **To change copy, or to localise, edit this file.** The composables never
* build a sentence themselves; they ask for it here. Keeping the sentences as
* plain functions of state also means they are unit-tested (`CatanTextTest`)
* without a device.
*
* "Katan" is this app's name for the game (see `docs/RULES-catan.md`); the
* resources are lumber, brick, wool, grain and ore.
*/
object CatanText {
const val TITLE = "Katan"
// --- Names ---------------------------------------------------------------
fun resource(resource: Resource): String = when (resource) {
Resource.LUMBER -> "Lumber"
Resource.BRICK -> "Brick"
Resource.WOOL -> "Wool"
Resource.GRAIN -> "Grain"
Resource.ORE -> "Ore"
}
fun terrain(terrain: Terrain): String = when (terrain) {
Terrain.FOREST -> "Forest"
Terrain.PASTURE -> "Pasture"
Terrain.FIELDS -> "Fields"
Terrain.HILLS -> "Hills"
Terrain.MOUNTAINS -> "Mountains"
Terrain.DESERT -> "Desert"
}
fun devCard(type: DevCardType): String = when (type) {
DevCardType.KNIGHT -> "Knight"
DevCardType.ROAD_BUILDING -> "Road building"
DevCardType.MONOPOLY -> "Monopoly"
DevCardType.YEAR_OF_PLENTY -> "Year of plenty"
DevCardType.VICTORY_POINT -> "Victory point"
}
fun devCardHelp(type: DevCardType): String = when (type) {
DevCardType.KNIGHT -> "Move the robber and steal a card. Three played knights take Largest Army."
DevCardType.ROAD_BUILDING -> "Build two roads for free."
DevCardType.MONOPOLY -> "Name a resource and take every card of it from the other players."
DevCardType.YEAR_OF_PLENTY -> "Take any two resource cards from the bank."
DevCardType.VICTORY_POINT -> "One victory point. Kept hidden until you win."
}
/** "3:1" for a generic harbour, "2:1" for a resource one. */
fun portRatio(port: Port): String = "${port.ratio}:1"
fun portName(port: Port): String =
port.resource?.let { "${portRatio(port)} ${resource(it).lowercase()}" } ?: "3:1 any"
// --- Costs ---------------------------------------------------------------
fun cost(cost: Map<Resource, Int>): String =
cost.entries.joinToString(" + ") { (r, n) -> "$n ${resource(r).lowercase()}" }
val ROAD_COST: String get() = cost(CatanRules.ROAD_COST)
val SETTLEMENT_COST: String get() = cost(CatanRules.SETTLEMENT_COST)
val CITY_COST: String get() = cost(CatanRules.CITY_COST)
val DEV_COST: String get() = cost(CatanRules.DEV_COST)
// --- Buttons and headings --------------------------------------------------
const val ROLL = "Roll dice"
const val END_TURN = "End turn"
const val ROAD = "Road"
const val SETTLEMENT = "Settlement"
const val CITY = "City"
const val BUY_CARD = "Buy card"
const val PLAY_CARD = "Play card"
const val TRADE = "Trade"
const val CANCEL = "Cancel"
const val CONFIRM = "Confirm"
const val STOP_ROADS = "Done with roads"
const val BUILD = "Build"
/**
* The empty second line of a button that has no cost, so every button in the
* action bar is the same height. A non-breaking space: blank, but a line.
*/
const val NO_COST = "\u00A0"
const val YOUR_HAND = "Your hand"
const val CARDS = "cards"
const val ACCEPT = "Accept"
const val DECLINE = "Decline"
const val TRADE_WITH_PLAYER = "Offer to a player"
const val TRADE_WITH_BANK = "Trade with the bank"
const val YOU_GIVE = "You give"
const val YOU_GET = "You get"
const val SEND_OFFER = "Send offer"
const val NO_LOG = "Nothing has happened yet."
const val END_GAME = "End game"
const val LOOK_AT_BOARD = "Look at the board"
const val LEAVE_GAME = "Leave the game"
// --- The prompt line ------------------------------------------------------
/**
* The one line that tells the player what is going on and what to do.
*
* [setupVertexChosen] is the setup settlement already picked and waiting
* for its road; [building] is the build mode the player has entered, if any.
*/
fun prompt(
state: CatanState,
seats: CatanSeats,
setupVertexChosen: Boolean,
building: BuildKind?,
): String {
val me = seats.me
val onClock = CatanRules.currentSeat(state)
val mine = onClock == me
val turn = seats.label(state.turn)
return when (state.phase) {
CatanPhase.GAME_OVER -> {
val winner = state.winner
if (winner == null) "The game is over." else "${seats.label(winner)} won the game."
}
CatanPhase.SETUP_FIRST, CatanPhase.SETUP_SECOND -> when {
!mine -> "${seats.label(onClock ?: state.turn)} is placing a settlement and a road…"
setupVertexChosen -> "Now tap the road to place beside that settlement."
state.phase == CatanPhase.SETUP_SECOND ->
"Place your second settlement. It earns resources from the hexes around it."
else -> "Place a settlement, then a road."
}
CatanPhase.AWAIT_ROLL ->
if (mine) "Your turn. Roll the dice." else "$turn is rolling…"
CatanPhase.DISCARDING -> {
val discarder = state.discardSeat
when {
discarder == me -> "A seven! You hold ${state.resourceCount(me)} cards; " +
"discard ${state.resourceCount(me) / 2}."
discarder != null -> "A seven! ${seats.label(discarder)} must discard half."
else -> "A seven! Discarding…"
}
}
CatanPhase.ROBBER ->
if (mine) "Move the robber. Tap a hex." else "$turn is moving the robber…"
CatanPhase.PLAYING -> when {
!mine -> "$turn is playing…"
building == BuildKind.ROAD -> "Tap an edge to build a road."
building == BuildKind.SETTLEMENT -> "Tap an empty crossing to build a settlement."
building == BuildKind.CITY -> "Tap one of your settlements to upgrade it."
else -> "Your turn. Build, trade, or end the turn."
}
CatanPhase.FREE_ROADS ->
if (mine) {
"Road building: place ${state.pendingFreeRoads} more " +
if (state.pendingFreeRoads == 1) "road." else "roads."
} else {
"$turn is placing free roads…"
}
CatanPhase.TRADE_PENDING -> {
val offer = state.pendingTrade
when {
offer == null -> "Waiting for a reply…"
offer.to == me -> "${seats.label(offer.by)} offers you a trade."
offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…"
else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade."
}
}
}
}
/** "3 Lumber + 1 Brick" for a flat list of cards. */
fun cards(cards: List<Resource>): String =
cards.groupingBy { it }.eachCount().entries
.sortedBy { it.key.ordinal }
.joinToString(" + ") { (r, n) -> "$n ${resource(r)}" }
.ifEmpty { "nothing" }
fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
"$by offers ${cards(give)} to $to for ${cards(get)}"
fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
val winner = state.winner ?: return "Game over"
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
}
}
@@ -121,6 +121,20 @@ class CatalogTest {
assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction) assertEquals(listOf(GameCatalog.byId(GameCatalog.CATAN)!!), hiddenFromProduction)
} }
@Test
fun `a preview game opens on a dev build and nowhere else`() {
val preview = GameCatalog.all.filter { it.devPreview }
assertTrue("Katan is the preview game", preview.map { it.id } == listOf(GameCatalog.CATAN))
preview.forEach { game ->
assertFalse("${game.id}: preview is for games that are not yet available", game.available)
assertTrue("${game.id} should open on a dev build", game.playableIn(devBuild = true))
assertFalse("${game.id} must not open in production", game.playableIn(devBuild = false))
}
// An ordinary finished game opens everywhere.
val chess = GameCatalog.byId(GameCatalog.CHESS)!!
assertTrue(chess.playableIn(devBuild = true) && chess.playableIn(devBuild = false))
}
@Test @Test
fun `hiding unfinished games still leaves something to play in every menu`() { fun `hiding unfinished games still leaves something to play in every menu`() {
// A menu filtered down to nothing would leave a stranded heading, and a // A menu filtered down to nothing would leave a stranded heading, and a
@@ -340,6 +340,89 @@ class CatanRulesTest {
) )
} }
/** Seat 0 owns a settlement at vertex 0 and a two-road chain v0 - w - x; `beyond` continues past x. */
private data class Chain(val state: CatanState, val x: Int, val beyond: Int, val spur: Int?)
private fun chain(): Chain {
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 beyond = CatanBoard.VERTEX_EDGES[x].first { it != e2 }
val spur = CatanBoard.VERTEX_EDGES[w].firstOrNull { it != e1 && it != e2 }
val state = craft()
.copy(resources = hands(hand(Resource.LUMBER to 5, Resource.BRICK to 5)))
.withBuilding(0, BuildingType.SETTLEMENT, 0)
.withRoad(e1, 0)
.withRoad(e2, 0)
return Chain(state, x, beyond, spur)
}
@Test
fun `a road cannot be built past an opponent's settlement`() {
val (open, x, beyond, spur) = chain()
assertTrue("open crossing: the road continues", BuildRoad(beyond) in CatanRules.legalMoves(open, 0))
val blocked = open.withBuilding(x, BuildingType.SETTLEMENT, 1)
assertFalse(
"an opponent's settlement on the crossing breaks the network",
BuildRoad(beyond) in CatanRules.legalMoves(blocked, 0),
)
// The same through a city, and while laying free roads from Road Building.
val city = open.withBuilding(x, BuildingType.CITY, 1)
assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(city, 0))
val free = blocked.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(free, 0))
// The rest of the network is untouched: the spur at the other crossing still works.
if (spur != null) assertTrue(BuildRoad(spur) in CatanRules.legalMoves(blocked, 0))
}
@Test
fun `your own settlement on the crossing does not block your road`() {
val (open, x, beyond, _) = chain()
val own = open.withBuilding(x, BuildingType.SETTLEMENT, 0)
assertTrue(BuildRoad(beyond) in CatanRules.legalMoves(own, 0))
}
@Test
fun `a player runs out of pieces`() {
val rich = hands(
hand(
Resource.LUMBER to 9, Resource.BRICK to 9, Resource.WOOL to 9,
Resource.GRAIN to 9, Resource.ORE to 9,
),
)
// Roads: fifteen, and no sixteenth - in the main phase or from Road Building.
var roads = craft().copy(resources = rich).withBuilding(0, BuildingType.SETTLEMENT, 0)
repeat(14) {
roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance<BuildRoad>().first().edge, 0)
}
assertTrue("fourteen laid: one more is allowed", CatanRules.legalMoves(roads, 0).any { it is BuildRoad })
roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance<BuildRoad>().first().edge, 0)
assertEquals(15, roads.roads.count { it == 0 })
assertTrue("fifteen laid: none left", CatanRules.legalMoves(roads, 0).none { it is BuildRoad })
val free = roads.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
assertEquals(listOf<CatanMove>(EndFreeRoads), CatanRules.legalMoves(free, 0))
// Settlements: five.
val homes = scattered(7)
var settled = craft().copy(resources = rich)
for (i in 0 until 4) settled = settled.withBuilding(homes[i], BuildingType.SETTLEMENT, 0)
settled = settled.withRoad(CatanBoard.VERTEX_EDGES[homes[5]][0], 0)
assertTrue("four standing: a fifth is allowed", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0))
settled = settled.withBuilding(homes[4], BuildingType.SETTLEMENT, 0)
assertFalse("five standing: no sixth", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0))
// Cities: four. A raised settlement returns to the supply, so five settlements stays possible.
var raised = craft().copy(resources = rich)
for (i in 0 until 3) raised = raised.withBuilding(homes[i], BuildingType.CITY, 0)
raised = raised.withBuilding(homes[3], BuildingType.SETTLEMENT, 0)
assertTrue("three cities: a fourth is allowed", BuildCity(homes[3]) in CatanRules.legalMoves(raised, 0))
raised = raised.withBuilding(homes[3], BuildingType.CITY, 0).withBuilding(homes[4], BuildingType.SETTLEMENT, 0)
assertFalse("four cities: no fifth", BuildCity(homes[4]) in CatanRules.legalMoves(raised, 0))
}
@Test @Test
fun `a settlement needs an open vertex on the network, a city one of yours`() { fun `a settlement needs an open vertex on the network, a city one of yours`() {
val base = craft() val base = craft()
@@ -0,0 +1,53 @@
package org.prolibertate.games.ui.game.catan
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanBuilding
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** Shared fixtures for the Katan UI-logic tests: real islands, bent to taste. */
internal object CatanFixtures {
private val json = Json { encodeDefaults = true }
fun config(players: Int = 3, seed: Long = 7L) = TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until players).map { PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) },
optionsJson = json.encodeToString(CatanOptions(playerCount = players)),
seed = seed,
)
/** A fresh table, on the first setup placement. */
fun start(players: Int = 3, seed: Long = 7L): CatanState =
CatanRules.initialState(config(players, seed))
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
fun playing(
turn: Int = 0,
players: Int = 3,
adjust: CatanState.() -> CatanState = { this },
): CatanState = start(players)
.copy(phase = CatanPhase.PLAYING, turn = turn, turnNumber = 1)
.adjust()
fun hand(vararg cards: Pair<Resource, Int>): Map<Resource, Int> =
Resource.entries.associateWith { cards.toMap()[it] ?: 0 }
fun CatanState.withHand(seat: Int, hand: Map<Resource, Int>): CatanState =
copy(resources = resources.toMutableList().also { it[seat] = hand })
fun CatanState.withBuilding(vertex: Int, type: BuildingType, owner: Int): CatanState =
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, owner) })
fun CatanState.withRoad(edge: Int, owner: Int): CatanState =
copy(roads = roads.toMutableList().also { it[edge] = owner })
}
@@ -0,0 +1,117 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.catan.CatanBoard
import kotlin.math.abs
import kotlin.math.sqrt
class CatanGeometryTest {
private val sizes = listOf(360f to 800f, 800f to 360f, 500f to 500f, 1000f to 1000f / CatanGeometry.ASPECT)
private fun dist(a: Pt, b: Pt): Float = sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
@Test
fun `the whole island fits inside the box at every shape`() {
for ((w, h) in sizes) {
val layout = CatanGeometry.fit(w, h)
for (v in 0 until CatanBoard.VERTEX_COUNT) {
val p = layout.vertex(v)
assertTrue("vertex $v x=${p.x} outside 0..$w", p.x in 0f..w)
assertTrue("vertex $v y=${p.y} outside 0..$h", p.y in 0f..h)
}
for (e in CatanBoard.PORT_POSITIONS) {
val p = layout.portAnchor(e)
assertTrue("harbour marker x=${p.x} outside 0..$w at ${w}x$h", p.x in 0f..w)
assertTrue("harbour marker y=${p.y} outside 0..$h at ${w}x$h", p.y in 0f..h)
}
}
}
@Test
fun `the layout is as large as the box allows`() {
// Tall and narrow is limited by width, short and wide by height; either
// way the island should use most of the limiting side.
val tall = CatanGeometry.fit(360f, 800f)
val wide = CatanGeometry.fit(800f, 360f)
assertTrue(tall.radius > 20f)
assertTrue(wide.radius > 20f)
val xs = (0 until CatanBoard.VERTEX_COUNT).map { tall.vertex(it).x }
assertTrue("island should span most of the width", xs.max() - xs.min() > 360f * 0.7f)
}
@Test
fun `every edge is one hex radius long and every hex corner is one radius from its centre`() {
val layout = CatanGeometry.fit(500f, 500f)
for (e in 0 until CatanBoard.EDGE_COUNT) {
val (a, b) = layout.edgeEnds(e)
assertEquals("edge $e length", layout.radius, dist(a, b), layout.radius * 0.01f)
}
for (h in 0 until CatanBoard.HEX_COUNT) {
val centre = layout.hexCenter(h)
for (corner in layout.hexCorners(h)) {
assertEquals("hex $h corner", layout.radius, dist(centre, corner), layout.radius * 0.01f)
}
}
}
@Test
fun `all fifty-four vertices sit at distinct places`() {
val layout = CatanGeometry.fit(500f, 500f)
val places = (0 until CatanBoard.VERTEX_COUNT).map {
val p = layout.vertex(it)
Math.round(p.x) to Math.round(p.y)
}.toSet()
assertEquals(CatanBoard.VERTEX_COUNT, places.size)
}
@Test
fun `a tap on a vertex finds that vertex, and a tap far away finds nothing`() {
val layout = CatanGeometry.fit(500f, 500f)
val all = (0 until CatanBoard.VERTEX_COUNT).toList()
for (v in all) {
val p = layout.vertex(v)
assertEquals(v, layout.nearestVertex(p, layout.radius * 0.5f, all))
// A slightly clumsy tap still lands on it.
assertEquals(v, layout.nearestVertex(Pt(p.x + layout.radius * 0.2f, p.y - layout.radius * 0.15f), layout.radius * 0.55f, all))
}
assertNull(layout.nearestVertex(Pt(-100f, -100f), layout.radius * 0.5f, all))
}
@Test
fun `hit testing only considers the candidates it is given`() {
val layout = CatanGeometry.fit(500f, 500f)
val at = layout.vertex(5)
assertNull(layout.nearestVertex(at, layout.radius * 0.3f, listOf(6)))
assertEquals(6, layout.nearestVertex(layout.vertex(6), layout.radius * 0.3f, listOf(6)))
assertEquals(3, layout.nearestEdge(layout.edgeMidpoint(3), 5f, listOf(3, 4)))
assertEquals(7, layout.nearestHex(layout.hexCenter(7), 5f, listOf(7, 8)))
}
@Test
fun `the nearest candidate wins when several are in reach`() {
val layout = CatanGeometry.fit(500f, 500f)
val a = 10
val b = CatanBoard.VERTEX_NEIGHBORS[a].first()
val pa = layout.vertex(a)
val pb = layout.vertex(b)
// A point two-thirds of the way toward b is nearer b, though both are in reach.
val at = Pt(pa.x + (pb.x - pa.x) * 0.66f, pa.y + (pb.y - pa.y) * 0.66f)
assertEquals(b, layout.nearestVertex(at, layout.radius * 2f, listOf(a, b)))
}
@Test
fun `harbour markers sit outside the coast`() {
val layout = CatanGeometry.fit(500f, 500f)
val centre = Pt(500f / 2f, 500f / 2f)
for (e in CatanBoard.PORT_POSITIONS) {
val anchor = layout.portAnchor(e)
val mid = layout.edgeMidpoint(e)
assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre))
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * PORT_OFFSET) < 1f)
}
}
}
@@ -0,0 +1,348 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
import org.prolibertate.games.game.catan.BuildSettlement
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanBoard
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.DevCard
import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayYearOfPlenty
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.SetupPlace
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
import org.prolibertate.games.ui.game.catan.CatanFixtures.withBuilding
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
import org.prolibertate.games.ui.game.catan.CatanFixtures.withRoad
class CatanInteractionTest {
private val rich = hand(
Resource.LUMBER to 5, Resource.BRICK to 5, Resource.WOOL to 5,
Resource.GRAIN to 5, Resource.ORE to 5,
)
// --- Setup ---------------------------------------------------------------
@Test
fun `setup first offers every settlement spot, then only the roads beside the chosen one`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val setup = legal.filterIsInstance<SetupPlace>()
val first = CatanInteraction.targets(state, legal, null, null)
assertEquals(setup.map { it.vertex }.toSet(), first.vertices)
assertTrue(first.edges.isEmpty() && first.hexes.isEmpty())
val vertex = first.vertices.first()
val second = CatanInteraction.targets(state, legal, null, vertex)
assertTrue(second.vertices.isEmpty())
assertEquals(setup.filter { it.vertex == vertex }.map { it.edge }.toSet(), second.edges)
}
@Test
fun `a setup placement is two taps - the settlement, then its road`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
val edge = CatanInteraction.targets(state, legal, null, vertex).edges.first()
assertEquals(TapOutcome.PickSetupVertex(vertex), CatanInteraction.onVertexTap(vertex, legal, null, null))
assertEquals(TapOutcome.Submit(SetupPlace(vertex, edge)), CatanInteraction.onEdgeTap(edge, legal, null, vertex))
// A road tapped before any settlement is chosen means nothing.
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, null, null))
}
@Test
fun `a road that does not touch the chosen settlement is refused`() {
val state = start()
val legal = CatanRules.legalMoves(state, 0)
val vertex = CatanInteraction.targets(state, legal, null, null).vertices.first()
val beside = CatanBoard.VERTEX_EDGES[vertex].toSet()
val far = (0 until CatanBoard.EDGE_COUNT).first { it !in beside }
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(far, legal, null, vertex))
}
// --- Building -------------------------------------------------------------
/** Seat 0 owns a settlement at vertex 0 and a road two edges long leading away from it. */
private fun established(): Triple<org.prolibertate.games.game.catan.CatanState, Int, Int> {
val e1 = CatanBoard.VERTEX_EDGES[0][0]
val w = CatanBoard.otherEnd(e1, 0)
val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 }
val x = CatanBoard.otherEnd(e2, w)
val state = playing {
withHand(0, rich).withBuilding(0, BuildingType.SETTLEMENT, 0).withRoad(e1, 0).withRoad(e2, 0)
}
return Triple(state, x, e2)
}
@Test
fun `nothing on the board is tappable until a build mode is chosen`() {
val (state, _, _) = established()
val legal = CatanRules.legalMoves(state, 0)
assertTrue(CatanInteraction.targets(state, legal, null, null).isEmpty)
}
@Test
fun `build modes target exactly what the engine allows`() {
val (state, x, _) = established()
val legal = CatanRules.legalMoves(state, 0)
assertEquals(
legal.filterIsInstance<BuildRoad>().map { it.edge }.toSet(),
CatanInteraction.targets(state, legal, BuildKind.ROAD, null).edges,
)
assertTrue(x in CatanInteraction.targets(state, legal, BuildKind.SETTLEMENT, null).vertices)
assertEquals(setOf(0), CatanInteraction.targets(state, legal, BuildKind.CITY, null).vertices)
assertEquals(setOf(BuildKind.ROAD, BuildKind.SETTLEMENT, BuildKind.CITY), CatanInteraction.canBuild(legal))
}
@Test
fun `a tap builds only when the matching mode is armed and the spot is legal`() {
val (state, x, _) = established()
val legal = CatanRules.legalMoves(state, 0)
val road = legal.filterIsInstance<BuildRoad>().first().edge
assertEquals(TapOutcome.Submit(BuildSettlement(x)), CatanInteraction.onVertexTap(x, legal, BuildKind.SETTLEMENT, null))
assertEquals(TapOutcome.Submit(BuildCity(0)), CatanInteraction.onVertexTap(0, legal, BuildKind.CITY, null))
assertEquals(TapOutcome.Submit(BuildRoad(road)), CatanInteraction.onEdgeTap(road, legal, BuildKind.ROAD, null))
// No mode armed: nothing happens.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, null, null))
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(road, legal, null, null))
// A settlement on an occupied crossing is not offered.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(0, legal, BuildKind.SETTLEMENT, null))
// The wrong mode for the spot.
assertEquals(TapOutcome.Ignored, CatanInteraction.onVertexTap(x, legal, BuildKind.CITY, null))
}
@Test
fun `without the cards, nothing can be built`() {
val (state, _, _) = established()
val broke = state.withHand(0, hand())
val legal = CatanRules.legalMoves(broke, 0)
assertTrue(CatanInteraction.canBuild(legal).isEmpty())
assertFalse(CatanInteraction.canBuyDevCard(legal))
assertTrue(CatanInteraction.canEndTurn(legal))
}
@Test
fun `free roads from a road building card need no build mode`() {
val (state, _, _) = established()
val free = state.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
val legal = CatanRules.legalMoves(free, 0)
val targets = CatanInteraction.targets(free, legal, null, null)
assertTrue(targets.edges.isNotEmpty())
val edge = targets.edges.first()
assertEquals(TapOutcome.Submit(BuildRoad(edge)), CatanInteraction.onEdgeTap(edge, legal, null, null))
assertTrue(CatanInteraction.canEndFreeRoads(legal))
}
// --- The robber ---------------------------------------------------------------
@Test
fun `the robber may go to any hex but the one it is on`() {
val state = playing { copy(phase = CatanPhase.ROBBER) }
val legal = CatanRules.legalMoves(state, 0)
val targets = CatanInteraction.targets(state, legal, null, null)
assertEquals((0 until CatanBoard.HEX_COUNT).filter { it != state.robber }.toSet(), targets.hexes)
val hex = targets.hexes.first()
// Nobody has built anything, so there is nobody to rob: the move goes straight through.
assertEquals(
TapOutcome.Submit(MoveRobber(CatanBoard.HEXES[hex], null)),
CatanInteraction.onHexTap(hex, legal),
)
assertEquals(TapOutcome.Ignored, CatanInteraction.onHexTap(state.robber, legal))
}
@Test
fun `a hex with two possible victims asks which`() {
val base = playing { copy(phase = CatanPhase.ROBBER) }
val hex = (0 until CatanBoard.HEX_COUNT).first { it != base.robber }
val corners = CatanBoard.HEX_CORNERS[hex]
val state = base
.withBuilding(corners[0], BuildingType.SETTLEMENT, 1)
.withBuilding(corners[3], BuildingType.SETTLEMENT, 2)
.withHand(1, hand(Resource.ORE to 1))
.withHand(2, hand(Resource.GRAIN to 1))
val legal = CatanRules.legalMoves(state, 0)
val outcome = CatanInteraction.onHexTap(hex, legal)
assertTrue(outcome is TapOutcome.PickRobberVictim)
outcome as TapOutcome.PickRobberVictim
assertEquals(CatanBoard.HEXES[hex], outcome.hex)
assertEquals(setOf(1, 2), outcome.victims.toSet())
}
// --- Discarding -----------------------------------------------------------------
@Test
fun `a seven costs half the hand, rounded down`() {
assertEquals(4, CatanInteraction.discardQuota(9))
assertEquals(4, CatanInteraction.discardQuota(8))
assertEquals(3, CatanInteraction.discardQuota(7))
}
@Test
fun `a discard is found only when it names exactly an allowed set of cards`() {
val state = playing {
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
.withHand(0, hand(Resource.LUMBER to 3, Resource.BRICK to 3, Resource.WOOL to 3))
}
val legal = CatanRules.legalMoves(state, 0)
assertTrue(legal.all { it is Discard })
val good = listOf(Resource.LUMBER, Resource.LUMBER, Resource.BRICK, Resource.WOOL)
assertNotNull(CatanInteraction.discardMove(legal, good))
// Order does not matter.
assertNotNull(CatanInteraction.discardMove(legal, good.reversed()))
// Too few, and more than are held.
assertNull(CatanInteraction.discardMove(legal, good.take(3)))
assertNull(CatanInteraction.discardMove(legal, List(4) { Resource.LUMBER }))
}
// --- Trading ---------------------------------------------------------------------
@Test
fun `bank trades list what can be given and what it can buy`() {
val state = playing { withHand(0, hand(Resource.GRAIN to 4)) }
val legal = CatanRules.legalMoves(state, 0)
assertTrue(legal.any { it is MaritimeTrade })
val trades = CatanInteraction.bankTrades(legal)
assertEquals(setOf(Resource.GRAIN), trades.keys)
assertEquals(Resource.entries.filter { it != Resource.GRAIN }, trades.getValue(Resource.GRAIN))
assertEquals(4, CatanInteraction.bankRatio(state, 0, Resource.GRAIN))
}
@Test
fun `an offer is allowed only to another player, for something different, from cards held`() {
val state = playing { withHand(0, hand(Resource.LUMBER to 2)) }
val lumber = listOf(Resource.LUMBER)
val grain = listOf(Resource.GRAIN)
assertTrue(CatanInteraction.offerAllowed(state, 0, 1, lumber, grain))
assertFalse("to oneself", CatanInteraction.offerAllowed(state, 0, 0, lumber, grain))
assertFalse("nothing offered", CatanInteraction.offerAllowed(state, 0, 1, emptyList(), grain))
assertFalse("nothing asked", CatanInteraction.offerAllowed(state, 0, 1, lumber, emptyList()))
assertFalse("like for like", CatanInteraction.offerAllowed(state, 0, 1, lumber, lumber))
assertFalse("more than held", CatanInteraction.offerAllowed(state, 0, 1, List(3) { Resource.LUMBER }, grain))
assertFalse("not their turn", CatanInteraction.offerAllowed(state, 1, 0, lumber, grain))
}
// --- Development cards -------------------------------------------------------------
@Test
fun `a development card is playable unless it was bought this turn or is a victory point`() {
val state = playing {
copy(
devHands = listOf(
listOf(
DevCard(1, DevCardType.KNIGHT, boughtTurn = 0),
DevCard(2, DevCardType.VICTORY_POINT, boughtTurn = 0),
DevCard(3, DevCardType.KNIGHT, boughtTurn = 1),
),
emptyList(),
emptyList(),
),
)
}
val legal = CatanRules.legalMoves(state, 0)
val hand = CatanInteraction.devHand(state, 0, legal)
// Sorted by type then id: the two knights, then the victory point.
assertEquals(listOf(1, 3, 2), hand.map { it.card.id })
assertEquals(listOf(true, false, false), hand.map { it.playable })
assertEquals(listOf<Any>(PlayKnight(1)), hand.first().plays)
}
@Test
fun `monopoly offers one play per resource`() {
val plays = Resource.entries.map { PlayMonopoly(9, it) }
val choices = CatanInteraction.monopolyChoices(plays)
assertEquals(Resource.entries.toSet(), choices.keys)
assertEquals(PlayMonopoly(9, Resource.ORE), choices.getValue(Resource.ORE))
}
@Test
fun `year of plenty narrows to the cards that still complete a take`() {
val plays = listOf(
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.BRICK)),
PlayYearOfPlenty(4, listOf(Resource.LUMBER, Resource.LUMBER)),
PlayYearOfPlenty(4, listOf(Resource.ORE)),
)
assertEquals(
setOf(Resource.LUMBER, Resource.BRICK, Resource.ORE),
CatanInteraction.plentyAvailable(plays, emptyList()),
)
assertEquals(
setOf(Resource.LUMBER, Resource.BRICK),
CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER)),
)
assertEquals(setOf(Resource.LUMBER), CatanInteraction.plentyAvailable(plays, listOf(Resource.BRICK)))
assertTrue(CatanInteraction.plentyAvailable(plays, listOf(Resource.LUMBER, Resource.LUMBER)).isEmpty())
assertEquals(plays[0], CatanInteraction.plentyMove(plays, listOf(Resource.BRICK, Resource.LUMBER)))
assertEquals(plays[2], CatanInteraction.plentyMove(plays, listOf(Resource.ORE)))
assertNull(CatanInteraction.plentyMove(plays, listOf(Resource.GRAIN)))
}
// --- Trade replies ------------------------------------------------------------------
@Test
fun `the addressee of an offer is given accept and decline`() {
val base = playing {
withHand(0, hand(Resource.LUMBER to 1)).withHand(1, hand(Resource.GRAIN to 1))
}
val offer = org.prolibertate.games.game.catan.TradeOffer(
by = 0, to = 1, give = listOf(Resource.LUMBER), get = listOf(Resource.GRAIN),
)
val pending = base.copy(phase = CatanPhase.TRADE_PENDING, pendingTrade = offer)
val legal = CatanRules.legalMoves(pending, 1)
assertEquals(true to true, CatanInteraction.tradeResponses(legal))
// If the addressee no longer holds what was asked, only decline remains.
val short = pending.withHand(1, hand())
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
}
// --- Rolled-hex highlight --------------------------------------------------
@Test
fun `rolled hexes are those carrying the number, minus the robber's`() {
val base = playing(turn = 0)
val number = base.numbers.first { it != 0 && it != 7 }
val state = base.copy(lastDice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6))
val all = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] == number }
assertTrue(all.isNotEmpty())
assertEquals(all.filter { it != state.robber }.toSet(), CatanInteraction.rolledHexes(state))
val blocked = state.copy(robber = all.first())
assertFalse(all.first() in CatanInteraction.rolledHexes(blocked))
}
@Test
fun `nothing is highlighted before this turn's roll, on a seven, or in setup`() {
val rolled = playing(turn = 0).copy(lastDice = listOf(3, 3))
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.AWAIT_ROLL)).isEmpty())
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = listOf(3, 4))).isEmpty())
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.SETUP_FIRST)).isEmpty())
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = emptyList())).isEmpty())
}
}
@@ -0,0 +1,101 @@
package org.prolibertate.games.ui.game.catan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.Port
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.ui.game.catan.CatanFixtures.hand
import org.prolibertate.games.ui.game.catan.CatanFixtures.playing
import org.prolibertate.games.ui.game.catan.CatanFixtures.start
import org.prolibertate.games.ui.game.catan.CatanFixtures.withHand
class CatanTextTest {
private val names = listOf("Ann", "Bo", "Cy")
@Test
fun `seats are named, with You for the local player and a number when a name is unknown`() {
val seats = CatanSeats(me = 1, names = names)
assertEquals("You", seats.label(1))
assertEquals("Ann", seats.label(0))
assertEquals("you", seats.lower(1))
assertEquals("Seat 4", seats.label(3))
assertEquals("Seat 1", CatanSeats(me = 1).label(0))
assertEquals("Seat 1", CatanSeats(me = 1, names = listOf(" ")).label(0))
}
@Test
fun `setup prompts say what to place next`() {
val state = start()
val mine = CatanSeats(0, names)
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = false, building = null).contains("Place a settlement"))
assertTrue(CatanText.prompt(state, mine, setupVertexChosen = true, building = null).contains("road"))
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).startsWith("Ann is placing"))
val second = state.copy(phase = CatanPhase.SETUP_SECOND, setupIndex = 3)
assertTrue(CatanText.prompt(second, CatanSeats(2, names), false, null).contains("second settlement"))
}
@Test
fun `the turn prompts follow the phase`() {
val roll = playing().copy(phase = CatanPhase.AWAIT_ROLL)
assertTrue(CatanText.prompt(roll, CatanSeats(0, names), false, null).contains("Roll the dice"))
assertEquals("Ann is rolling…", CatanText.prompt(roll, CatanSeats(1, names), false, null))
val turn = playing()
assertTrue(CatanText.prompt(turn, CatanSeats(0, names), false, null).contains("Your turn"))
assertEquals("Tap an edge to build a road.", CatanText.prompt(turn, CatanSeats(0, names), false, BuildKind.ROAD))
assertEquals("Bo is playing…", CatanText.prompt(playing(turn = 1), CatanSeats(0, names), false, null))
val robber = playing().copy(phase = CatanPhase.ROBBER)
assertTrue(CatanText.prompt(robber, CatanSeats(0, names), false, null).contains("robber"))
}
@Test
fun `the discard prompt names the quota`() {
val state = playing {
copy(phase = CatanPhase.DISCARDING, discardSeat = 0)
.withHand(0, hand(Resource.LUMBER to 9))
}
assertTrue(CatanText.prompt(state, CatanSeats(0, names), false, null).contains("discard 4"))
assertTrue(CatanText.prompt(state, CatanSeats(1, names), false, null).contains("Ann must discard half"))
}
@Test
fun `road building counts down in the singular and the plural`() {
val two = playing().copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
val one = two.copy(pendingFreeRoads = 1)
assertTrue(CatanText.prompt(two, CatanSeats(0, names), false, null).endsWith("2 more roads."))
assertTrue(CatanText.prompt(one, CatanSeats(0, names), false, null).endsWith("1 more road."))
}
@Test
fun `the end of the game names the winner`() {
val over = playing().copy(phase = CatanPhase.GAME_OVER, winner = 1)
assertEquals("Bo won the game.", CatanText.prompt(over, CatanSeats(0, names), false, null))
assertEquals("You won!", CatanText.winnerHeadline(over, CatanSeats(1, names)))
assertEquals("Bo won", CatanText.winnerHeadline(over, CatanSeats(0, names)))
}
@Test
fun `cards read as a sorted tally`() {
assertEquals(
"2 Lumber + 1 Grain",
CatanText.cards(listOf(Resource.GRAIN, Resource.LUMBER, Resource.LUMBER)),
)
assertEquals("nothing", CatanText.cards(emptyList()))
assertEquals(
"Ann offers 1 Lumber to you for 2 Ore",
CatanText.tradeSummary("Ann", "you", listOf(Resource.LUMBER), listOf(Resource.ORE, Resource.ORE)),
)
}
@Test
fun `costs and harbours read as the rules state them`() {
assertEquals("1 lumber + 1 brick", CatanText.ROAD_COST)
assertEquals("2 grain + 3 ore", CatanText.CITY_COST)
assertEquals("2:1 ore", CatanText.portName(Port(edge = 0, resource = Resource.ORE, ratio = 2)))
assertEquals("3:1 any", CatanText.portName(Port(edge = 0, resource = null, ratio = 3)))
}
}
+2
View File
@@ -42,6 +42,8 @@ Naming: the catalogue id, lowercase, e.g. `crazy8s` → `RULES-crazy8s.md`.
- Build, CI, releases and OTA are described in the CI section of the root - Build, CI, releases and OTA are described in the CI section of the root
[README](../README.md). [README](../README.md).
- [UI-catan.md](UI-catan.md) — how the Katan screen is layered, and how to restyle,
re-copy or re-lay-out it without touching the rules.
- `licenses/` — third-party licences that must travel with the app - `licenses/` — third-party licences that must travel with the app
(Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`). (Uncial Antiqua under the SIL OFL; the app itself is GPL-3.0, see `/LICENSE`).
- Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root. - Repo-level agent notes live in [`CLAUDE.md`](../CLAUDE.md) at the root.
+17 -7
View File
@@ -19,8 +19,7 @@ behaviour is pinned by `CatanBoardTest`, `CatanRulesTest` and `CatanAiTest`.
- **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at - **Points to win — *toggle*.** Defaults to 10. `CatanOptions.pointsToWin`, at
least 2. A lower target makes a test game short. least 2. A lower target makes a test game short.
The setup screen that exposes these lands with the screen task; the engine reads The setup screen exposes both (players: 3 or 4; game to 5, 8, 10 or 12 points).
them from the table configuration today.
## Board ## Board
@@ -160,7 +159,19 @@ its edge.
A settlement goes on an **empty vertex** at least two edges from every standing A settlement goes on an **empty vertex** at least two edges from every standing
building and connected to the seat's own road network. A city upgrades one of building and connected to the seat's own road network. A city upgrades one of
the seat's own settlements. A road goes on an empty edge that connects to the the seat's own settlements. A road goes on an empty edge that connects to the
seat's network (a road or a building at one of its ends). seat's network: it must start from a crossing where the seat has a building, or
where one of its roads ends.
**An opponent's settlement or city on a crossing breaks the network there.** A
road cannot be laid past it, even from a crossing where one of your own roads
ends — only a crossing you own a building on is a way through. The same rule
applies to the free roads of a Road Building card, and it is the same wall that
cuts Longest Road (below).
**Pieces are limited**: each player has **15 roads, 5 settlements and 4 cities**.
A player who has laid them all cannot build another of that kind. Raising a
settlement into a city returns the settlement to the supply, so the five
settlements remain available to be laid again.
The development deck is the published 25 cards: **14 Knights, 2 Road Building, The development deck is the published 25 cards: **14 Knights, 2 Road Building,
2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the 2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
@@ -291,10 +302,9 @@ timing rules; Longest Road and Largest Army worth five; victory at ten.
- **No player-to-player trade over the wire beyond accept/decline.** The engine - **No player-to-player trade over the wire beyond accept/decline.** The engine
supports it; whether the UI surfaces it is the screen task's concern. supports it; whether the UI surfaces it is the screen task's concern.
- **No trading away a development card.** Not part of the base game. - **No trading away a development card.** Not part of the base game.
- **The setup screen is not part of this task.** `CatanOptions` (players, target - **The screen is built, not yet verified on two devices.** The Katan screen and
points) is read from the table configuration; the screen that sets it, and the setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a
catalogue descriptor's `available` flag, land with the screen and verification dev-channel preview until two-device play has been verified.
tasks.
- **The AI does not look ahead.** It scores one move deep with a fixed resource - **The AI does not look ahead.** It scores one move deep with a fixed resource
valuation. It plays legally and plausibly; it is not a strong opponent, and no valuation. It plays legally and plausibly; it is not a strong opponent, and no
strength claim is made. strength claim is made.
+142
View File
@@ -0,0 +1,142 @@
# 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.
6. **The layout must not move.** Everything outside the board has a *fixed
height*, and the board takes whatever is left, so the island's scale is the
same all game. Concretely: a control is *enabled or disabled*, never
*present or absent* (the action bar always shows every button); text that can
vary in length is pinned to a fixed number of lines (`minLines = maxLines`);
a line that is sometimes empty is still drawn (a non-breaking space), as the
award line on a player card is. A change that makes any non-board element
taller or shorter at runtime will rescale the board under the player's thumb.
## 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.